diff options
Diffstat (limited to '')
128 files changed, 70085 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] + ); +} diff --git a/comm/mail/base/content/FilterListDialog.xhtml b/comm/mail/base/content/FilterListDialog.xhtml new file mode 100644 index 0000000000..397bdea0d8 --- /dev/null +++ b/comm/mail/base/content/FilterListDialog.xhtml @@ -0,0 +1,168 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/filterDialog.css" type="text/css"?> + +<!DOCTYPE html [ + <!ENTITY % filtersDTD SYSTEM "chrome://messenger/locale/FilterListDialog.dtd">%filtersDTD; +]> +<html id="filterListDialog" xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="mailnews:filterlist" + lightweightthemes="true" + persist="width height screenX screenY" + scrolling="false" + style="min-width: 800px; min-height: 500px;"> +<head> + <title>&window.title;</title> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/searchWidgets.js"></script> + <script defer="defer" src="chrome://messenger/content/FilterListDialog.js"></script> +</head> +<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <stringbundle id="bundle_filter" src="chrome://messenger/locale/filter.properties"/> + <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/> + + <keyset> + <key key="&closeCmd.key;" modifiers="accel" oncommand="if (onFilterClose()) { window.close(); }"/> + <key keycode="VK_ESCAPE" oncommand="if (onFilterClose()) { window.close(); }"/> + </keyset> + + <hbox id="filterHeader" align="center"> + <label value="&filtersForPrefix.label;" + accesskey="&filtersForPrefix.accesskey;" control="serverMenu"/> + + <menulist id="serverMenu" + class="folderMenuItem" flex="1"> + <menupopup is="folder-menupopup" id="serverMenuPopup" + mode="filters" + class="menulist-menupopup" + expandFolders="nntp" + showFileHereLabel="true" + showAccountsFileHere="true" + oncommand="setFilterFolder(event.target._folder);"/> + </menulist> + <search-textbox id="searchBox" + class="themeableSearchBox" + oncommand="rebuildFilterList();" + placeholder="&searchBox.emptyText;" + isempty="true"/> + </hbox> + <separator class="thin"/> + <hbox id="filterListGrid" flex="1"> + <vbox id="filterListBox" flex="1"> + <hbox> + <label id="filterListLabel" control="filterList" flex="1"> + &filterHeader.label; + </label> + <label id="countBox"/> + </hbox> + <richlistbox id="filterList" flex="1" onselect="updateButtons();" + seltype="multiple" + onkeypress="onFilterListKeyPress(event);"> + <treecols> + <treecol id="nameColumn" label="&nameColumn.label;" flex="1"/> + <treecol id="activeColumn" label="&activeColumn.label;" style="width: 100px;"/> + </treecols> + </richlistbox> + <vbox> + <separator class="thin"/> + <hbox align="center"> + <label id="folderPickerPrefix" value="&folderPickerPrefix.label;" + accesskey="&folderPickerPrefix.accesskey;" + disabled="true" control="runFiltersFolder"/> + <menulist id="runFiltersFolder" disabled="true" flex="1" + class="folderMenuItem" + displayformat="verbose"> + <menupopup is="folder-menupopup" id="runFiltersPopup" + class="menulist-menupopup" + showFileHereLabel="true" + showAccountsFileHere="false" + oncommand="setRunFolder(event.target._folder);"/> + </menulist> + <button id="runFiltersButton" + label="&runFilters.label;" + accesskey="&runFilters.accesskey;" + runlabel="&runFilters.label;" + runaccesskey="&runFilters.accesskey;" + stoplabel="&stopFilters.label;" + stopaccesskey="&stopFilters.accesskey;" + oncommand="runSelectedFilters();" disabled="true"/> + </hbox> + </vbox> + </vbox> + <vbox id="filterActionButtons"> + <label value=""/> + <toolbarbutton is="toolbarbutton-menu-button" id="newButton" + type="menu" + label="&newButton.label;" + accesskey="&newButton.accesskey;" + oncommand="onNewFilter();"> + <menupopup id="newFilterMenupopup"> + <menuitem label="&newButton.label;" + accesskey="&newButton.accesskey;"/> + <menuitem id="copyToNewButton" + label="&newButton.popupCopy.label;" + accesskey="&newButton.popupCopy.accesskey;" + oncommand="onCopyToNewFilter(); event.stopPropagation();"/> + </menupopup> + </toolbarbutton> + <button id="editButton" label="&editButton.label;" + accesskey="&editButton.accesskey;" + oncommand="onEditFilter();"/> + <button id="deleteButton" + label="&deleteButton.label;" + accesskey="&deleteButton.accesskey;" + oncommand="onDeleteFilter();"/> + <separator class="thin"/> + <button id="reorderTopButton" + label="&reorderTopButton;" + accesskey="&reorderTopButton.accessKey;" + tooltiptext="&reorderTopButton.toolTip;" + oncommand="onTop(event);"/> + <button id="reorderUpButton" + label="&reorderUpButton.label;" + accesskey="&reorderUpButton.accesskey;" + class="up" + oncommand="onUp(event);"/> + <button id="reorderDownButton" + label="&reorderDownButton.label;" + accesskey="&reorderDownButton.accesskey;" + class="down" + oncommand="onDown(event);"/> + <button id="reorderBottomButton" + label="&reorderBottomButton;" + accesskey="&reorderBottomButton.accessKey;" + tooltiptext="&reorderBottomButton.toolTip;" + oncommand="onBottom(event);"/> + <vbox flex="1" pack="end"> + <button id="filterLogButton" + label="&viewLogButton.label;" + accesskey="&viewLogButton.accesskey;" + oncommand="viewLog();"/> + </vbox> + </vbox> + </hbox> + + <separator class="thin"/> + + <hbox id="statusbar" role="status"> + <label id="statusText" flex="1" crop="end"/> + <hbox id="statusbar-progresspanel" class="statusbarpanel-progress" collapsed="true"> + <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/> + </hbox> + </hbox> + +</html:body> +</html> diff --git a/comm/mail/base/content/SearchDialog.js b/comm/mail/base/content/SearchDialog.js new file mode 100644 index 0000000000..127370d7f2 --- /dev/null +++ b/comm/mail/base/content/SearchDialog.js @@ -0,0 +1,650 @@ +/* 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 ../../../mailnews/extensions/newsblog/newsblogOverlay.js */ +/* import-globals-from ../../../mailnews/search/content/searchTerm.js */ +/* import-globals-from folderDisplay.js */ +/* import-globals-from globalOverlay.js */ +/* import-globals-from threadPane.js */ + +/* globals nsMsgStatusFeedback */ // From mailWindow.js + +"use strict"; + +var { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); +var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm"); + +var messenger; +var msgWindow; + +var gCurrentFolder; + +var gFolderDisplay; + +var gFolderPicker; +var gStatusFeedback; +var gSearchBundle; + +// Datasource search listener -- made global as it has to be registered +// and unregistered in different functions. +var gDataSourceSearchListener; +var gViewSearchListener; + +var gSearchStopButton; + +// Should we try to search online? +var gSearchOnline = false; + +window.addEventListener("load", searchOnLoad); +window.addEventListener("unload", event => { + onSearchStop(); + searchOnUnload(); +}); + +// Controller object for search results thread pane +var nsSearchResultsController = { + supportsCommand(command) { + switch (command) { + case "cmd_delete": + case "cmd_shiftDelete": + case "button_delete": + case "cmd_open": + case "file_message_button": + case "open_in_folder_button": + case "saveas_vf_button": + case "cmd_selectAll": + return true; + default: + return false; + } + }, + + // this controller only handles commands + // that rely on items being selected in + // the search results pane. + isCommandEnabled(command) { + var enabled = true; + + switch (command) { + case "open_in_folder_button": + if (gFolderDisplay.selectedCount != 1) { + enabled = false; + } + break; + case "cmd_delete": + case "cmd_shiftDelete": + case "button_delete": + // this assumes that advanced searches don't cross accounts + if (gFolderDisplay.selectedCount <= 0) { + enabled = false; + } + break; + case "saveas_vf_button": + // need someway to see if there are any search criteria... + return true; + case "cmd_selectAll": + return true; + default: + if (gFolderDisplay.selectedCount <= 0) { + enabled = false; + } + break; + } + + return enabled; + }, + + doCommand(command) { + switch (command) { + case "cmd_open": + MsgOpenSelectedMessages(); + return true; + + case "cmd_delete": + case "button_delete": + MsgDeleteSelectedMessages(Ci.nsMsgViewCommandType.deleteMsg); + return true; + + case "cmd_shiftDelete": + MsgDeleteSelectedMessages(Ci.nsMsgViewCommandType.deleteNoTrash); + return true; + + case "open_in_folder_button": + OpenInFolder(); + return true; + + case "saveas_vf_button": + saveAsVirtualFolder(); + return true; + + case "cmd_selectAll": + // move the focus to the search results pane + GetThreadTree().focus(); + gFolderDisplay.doCommand(Ci.nsMsgViewCommandType.selectAll); + return true; + + default: + return false; + } + }, + + onEvent(event) {}, +}; + +function UpdateMailSearch(caller) { + document.commandDispatcher.updateCommands("mail-search"); +} + +function SetAdvancedSearchStatusText(aNumHits) {} + +/** + * Subclass the FolderDisplayWidget to deal with UI specific to the search + * window. + */ +function SearchFolderDisplayWidget() { + FolderDisplayWidget.call(this); +} + +SearchFolderDisplayWidget.prototype = { + __proto__: FolderDisplayWidget.prototype, + + // folder display will want to show the thread pane; we need do nothing + _showThreadPane() {}, + + onSearching(aIsSearching) { + if (aIsSearching) { + // Search button becomes the "stop" button + gSearchStopButton.setAttribute( + "label", + gSearchBundle.GetStringFromName("labelForStopButton") + ); + gSearchStopButton.setAttribute( + "accesskey", + gSearchBundle.GetStringFromName("labelForStopButton.accesskey") + ); + + // update our toolbar equivalent + UpdateMailSearch("new-search"); + // spin the meteors + gStatusFeedback._startMeteors(); + // tell the user that we're searching + gStatusFeedback.showStatusString( + gSearchBundle.GetStringFromName("searchingMessage") + ); + } else { + // Stop button resumes being the "search" button + gSearchStopButton.setAttribute( + "label", + gSearchBundle.GetStringFromName("labelForSearchButton") + ); + gSearchStopButton.setAttribute( + "accesskey", + gSearchBundle.GetStringFromName("labelForSearchButton.accesskey") + ); + + // update our toolbar equivalent + UpdateMailSearch("done-search"); + // stop spining the meteors + gStatusFeedback._stopMeteors(); + // set the result test + this.updateStatusResultText(); + } + }, + + /** + * If messages were removed, we might have lost some search results and so + * should update our search result text. Also, defer to our super-class. + */ + onMessagesRemoved() { + // result text is only for when we are not searching + if (!this.view.searching) { + this.updateStatusResultText(); + } + this.__proto__.__proto__.onMessagesRemoved.call(this); + }, + + updateStatusResultText() { + let rowCount = this.view.dbView.rowCount; + let statusMsg; + + if (rowCount == 0) { + statusMsg = gSearchBundle.GetStringFromName("noMatchesFound"); + } else { + statusMsg = PluralForm.get( + rowCount, + gSearchBundle.GetStringFromName("matchesFound") + ); + statusMsg = statusMsg.replace("#1", rowCount); + } + + gStatusFeedback.showStatusString(statusMsg); + }, +}; + +function searchOnLoad() { + TagUtils.loadTagsIntoCSS(document); + initializeSearchWidgets(); + initializeSearchWindowWidgets(); + // eslint-disable-next-line no-global-assign + messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger); + + gSearchBundle = Services.strings.createBundle( + "chrome://messenger/locale/search.properties" + ); + gSearchStopButton.setAttribute( + "label", + gSearchBundle.GetStringFromName("labelForSearchButton") + ); + gSearchStopButton.setAttribute( + "accesskey", + gSearchBundle.GetStringFromName("labelForSearchButton.accesskey") + ); + + // eslint-disable-next-line no-global-assign + gFolderDisplay = new SearchFolderDisplayWidget(); + gFolderDisplay.messenger = messenger; + gFolderDisplay.msgWindow = msgWindow; + gFolderDisplay.tree = document.getElementById("threadTree"); + + // The view is initially unsorted; get the persisted sortDirection column + // and set up the user's desired sort. This synthetic view is not backed by + // a db, so secondary sorts and custom columns are not supported here. + let sortCol = gFolderDisplay.tree.querySelector("[sortDirection]"); + let sortType, sortOrder; + if (sortCol) { + sortType = Ci.nsMsgViewSortType[gFolderDisplay.COLUMNS_MAP.get(sortCol.id)]; + sortOrder = + sortCol.getAttribute("sortDirection") == "descending" + ? Ci.nsMsgViewSortOrder.descending + : Ci.nsMsgViewSortOrder.ascending; + } + + gFolderDisplay.view.openSearchView(); + gFolderDisplay.makeActive(); + + if (sortType) { + gFolderDisplay.view.sort(sortType, sortOrder); + } + + if (window.arguments && window.arguments[0]) { + updateSearchFolderPicker(window.arguments[0].folder); + } + + // Trigger searchTerm.js to create the first criterion. + onMore(null); + // Make sure all the buttons are configured. + UpdateMailSearch("onload"); +} + +function searchOnUnload() { + gFolderDisplay.close(); + top.controllers.removeController(nsSearchResultsController); + + msgWindow.closeWindow(); +} + +function initializeSearchWindowWidgets() { + gFolderPicker = document.getElementById("searchableFolders"); + gSearchStopButton = document.getElementById("search-button"); + hideMatchAllItem(); + + // eslint-disable-next-line no-global-assign + msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance( + Ci.nsIMsgWindow + ); + msgWindow.domWindow = window; + msgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL; + + gStatusFeedback = new nsMsgStatusFeedback(); + msgWindow.statusFeedback = gStatusFeedback; + + // functionality to enable/disable buttons using nsSearchResultsController + // depending of whether items are selected in the search results thread pane. + top.controllers.insertControllerAt(0, nsSearchResultsController); +} + +function onSearchStop() { + gFolderDisplay.view.search.session.interruptSearch(); +} + +function onResetSearch(event) { + onReset(event); + gFolderDisplay.view.search.clear(); + + gStatusFeedback.showStatusString(""); +} + +function updateSearchFolderPicker(folder) { + gCurrentFolder = folder; + gFolderPicker.menupopup.selectFolder(folder); + + var searchOnline = document.getElementById("checkSearchOnline"); + // We will hide and disable the search online checkbox if we are offline, or + // if the folder does not support online search. + + // Any offlineSupportLevel > 0 is an online server like IMAP or news. + if (gCurrentFolder?.server.offlineSupportLevel && !Services.io.offline) { + searchOnline.hidden = false; + searchOnline.disabled = false; + } else { + searchOnline.hidden = true; + searchOnline.disabled = true; + } + if (gCurrentFolder) { + setSearchScope(GetScopeForFolder(gCurrentFolder)); + } +} + +function updateSearchLocalSystem() { + setSearchScope(GetScopeForFolder(gCurrentFolder)); +} + +function UpdateAfterCustomHeaderChange() { + updateSearchAttributes(); +} + +function onEnterInSearchTerm() { + // on enter + // if not searching, start the search + // if searching, stop and then start again + if ( + gSearchStopButton.getAttribute("label") == + gSearchBundle.GetStringFromName("labelForSearchButton") + ) { + onSearch(); + } else { + onSearchStop(); + onSearch(); + } +} + +function onSearch() { + let viewWrapper = gFolderDisplay.view; + let searchTerms = getSearchTerms(); + + viewWrapper.beginViewUpdate(); + viewWrapper.search.userTerms = searchTerms.length ? searchTerms : null; + viewWrapper.search.onlineSearch = gSearchOnline; + viewWrapper.searchFolders = getSearchFolders(); + viewWrapper.endViewUpdate(); +} + +/** + * Get the current set of search terms, returning them as a list. We filter out + * dangerous and insane predicates. + */ +function getSearchTerms() { + let termCreator = gFolderDisplay.view.search.session; + + let searchTerms = []; + // searchTerm.js stores wrapper objects in its gSearchTerms array. Pluck + // them. + for (let iTerm = 0; iTerm < gSearchTerms.length; iTerm++) { + let termWrapper = gSearchTerms[iTerm].obj; + let realTerm = termCreator.createTerm(); + termWrapper.saveTo(realTerm); + // A header search of "" is illegal for IMAP and will cause us to + // explode. You don't want that and I don't want that. So let's check + // if the bloody term is a subject search on a blank string, and if it + // is, let's secretly not add the term. Everyone wins! + if ( + realTerm.attrib != Ci.nsMsgSearchAttrib.Subject || + realTerm.value.str != "" + ) { + searchTerms.push(realTerm); + } + } + + return searchTerms; +} + +/** + * @returns the list of folders the search should cover. + */ +function getSearchFolders() { + let searchFolders = []; + + if (!gCurrentFolder.isServer && !gCurrentFolder.noSelect) { + searchFolders.push(gCurrentFolder); + } + + var searchSubfolders = document.getElementById( + "checkSearchSubFolders" + ).checked; + if ( + gCurrentFolder && + (searchSubfolders || gCurrentFolder.isServer || gCurrentFolder.noSelect) + ) { + AddSubFolders(gCurrentFolder, searchFolders); + } + + return searchFolders; +} + +function AddSubFolders(folder, outFolders) { + for (let nextFolder of folder.subFolders) { + if (!(nextFolder.flags & Ci.nsMsgFolderFlags.Virtual)) { + if (!nextFolder.noSelect) { + outFolders.push(nextFolder); + } + + AddSubFolders(nextFolder, outFolders); + } + } +} + +function AddSubFoldersToURI(folder) { + var returnString = ""; + + for (let nextFolder of folder.subFolders) { + if (!(nextFolder.flags & Ci.nsMsgFolderFlags.Virtual)) { + if (!nextFolder.noSelect && !nextFolder.isServer) { + if (returnString.length > 0) { + returnString += "|"; + } + returnString += nextFolder.URI; + } + var subFoldersString = AddSubFoldersToURI(nextFolder); + if (subFoldersString.length > 0) { + if (returnString.length > 0) { + returnString += "|"; + } + returnString += subFoldersString; + } + } + } + return returnString; +} + +/** + * Determine the proper search scope to use for a folder, so that the user is + * presented with a correct list of search capabilities. The user may manually + * request on online search for certain server types. To determine if the + * folder body may be searched, we ignore whether autosync is enabled, + * figuring that after the user manually syncs, they would still expect that + * body searches would work. + * + * The available search capabilities also depend on whether the user is + * currently online or offline. Although that is also checked by the server, + * we do it ourselves because we have a more complex response to offline + * than the server's searchScope attribute provides. + * + * This method only works for real folders. + */ +function GetScopeForFolder(folder) { + let searchOnline = document.getElementById("checkSearchOnline"); + if (!searchOnline.disabled && searchOnline.checked) { + gSearchOnline = true; + return folder.server.searchScope; + } + gSearchOnline = false; + + // We are going to search offline. The proper search scope may depend on + // whether we have the body and/or junk available or not. + let localType; + try { + localType = folder.server.localStoreType; + } catch (e) {} // On error, we'll just assume the default mailbox type + + let hasBody = folder.getFlag(Ci.nsMsgFolderFlags.Offline); + let nsMsgSearchScope = Ci.nsMsgSearchScope; + switch (localType) { + case "news": + // News has four offline scopes, depending on whether junk and body + // are available. + let hasJunk = + folder.getInheritedStringProperty( + "dobayes.mailnews@mozilla.org#junk" + ) == "true"; + if (hasJunk && hasBody) { + return nsMsgSearchScope.localNewsJunkBody; + } + if (hasJunk) { + // and no body + return nsMsgSearchScope.localNewsJunk; + } + if (hasBody) { + // and no junk + return nsMsgSearchScope.localNewsBody; + } + // We don't have offline message bodies or junk processing. + return nsMsgSearchScope.localNews; + + case "imap": + // Junk is always enabled for imap, so the offline scope only depends on + // whether the body is available. + + // If we are the root folder, use the server property for body rather + // than the folder property. + if (folder.isServer) { + let imapServer = folder.server.QueryInterface(Ci.nsIImapIncomingServer); + if (imapServer && imapServer.offlineDownload) { + hasBody = true; + } + } + + if (!hasBody) { + return nsMsgSearchScope.onlineManual; + } + // fall through to default + default: + return nsMsgSearchScope.offlineMail; + } +} + +function goUpdateSearchItems(commandset) { + for (var i = 0; i < commandset.children.length; i++) { + var commandID = commandset.children[i].getAttribute("id"); + if (commandID) { + goUpdateCommand(commandID); + } + } +} + +// used to toggle functionality for Search/Stop button. +function onSearchButton(event) { + if ( + event.target.label == + gSearchBundle.GetStringFromName("labelForSearchButton") + ) { + onSearch(); + } else { + onSearchStop(); + } +} + +function MsgDeleteSelectedMessages(aCommandType) { + gFolderDisplay.hintAboutToDeleteMessages(); + gFolderDisplay.doCommand(aCommandType); +} + +/** + * Move selected messages to the destination folder + * + * @param destFolder {nsIMsgFolder} - destination folder + */ +function MoveMessageInSearch(destFolder) { + gFolderDisplay.hintAboutToDeleteMessages(); + gFolderDisplay.doCommandWithFolder( + Ci.nsMsgViewCommandType.moveMessages, + destFolder + ); +} + +function OpenInFolder() { + MailUtils.displayMessageInFolderTab(gFolderDisplay.selectedMessage); +} + +function saveAsVirtualFolder() { + var searchFolderURIs = gCurrentFolder.URI; + + var searchSubfolders = document.getElementById( + "checkSearchSubFolders" + ).checked; + if ( + gCurrentFolder && + (searchSubfolders || gCurrentFolder.isServer || gCurrentFolder.noSelect) + ) { + var subFolderURIs = AddSubFoldersToURI(gCurrentFolder); + if (subFolderURIs.length > 0) { + searchFolderURIs += "|" + subFolderURIs; + } + } + + var searchOnline = document.getElementById("checkSearchOnline"); + var doOnlineSearch = searchOnline.checked && !searchOnline.disabled; + + window.openDialog( + "chrome://messenger/content/virtualFolderProperties.xhtml", + "", + "chrome,titlebar,modal,centerscreen,resizable=yes", + { + folder: window.arguments[0].folder, + searchTerms: getSearchTerms(), + searchFolderURIs, + searchOnline: doOnlineSearch, + } + ); +} + +function MsgOpenSelectedMessages() { + // Toggle message body (feed summary) and content-base url in message pane or + // load in browser, per pref, otherwise open summary or web page in new window + // or tab, per that pref. + if ( + gFolderDisplay.treeSelection && + gFolderDisplay.treeSelection.count == 1 && + gFolderDisplay.selectedMessageIsFeed + ) { + let msgHdr = gFolderDisplay.selectedMessage; + if ( + document.documentElement.getAttribute("windowtype") == "mail:3pane" && + FeedMessageHandler.onOpenPref == + FeedMessageHandler.kOpenToggleInMessagePane + ) { + let showSummary = FeedMessageHandler.shouldShowSummary(msgHdr, true); + FeedMessageHandler.setContent(msgHdr, showSummary); + return; + } + if ( + FeedMessageHandler.onOpenPref == FeedMessageHandler.kOpenLoadInBrowser + ) { + setTimeout(FeedMessageHandler.loadWebPage, 20, msgHdr, { browser: true }); + return; + } + } + + // This is somewhat evil. If we're in a 3pane window, we'd have a tabmail + // element and would pass it in here, ensuring that if we open tabs, we use + // this tabmail to open them. If we aren't, then we wouldn't, so + // displayMessages would look for a 3pane window and open tabs there. + MailUtils.displayMessages( + gFolderDisplay.selectedMessages, + gFolderDisplay.view, + document.getElementById("tabmail") + ); +} diff --git a/comm/mail/base/content/SearchDialog.xhtml b/comm/mail/base/content/SearchDialog.xhtml new file mode 100644 index 0000000000..6d6ff82713 --- /dev/null +++ b/comm/mail/base/content/SearchDialog.xhtml @@ -0,0 +1,151 @@ +<?xml version="1.0"?> +# 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/. + +#filter substitution +#define SEARCH_WINDOW +<?xml-stylesheet href="chrome://messenger/skin/searchDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderPane.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/tagColors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> + +<!DOCTYPE html [ + <!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" > + %messengerDTD; + <!ENTITY % SearchDialogDTD SYSTEM "chrome://messenger/locale/SearchDialog.dtd"> + %SearchDialogDTD; + <!ENTITY % searchTermDTD SYSTEM "chrome://messenger/locale/searchTermOverlay.dtd"> + %searchTermDTD; +]> +<html id="searchMailWindow" xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + windowtype="mailnews:search" + scrolling="false" + style="min-width:52em; min-height:34em;" + lightweightthemes="true" + persist="screenX screenY width height sizemode"> +<head> + <title>&searchDialogTitle.label;</title> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/searchWidgets.js"></script> + <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script> + <script defer="defer" src="chrome://messenger/content/folderDisplay.js"></script> + <script defer="defer" src="chrome://messenger/content/threadPane.js"></script> + <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script> + <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/searchTerm.js"></script> + <script defer="defer" src="chrome://messenger/content/dateFormat.js"></script> + <script defer="defer" src="chrome://messenger/content/messenger.js"></script> + <script defer="defer" src="chrome://messenger/content/SearchDialog.js"></script> +</head> +<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/> + <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/> + + <commands id="commands"> + <commandset id="mailSearchItems" + commandupdater="true" + events="mail-search" + oncommandupdate="goUpdateSearchItems(this)"> + <command id="cmd_open" oncommand="goDoCommand('cmd_open')" disabled="true"/> + <command id="button_delete" oncommand="goDoCommand('button_delete')" disabled="true"/> + <command id="open_in_folder_button" oncommand="goDoCommand('open_in_folder_button')" disabled="true"/> + <command id="saveas_vf_button" oncommand="goDoCommand('saveas_vf_button')" disabled="false"/> + <command id="file_message_button" oncommand="MoveMessageInSearch(event.target._folder);" disabled="true"/> + <command id="cmd_delete" oncommand="goDoCommand('cmd_delete')" disabled="true"/> + <command id="cmd_shiftDelete" oncommand="goDoCommand('cmd_shiftDelete');"/> + </commandset> + </commands> + + <keyset id="mailKeys"> + <key key="&closeCmd.key;" modifiers="accel" oncommand="window.close();"/> + <key keycode="VK_ESCAPE" oncommand="window.close();"/> +#ifdef XP_MACOSX + <key id="key_delete" keycode="VK_BACK" command="cmd_delete"/> + <key id="key_delete2" keycode="VK_DELETE" command="cmd_delete"/> + <key id="cmd_shiftDelete" keycode="VK_BACK" + oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/> + <key id="cmd_shiftDelete2" keycode="VK_DELETE" + oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/> +#else + <key id="key_delete" keycode="VK_DELETE" command="cmd_delete"/> + <key id="cmd_shiftDelete" keycode="VK_DELETE" + oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/> +#endif + </keyset> + + <vbox id="searchTerms" class="themeable-brighttext" persist="height"> + <vbox> + <hbox align="center"> + <label value="&searchHeading.label;" accesskey="&searchHeading.accesskey;" + control="searchableFolders"/> + <menulist id="searchableFolders" class="folderMenuItem" + displayformat="verbose"> + <menupopup is="folder-menupopup" class="menulist-menupopup" + mode="search" showAccountsFileHere="true" showFileHereLabel="true" + oncommand="updateSearchFolderPicker(event.target._folder);"/> + </menulist> + <spacer style="flex: 10 10;"/> + <button id="search-button" oncommand="onSearchButton(event);" default="true"/> + </hbox> + + <hbox align="center"> + <checkbox id="checkSearchSubFolders" + label="&searchSubfolders.label;" + accesskey="&searchSubfolders.accesskey;" + checked="true" + persist="checked"/> + <spacer style="flex: 10 10;"/> + <button label="&resetButton.label;" oncommand="onResetSearch(event);" accesskey="&resetButton.accesskey;"/> + </hbox> + <hbox align="center"> + <checkbox id="checkSearchOnline" + label="&searchOnServer.label;" + accesskey="&searchOnServer.accesskey;" + oncommand="updateSearchLocalSystem();" + persist="checked"/> + </hbox> + </vbox> + + <hbox style="flex: 1 1 0; min-height: 0;"> + <vbox id="searchTermListBox" flex="1"> +#include ../../../mailnews/search/content/searchTerm.inc.xhtml + </hbox> + </vbox> + + <splitter id="gray_horizontal_splitter" persist="state" orient="vertical"/> + + <vbox id="searchResults" persist="height"> + <vbox id="searchResultListBox" flex="1"> +#include threadTree.inc.xhtml + </vbox> + <hbox align="start"> + <button label="&openButton.label;" id="openButton" command="cmd_open" accesskey="&openButton.accesskey;"/> + <button id="fileMessageButton" type="menu" label="&moveButton.label;" + accesskey="&moveButton.accesskey;" + command="file_message_button"> + <menupopup is="folder-menupopup" showFileHereLabel="true" mode="filing"/> + </button> + + <button label="&deleteButton.label;" id="deleteButton" command="button_delete" accesskey="&deleteButton.accesskey;"/> + <button label="&openInFolder.label;" id="openInFolderButton" command="open_in_folder_button" accesskey="&openInFolder.accesskey;" /> + <button label="&saveAsVFButton.label;" id="saveAsVFButton" command="saveas_vf_button" accesskey="&saveAsVFButton.accesskey;" /> + <spacer flex="1" /> + </hbox> + </vbox> + + <hbox id="status-bar" class="statusbar chromeclass-status" role="status"> + <label id="statusText" class="statusbarpanel" crop="end" flex="1"/> + <hbox id="statusbar-progresspanel" class="statusbarpanel statusbarpanel-progress" collapsed="true"> + <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/> + </hbox> + </hbox> +</html:body> +</html> diff --git a/comm/mail/base/content/about3Pane.js b/comm/mail/base/content/about3Pane.js new file mode 100644 index 0000000000..43e09a0acc --- /dev/null +++ b/comm/mail/base/content/about3Pane.js @@ -0,0 +1,7260 @@ +/* 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/. */ + +/* globals MozElements */ + +// mailCommon.js +/* globals commandController, DBViewWrapper, dbViewWrapperListener, + nsMsgViewIndex_None, VirtualFolderHelper */ +/* globals gDBView: true, gFolder: true, gViewWrapper: true */ + +// mailContext.js +/* globals mailContextMenu */ + +// globalOverlay.js +/* globals goDoCommand, goUpdateCommand */ + +// mail-offline.js +/* globals MailOfflineMgr */ + +// junkCommands.js +/* globals analyzeMessagesForJunk deleteJunkInFolder filterFolderForJunk */ + +// quickFilterBar.js +/* globals quickFilterBar */ + +// utilityOverlay.js +/* globals validateFileName */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { FolderTreeProperties } = ChromeUtils.import( + "resource:///modules/FolderTreeProperties.jsm" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { UIDensity } = ChromeUtils.import("resource:///modules/UIDensity.jsm"); +var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + FeedUtils: "resource:///modules/FeedUtils.jsm", + FolderUtils: "resource:///modules/FolderUtils.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + MailE10SUtils: "resource:///modules/MailE10SUtils.jsm", + MailStringUtils: "resource:///modules/MailStringUtils.jsm", + TagUtils: "resource:///modules/TagUtils.jsm", +}); + +const XULSTORE_URL = "chrome://messenger/content/messenger.xhtml"; + +const messengerBundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" +); + +const { getDefaultColumns, getDefaultColumnsForCardsView, isOutgoing } = + ChromeUtils.importESModule( + "chrome://messenger/content/thread-pane-columns.mjs" + ); + +// As defined in nsMsgDBView.h. +const MSG_VIEW_FLAG_DUMMY = 0x20000000; + +/** + * The TreeListbox widget that displays folders. + */ +var folderTree; +/** + * The TreeView widget that displays the message list. + */ +var threadTree; +/** + * A XUL browser that displays web pages when required. + */ +var webBrowser; +/** + * A XUL browser that displays single messages. This browser always has + * about:message loaded. + */ +var messageBrowser; +/** + * A XUL browser that displays summaries of multiple messages or threads. + * This browser always has multimessageview.xhtml loaded. + */ +var multiMessageBrowser; +/** + * A XUL browser that displays Account Central when an account's root folder + * is selected. + */ +var accountCentralBrowser; + +window.addEventListener("DOMContentLoaded", async event => { + if (event.target != document) { + return; + } + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); + + folderTree = document.getElementById("folderTree"); + accountCentralBrowser = document.getElementById("accountCentralBrowser"); + + paneLayout.init(); + folderPaneContextMenu.init(); + await folderPane.init(); + await threadPane.init(); + threadPaneHeader.init(); + await messagePane.init(); + + // Set up the initial state using information which may have been provided + // by mailTabs.js, or the saved state from the XUL store, or the defaults. + try { + // Do this in a try so that errors (e.g. bad data) don't prevent doing the + // rest of the important 3pane initialization below. + restoreState(window.openingState); + } catch (e) { + console.warn(`Couldn't restore state: ${e.message}`, e); + } + delete window.openingState; + + // Finally, add the folderTree listener and trigger it. Earlier events + // (triggered by `folderPane.init` and possibly `restoreState`) are ignored + // to avoid unnecessarily loading the thread tree or Account Central. + folderTree.addEventListener("select", folderPane); + folderTree.dispatchEvent(new CustomEvent("select")); + + // Attach the progress listener for the webBrowser. For the messageBrowser this + // happens in the "aboutMessageLoaded" event from aboutMessage.js. + // For the webBrowser, we can do it here directly. + top.contentProgress.addProgressListenerToBrowser(webBrowser); + + mailContextMenu.init(); +}); + +window.addEventListener("unload", () => { + MailServices.mailSession.RemoveFolderListener(folderListener); + gViewWrapper?.close(); + folderPane.uninit(); + threadPane.uninit(); + threadPaneHeader.uninit(); +}); + +var paneLayout = { + init() { + this.folderPaneSplitter = document.getElementById("folderPaneSplitter"); + this.messagePaneSplitter = document.getElementById("messagePaneSplitter"); + + for (let [splitter, properties, storeID] of [ + [this.folderPaneSplitter, ["width"], "folderPaneBox"], + [this.messagePaneSplitter, ["height", "width"], "messagepaneboxwrapper"], + ]) { + for (let property of properties) { + let value = Services.xulStore.getValue(XULSTORE_URL, storeID, property); + if (value) { + splitter[property] = value; + } + } + + splitter.storeAttr = function (attrName, attrValue) { + Services.xulStore.setValue(XULSTORE_URL, storeID, attrName, attrValue); + }; + + splitter.addEventListener("splitter-resized", () => { + if (splitter.resizeDirection == "vertical") { + splitter.storeAttr("height", splitter.height); + } else { + splitter.storeAttr("width", splitter.width); + } + }); + } + + this.messagePaneSplitter.addEventListener("splitter-collapsed", () => { + // Clear any loaded page or messages. + messagePane.clearAll(); + this.messagePaneSplitter.storeAttr("collapsed", true); + }); + + this.messagePaneSplitter.addEventListener("splitter-expanded", () => { + // Load the selected messages. + threadTree.dispatchEvent(new CustomEvent("select")); + this.messagePaneSplitter.storeAttr("collapsed", false); + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "layoutPreference", + "mail.pane_config.dynamic", + null, + (name, oldValue, newValue) => this.setLayout(newValue) + ); + this.setLayout(this.layoutPreference); + threadPane.updateThreadView( + Services.xulStore.getValue(XULSTORE_URL, "threadPane", "view") + ); + }, + + setLayout(preference) { + document.body.classList.remove( + "layout-classic", + "layout-vertical", + "layout-wide" + ); + switch (preference) { + case 1: + document.body.classList.add("layout-wide"); + this.messagePaneSplitter.resizeDirection = "vertical"; + break; + case 2: + document.body.classList.add("layout-vertical"); + this.messagePaneSplitter.resizeDirection = "horizontal"; + break; + default: + document.body.classList.add("layout-classic"); + this.messagePaneSplitter.resizeDirection = "vertical"; + break; + } + }, + + get accountCentralVisible() { + return document.body.classList.contains("account-central"); + }, + get folderPaneVisible() { + return !this.folderPaneSplitter.isCollapsed; + }, + set folderPaneVisible(visible) { + this.folderPaneSplitter.isCollapsed = !visible; + }, + get messagePaneVisible() { + return !this.messagePaneSplitter?.isCollapsed; + }, + set messagePaneVisible(visible) { + this.messagePaneSplitter.isCollapsed = !visible; + }, +}; + +var folderPaneContextMenu = { + /** + * @type {XULPopupElement} + */ + _menupopup: null, + + /** + * Commands handled by commandController. + * + * @type {Object.<string, string>} + */ + _commands: { + "folderPaneContext-new": "cmd_newFolder", + "folderPaneContext-remove": "cmd_deleteFolder", + "folderPaneContext-rename": "cmd_renameFolder", + "folderPaneContext-compact": "cmd_compactFolder", + "folderPaneContext-properties": "cmd_properties", + "folderPaneContext-favoriteFolder": "cmd_toggleFavoriteFolder", + }, + + /** + * Current state of commandController commands. Set to null to invalidate + * the states. + * + * @type {Object.<string, boolean>|null} + */ + _commandStates: null, + + init() { + this._menupopup = document.getElementById("folderPaneContext"); + this._menupopup.addEventListener("popupshowing", this); + this._menupopup.addEventListener("popuphidden", this); + this._menupopup.addEventListener("command", this); + folderTree.addEventListener("select", this); + }, + + handleEvent(event) { + switch (event.type) { + case "popupshowing": + this.onPopupShowing(event); + break; + case "popuphidden": + this.onPopupHidden(event); + break; + case "command": + this.onCommand(event); + break; + case "select": + this._commandStates = null; + break; + } + }, + + /** + * The folder that this context menu is operating on. This will be `gFolder` + * unless the menu was opened by right-clicking on another folder. + * + * @type {nsIMsgFolder} + */ + get activeFolder() { + return this._overrideFolder || gFolder; + }, + + /** + * Override the folder that this context menu should operate on. The effect + * lasts until `clearOverrideFolder` is called by `onPopupHidden`. + * + * @param {nsIMsgFolder} folder + */ + setOverrideFolder(folder) { + this._overrideFolder = folder; + this._commandStates = null; + }, + + /** + * Clear the overriding folder, and go back to using `gFolder`. + */ + clearOverrideFolder() { + this._overrideFolder = null; + this._commandStates = null; + }, + + /** + * Gets the enabled state of a command. If the state is unknown (because the + * selected folder has changed) the states of all the commands are worked + * out together to save unnecessary work. + * + * @param {string} command + */ + getCommandState(command) { + let folder = this.activeFolder; + if (!folder || FolderUtils.isSmartTagsFolder(folder)) { + return false; + } + if (this._commandStates === null) { + let { + canCompact, + canCreateSubfolders, + canRename, + deletable, + flags, + isServer, + server, + URI, + } = folder; + let isJunk = flags & Ci.nsMsgFolderFlags.Junk; + let isVirtual = flags & Ci.nsMsgFolderFlags.Virtual; + let isNNTP = server.type == "nntp"; + if (isNNTP && !isServer) { + // `folderPane.deleteFolder` has a special case for this. + deletable = true; + } + let isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder); + let showNewFolderItem = + (!isNNTP && canCreateSubfolders) || flags & Ci.nsMsgFolderFlags.Inbox; + + this._commandStates = { + cmd_newFolder: showNewFolderItem, + cmd_deleteFolder: isJunk + ? FolderUtils.canRenameDeleteJunkMail(URI) + : deletable, + cmd_renameFolder: + (!isServer && + canRename && + !(flags & Ci.nsMsgFolderFlags.SpecialUse)) || + isVirtual || + (isJunk && FolderUtils.canRenameDeleteJunkMail(URI)), + cmd_compactFolder: + !isVirtual && + (isServer || canCompact) && + folder.isCommandEnabled("cmd_compactFolder"), + cmd_emptyTrash: !isNNTP, + cmd_properties: !isServer && !isSmartTagsFolder, + cmd_toggleFavoriteFolder: !isServer && !isSmartTagsFolder, + }; + } + return this._commandStates[command]; + }, + + onPopupShowing(event) { + if (event.target != this._menupopup) { + return; + } + + function showItem(id, show) { + let item = document.getElementById(id); + if (item) { + item.hidden = !show; + } + } + + function checkItem(id, checked) { + let item = document.getElementById(id); + if (item) { + // Always convert truthy/falsy to boolean before string. + item.setAttribute("checked", !!checked); + } + } + + // Ask commandController about the commands it controls. + for (let [id, command] of Object.entries(this._commands)) { + showItem(id, commandController.isCommandEnabled(command)); + } + + let folder = this.activeFolder; + let { canCreateSubfolders, flags, isServer, isSpecialFolder, server } = + folder; + let isJunk = flags & Ci.nsMsgFolderFlags.Junk; + let isTrash = isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true); + let isVirtual = flags & Ci.nsMsgFolderFlags.Virtual; + let isRealFolder = !isServer && !isVirtual; + let isSmartVirtualFolder = FolderUtils.isSmartVirtualFolder(folder); + let isSmartTagsFolder = FolderUtils.isSmartTagsFolder(folder); + let serverType = server.type; + + showItem( + "folderPaneContext-getMessages", + (isServer && serverType != "none") || + (["nntp", "rss"].includes(serverType) && !isTrash && !isVirtual) + ); + let showPauseAll = isServer && FeedUtils.isFeedFolder(folder); + showItem("folderPaneContext-pauseAllUpdates", showPauseAll); + if (showPauseAll) { + let optionsAcct = FeedUtils.getOptionsAcct(server); + checkItem("folderPaneContext-pauseAllUpdates", !optionsAcct.doBiff); + } + let showPaused = !isServer && FeedUtils.getFeedUrlsInFolder(folder); + showItem("folderPaneContext-pauseUpdates", showPaused); + if (showPaused) { + let properties = FeedUtils.getFolderProperties(folder); + checkItem( + "folderPaneContext-pauseUpdates", + properties.includes("isPaused") + ); + } + + showItem("folderPaneContext-searchMessages", !isVirtual); + if (isVirtual) { + showItem("folderPaneContext-subscribe", false); + } else if (serverType == "rss" && !isTrash) { + showItem("folderPaneContext-subscribe", true); + } else { + showItem( + "folderPaneContext-subscribe", + isServer && ["imap", "nntp"].includes(serverType) + ); + } + showItem( + "folderPaneContext-newsUnsubscribe", + isRealFolder && serverType == "nntp" + ); + + let showNewFolderItem = + (serverType != "nntp" && canCreateSubfolders) || + flags & Ci.nsMsgFolderFlags.Inbox; + if (showNewFolderItem) { + document + .getElementById("folderPaneContext-new") + .setAttribute( + "label", + messengerBundle.GetStringFromName( + isServer || flags & Ci.nsMsgFolderFlags.Inbox + ? "newFolder" + : "newSubfolder" + ) + ); + } + + showItem( + "folderPaneContext-markMailFolderAllRead", + !isServer && !isSmartTagsFolder && serverType != "nntp" + ); + showItem( + "folderPaneContext-markNewsgroupAllRead", + isRealFolder && serverType == "nntp" + ); + showItem( + "folderPaneContext-emptyTrash", + isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true) + ); + showItem("folderPaneContext-emptyJunk", isJunk); + showItem( + "folderPaneContext-sendUnsentMessages", + flags & Ci.nsMsgFolderFlags.Queue + ); + + checkItem( + "folderPaneContext-favoriteFolder", + flags & Ci.nsMsgFolderFlags.Favorite + ); + showItem("folderPaneContext-markAllFoldersRead", isServer); + + showItem("folderPaneContext-settings", isServer); + + showItem("folderPaneContext-manageTags", isSmartTagsFolder); + + // If source folder is virtual, allow only "move" within its own server. + // Don't show "copy" and "again" and don't show "recent" and "favorite". + // Also, check if this is a top-level smart folder, e.g., virtual "Inbox" + // in unified folder view or a Tags folder. If so, don't show "move". + let movePopup = document.getElementById("folderContext-movePopup"); + if (isVirtual) { + showItem("folderPaneContext-copyMenu", false); + let showMove = true; + if (isSmartVirtualFolder || isSmartTagsFolder) { + showMove = false; + } + showItem("folderPaneContext-moveMenu", showMove); + if (showMove) { + let rootURI = MailUtils.getOrCreateFolder( + this.activeFolder.rootFolder.URI + ); + movePopup.parentFolder = rootURI; + } + } else { + // Non-virtual. Don't allow move or copy of special use or root folder. + let okToMoveCopy = !(isServer || flags & Ci.nsMsgFolderFlags.SpecialUse); + if (okToMoveCopy) { + // Set the move menu to show all accounts. + movePopup.parentFolder = null; + } + showItem("folderPaneContext-moveMenu", okToMoveCopy); + showItem("folderPaneContext-copyMenu", okToMoveCopy); + } + + let lastItem; + for (let child of document.getElementById("folderPaneContext").children) { + if (child.localName == "menuseparator") { + child.hidden = !lastItem || lastItem.localName == "menuseparator"; + } + if (!child.hidden) { + lastItem = child; + } + } + if (lastItem.localName == "menuseparator") { + lastItem.hidden = true; + } + }, + + onPopupHidden(event) { + if (event.target != this._menupopup) { + return; + } + + folderTree + .querySelector(".context-menu-target") + ?.classList.remove("context-menu-target"); + this.clearOverrideFolder(); + }, + + /** + * Check if the transfer mode selected from folder context menu is "copy". + * If "copy" (!isMove) is selected and the copy is within the same server, + * silently change to mode "move". + * Do the transfer and return true if moved, false if copied. + * + * @param {boolean} isMove + * @param {nsIMsgFolder} sourceFolder + * @param {nsIMsgFolder} targetFolder + */ + transferFolder(isMove, sourceFolder, targetFolder) { + if (!isMove && sourceFolder.server == targetFolder.server) { + // Don't allow folder copy within the same server; only move allowed. + // Can't copy folder intra-server, change to move. + isMove = true; + } + // Do the transfer. A slight delay in calling copyFolder() helps the + // folder-menupopup chain of items get properly closed so the next folder + // context popup can occur. + setTimeout(() => + MailServices.copy.copyFolder( + sourceFolder, + targetFolder, + isMove, + null, + top.msgWindow + ) + ); + return isMove; + }, + + onCommand(event) { + let folder = this.activeFolder; + // If commandController handles this command, ask it to do so. + if (event.target.id in this._commands) { + commandController.doCommand(this._commands[event.target.id], folder); + return; + } + + let topChromeWindow = window.browsingContext.topChromeWindow; + switch (event.target.id) { + case "folderPaneContext-getMessages": + topChromeWindow.MsgGetMessage([folder]); + break; + case "folderPaneContext-pauseAllUpdates": + topChromeWindow.MsgPauseUpdates( + [folder], + event.target.getAttribute("checked") == "true" + ); + break; + case "folderPaneContext-pauseUpdates": + topChromeWindow.MsgPauseUpdates( + [folder], + event.target.getAttribute("checked") == "true" + ); + break; + case "folderPaneContext-openNewTab": + topChromeWindow.MsgOpenNewTabForFolders([folder], { + event, + folderPaneVisible: !paneLayout.folderPaneSplitter.isCollapsed, + messagePaneVisible: !paneLayout.messagePaneSplitter.isCollapsed, + }); + break; + case "folderPaneContext-openNewWindow": + topChromeWindow.MsgOpenNewWindowForFolder(folder.URI, -1); + break; + case "folderPaneContext-searchMessages": + commandController.doCommand("cmd_searchMessages", folder); + break; + case "folderPaneContext-subscribe": + topChromeWindow.MsgSubscribe(folder); + break; + case "folderPaneContext-newsUnsubscribe": + topChromeWindow.MsgUnsubscribe([folder]); + break; + case "folderPaneContext-markMailFolderAllRead": + case "folderPaneContext-markNewsgroupAllRead": + if (folder.flags & Ci.nsMsgFolderFlags.Virtual) { + topChromeWindow.MsgMarkAllRead( + VirtualFolderHelper.wrapVirtualFolder(folder).searchFolders + ); + } else { + topChromeWindow.MsgMarkAllRead([folder]); + } + break; + case "folderPaneContext-emptyTrash": + folderPane.emptyTrash(folder); + break; + case "folderPaneContext-emptyJunk": + folderPane.emptyJunk(folder); + break; + case "folderPaneContext-sendUnsentMessages": + topChromeWindow.SendUnsentMessages(); + break; + case "folderPaneContext-properties": + folderPane.editFolder(folder); + break; + case "folderPaneContext-markAllFoldersRead": + topChromeWindow.MsgMarkAllFoldersRead([folder]); + break; + case "folderPaneContext-settings": + folderPane.editFolder(folder); + break; + case "folderPaneContext-manageTags": + goDoCommand("cmd_manageTags"); + break; + default: { + // Handle folder context menu items move to, copy to. + let isMove = false; + let isCopy = false; + let targetFolder; + if ( + document + .getElementById("folderPaneContext-moveMenu") + .contains(event.target) + ) { + // A move is requested via foldermenu-popup. + isMove = true; + } else if ( + document + .getElementById("folderPaneContext-copyMenu") + .contains(event.target) + ) { + // A copy is requested via foldermenu-popup. + isCopy = true; + } + if (isMove || isCopy) { + if (!targetFolder) { + targetFolder = event.target._folder; + } + isMove = this.transferFolder(isMove, folder, targetFolder); + // Save in prefs the target folder URI and if this was a move or + // copy. This is to fill in the next folder or message context + // menu item "Move|Copy to <TargetFolderName> Again". + Services.prefs.setStringPref( + "mail.last_msg_movecopy_target_uri", + targetFolder.URI + ); + Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove); + } + break; + } + } + }, +}; + +var folderPane = { + _initialized: false, + + /** + * If the local folders should be hidden. + * @type {boolean} + */ + _hideLocalFolders: false, + + _modes: { + all: { + name: "all", + active: false, + canBeCompact: false, + + initServer(server) { + let serverRow = folderPane._createServerRow(this.name, server); + folderPane._insertInServerOrder(this.containerList, serverRow); + folderPane._addSubFolders(server.rootFolder, serverRow, this.name); + }, + + addFolder(parentFolder, childFolder) { + FolderTreeProperties.setIsExpanded(childFolder.URI, this.name, true); + if ( + childFolder.server.hidden || + folderPane.getRowForFolder(childFolder, this.name) + ) { + // We're not displaying this server, or the folder already exists in + // the folder tree. Was `addFolder` called twice? + return; + } + if (!parentFolder) { + folderPane._insertInServerOrder( + this.containerList, + folderPane._createServerRow(this.name, childFolder.server) + ); + return; + } + + let parentRow = folderPane.getRowForFolder(parentFolder, this.name); + if (!parentRow) { + console.error("no parentRow for ", parentFolder.URI, childFolder.URI); + } + // To auto-expand non-root imap folders, imap URL "discoverchildren" is + // triggered -- but actually only occurs if server settings configured + // to ignore subscriptions. (This also occurs in _onExpanded() for + // manual folder expansion.) + if (parentFolder.server.type == "imap" && !parentFolder.isServer) { + parentFolder.QueryInterface(Ci.nsIMsgImapMailFolder); + parentFolder.performExpand(top.msgWindow); + } + folderTree.expandRow(parentRow); + let childRow = folderPane._createFolderRow(this.name, childFolder); + folderPane._addSubFolders(childFolder, childRow, "all"); + parentRow.insertChildInOrder(childRow); + }, + + removeFolder(parentFolder, childFolder) { + folderPane.getRowForFolder(childFolder, this.name)?.remove(); + }, + + changeAccountOrder() { + folderPane._reapplyServerOrder(this.containerList); + }, + }, + smart: { + name: "smart", + active: false, + canBeCompact: false, + + _folderTypes: [ + { flag: Ci.nsMsgFolderFlags.Inbox, name: "Inbox" }, + { flag: Ci.nsMsgFolderFlags.Drafts, name: "Drafts" }, + { flag: Ci.nsMsgFolderFlags.Templates, name: "Templates" }, + { flag: Ci.nsMsgFolderFlags.SentMail, name: "Sent" }, + { flag: Ci.nsMsgFolderFlags.Archive, name: "Archives" }, + { flag: Ci.nsMsgFolderFlags.Junk, name: "Junk" }, + { flag: Ci.nsMsgFolderFlags.Trash, name: "Trash" }, + // { flag: Ci.nsMsgFolderFlags.Queue, name: "Outbox" }, + ], + + init() { + this._smartServer = MailServices.accounts.findServer( + "nobody", + "smart mailboxes", + "none" + ); + if (!this._smartServer) { + this._smartServer = MailServices.accounts.createIncomingServer( + "nobody", + "smart mailboxes", + "none" + ); + // We don't want the "smart" server/account leaking out into the ui in + // other places, so set it as hidden. + this._smartServer.hidden = true; + let account = MailServices.accounts.createAccount(); + account.incomingServer = this._smartServer; + } + this._smartServer.prettyName = + messengerBundle.GetStringFromName("unifiedAccountName"); + let smartRoot = this._smartServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + + let allFlags = 0; + this._folderTypes.forEach(folderType => (allFlags |= folderType.flag)); + + for (let folderType of this._folderTypes) { + let folder = smartRoot.getChildWithURI( + `${smartRoot.URI}/${folderType.name}`, + false, + true + ); + if (!folder) { + try { + let searchFolders = []; + + function recurse(folder) { + let subFolders; + try { + subFolders = folder.subFolders; + } catch (ex) { + console.error( + new Error( + `Unable to access the subfolders of ${folder.URI}`, + { cause: ex } + ) + ); + } + if (!subFolders?.length) { + return; + } + + for (let sf of subFolders) { + // Add all of the subfolders except the ones that belong to + // a different folder type. + if (!(sf.flags & allFlags)) { + searchFolders.push(sf); + recurse(sf); + } + } + } + + for (let server of MailServices.accounts.allServers) { + for (let f of server.rootFolder.getFoldersWithFlags( + folderType.flag + )) { + searchFolders.push(f); + recurse(f); + } + } + + folder = smartRoot.createLocalSubfolder(folderType.name); + folder.flags |= Ci.nsMsgFolderFlags.Virtual | folderType.flag; + + let msgDatabase = folder.msgDatabase; + let folderInfo = msgDatabase.dBFolderInfo; + + folderInfo.setCharProperty("searchStr", "ALL"); + folderInfo.setCharProperty( + "searchFolderUri", + searchFolders.map(f => f.URI).join("|") + ); + folderInfo.setUint32Property("searchFolderFlag", folderType.flag); + folderInfo.setBooleanProperty("searchOnline", true); + msgDatabase.summaryValid = true; + msgDatabase.close(true); + + smartRoot.notifyFolderAdded(folder); + } catch (ex) { + console.error(ex); + continue; + } + } + let row = folderPane._createFolderRow(this.name, folder); + this.containerList.appendChild(row); + folderType.folderURI = folder.URI; + folderType.list = row.childList; + + // Display the searched folders for this type. + let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder); + for (let searchFolder of wrappedFolder.searchFolders) { + if (searchFolder != folder) { + this._addSearchedFolder( + folderType, + folderPane._getNonGmailParent(searchFolder), + searchFolder + ); + } + } + } + MailServices.accounts.saveVirtualFolders(); + }, + + regenerateMode() { + if (this._smartServer) { + MailServices.accounts.removeIncomingServer(this._smartServer, true); + } + this.init(); + }, + + _addSearchedFolder(folderType, parentFolder, childFolder) { + if (folderType.flag & childFolder.flags) { + // The folder has the flag for this type. + let folderRow = folderPane._createFolderRow( + this.name, + childFolder, + "server" + ); + folderPane._insertInServerOrder(folderType.list, folderRow); + return; + } + + if (!childFolder.isSpecialFolder(folderType.flag, true)) { + // This folder is searched by the virtual folder but it hasn't got + // the flag of this type and no ancestor has the flag of this type. + // We don't have a good way of displaying it. + return; + } + + // The folder is a descendant of one which has the flag. + let parentRow = folderPane.getRowForFolder(parentFolder, this.name); + if (!parentRow) { + // This is awkward: `childFolder` is searched but `parentFolder` is + // not. Displaying the unsearched folder is probably the least + // confusing way to handle this situation. + this._addSearchedFolder( + folderType, + folderPane._getNonGmailParent(parentFolder), + parentFolder + ); + parentRow = folderPane.getRowForFolder(parentFolder, this.name); + } + parentRow.insertChildInOrder( + folderPane._createFolderRow(this.name, childFolder) + ); + }, + + changeSearchedFolders(smartFolder) { + let folderType = this._folderTypes.find( + ft => ft.folderURI == smartFolder.URI + ); + if (!folderType) { + // This virtual folder isn't one of the smart folders. It's probably + // one of the tags virtual folders. + return; + } + + let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(smartFolder); + let smartFolderRow = folderPane.getRowForFolder(smartFolder, this.name); + let searchFolderURIs = wrappedFolder.searchFolders.map(sf => sf.URI); + let serversToCheck = new Set(); + + // Remove any rows which may belong to folders that aren't searched. + for (let row of [...smartFolderRow.querySelectorAll("li")]) { + if (!searchFolderURIs.includes(row.uri)) { + row.remove(); + let folder = MailServices.folderLookup.getFolderForURL(row.uri); + if (folder) { + serversToCheck.add(folder.server); + } + } + } + + // Add missing rows for folders that are searched. + let existingRowURIs = Array.from( + smartFolderRow.querySelectorAll("li"), + row => row.uri + ); + for (let searchFolder of wrappedFolder.searchFolders) { + if ( + searchFolder == smartFolder || + existingRowURIs.includes(searchFolder.URI) + ) { + continue; + } + let existingRow = folderPane.getRowForFolder(searchFolder, this.name); + if (existingRow) { + // A row for this folder exists, but not under the smart folder. + // Remove it and display under the smart folder. + folderPane._removeFolderAndAncestors(searchFolder, this.name, f => + searchFolderURIs.includes(f.URI) + ); + } + this._addSearchedFolder( + folderType, + folderPane._getNonGmailParent(searchFolder), + searchFolder + ); + } + + // For any rows we removed, check they are added back to the tree. + for (let server of serversToCheck) { + this.initServer(server); + } + }, + + initServer(server) { + // Find all folders in this server, and display the ones that aren't + // currently displayed. + let descendants = new Map( + server.rootFolder.descendants.map(d => [d.URI, d]) + ); + if (!descendants.size) { + return; + } + let remainingFolderURIs = Array.from(descendants.keys()); + + // Get a list of folders that already exist in the folder tree. + let existingRows = this.containerList.getElementsByTagName("li"); + let existingURIs = Array.from(existingRows, li => li.uri); + do { + let folderURI = remainingFolderURIs.shift(); + if (existingURIs.includes(folderURI)) { + continue; + } + let folder = descendants.get(folderURI); + if (folderPane._isGmailFolder(folder)) { + continue; + } + this.addFolder(folderPane._getNonGmailParent(folder), folder); + // Update the list of existing folders. `existingRows` is a live + // list, so we don't need to call `getElementsByTagName` again. + existingURIs = Array.from(existingRows, li => li.uri); + } while (remainingFolderURIs.length); + }, + + addFolder(parentFolder, childFolder) { + if (folderPane.getRowForFolder(childFolder, this.name)) { + // If a row for this folder exists, do nothing. + return; + } + if (!parentFolder) { + // If this folder is the root folder for a server, do nothing. + return; + } + if (childFolder.server.hidden) { + // If this folder is from a hidden server, do nothing. + return; + } + + let folderType = this._folderTypes.find(ft => + childFolder.isSpecialFolder(ft.flag, true) + ); + if (folderType) { + let virtualFolder = VirtualFolderHelper.wrapVirtualFolder( + MailServices.folderLookup.getFolderForURL(folderType.folderURI) + ); + let searchFolders = virtualFolder.searchFolders; + if (searchFolders.includes(childFolder)) { + // This folder is included in the virtual folder, do nothing. + return; + } + + if (searchFolders.includes(parentFolder)) { + // This folder's parent is included in the virtual folder, but the + // folder itself isn't. Add it to the list of non-special folders. + // Note that `_addFolderAndAncestors` can't be used here, as that + // would add the row in the wrong place. + let serverRow = folderPane.getRowForFolder( + childFolder.rootFolder, + this.name + ); + if (!serverRow) { + serverRow = folderPane._createServerRow( + this.name, + childFolder.server + ); + folderPane._insertInServerOrder(this.containerList, serverRow); + } + let folderRow = folderPane._createFolderRow(this.name, childFolder); + serverRow.insertChildInOrder(folderRow); + folderPane._addSubFolders(childFolder, folderRow, this.name); + return; + } + } + + // Nothing special about this folder. Add it to the end of the list. + let folderRow = folderPane._addFolderAndAncestors( + this.containerList, + childFolder, + this.name + ); + folderPane._addSubFolders(childFolder, folderRow, this.name); + }, + + removeFolder(parentFolder, childFolder) { + let childRow = folderPane.getRowForFolder(childFolder, this.name); + if (!childRow) { + return; + } + let parentRow = childRow.parentNode.closest("li"); + childRow.remove(); + if ( + parentRow.parentNode == this.containerList && + parentRow.dataset.serverType && + !parentRow.querySelector("li") + ) { + parentRow.remove(); + } + }, + + changeAccountOrder() { + folderPane._reapplyServerOrder(this.containerList); + + for (let smartFolderRow of this.containerList.children) { + if (smartFolderRow.dataset.serverKey == this._smartServer.key) { + folderPane._reapplyServerOrder(smartFolderRow.childList); + } + } + }, + }, + unread: { + name: "unread", + active: false, + canBeCompact: true, + + _unreadFilter(folder, includeSubFolders = true) { + return folder.getNumUnread(includeSubFolders) > 0; + }, + + initServer(server) { + this.addFolder(null, server.rootFolder); + }, + + _recurseSubFolders(parentFolder) { + let subFolders; + try { + subFolders = parentFolder.subFolders; + } catch (ex) { + console.error( + new Error( + `Unable to access the subfolders of ${parentFolder.URI}`, + { cause: ex } + ) + ); + } + if (!subFolders?.length) { + return; + } + + for (let i = 0; i < subFolders.length; i++) { + let folder = subFolders[i]; + if (folderPane._isGmailFolder(folder)) { + subFolders.splice(i, 1, ...folder.subFolders); + } + } + + subFolders.sort((a, b) => a.compareSortKeys(b)); + + for (let folder of subFolders) { + if (!this._unreadFilter(folder)) { + continue; + } + if (this._unreadFilter(folder, false)) { + this._addFolder(folder); + } + this._recurseSubFolders(folder); + } + }, + + addFolder(unused, folder) { + if (!this._unreadFilter(folder)) { + return; + } + this._addFolder(folder); + this._recurseSubFolders(folder); + }, + + _addFolder(folder) { + if (folderPane.getRowForFolder(folder, this.name)) { + // Don't do anything. `folderPane.changeUnreadCount` already did it. + return; + } + + if (!this._unreadFilter(folder, !folderPane._isCompact)) { + return; + } + + if (folderPane._isCompact) { + let folderRow = folderPane._createFolderRow( + this.name, + folder, + "both" + ); + folderPane._insertInServerOrder(this.containerList, folderRow); + return; + } + + folderPane._addFolderAndAncestors( + this.containerList, + folder, + this.name + ); + }, + + removeFolder(parentFolder, childFolder) { + folderPane._removeFolderAndAncestors( + childFolder, + this.name, + this._unreadFilter + ); + + // If the folder is being moved, `childFolder.parent` is null so the + // above code won't remove ancestors. Do this now. + if (!childFolder.parent && parentFolder) { + folderPane._removeFolderAndAncestors( + parentFolder, + this.name, + this._unreadFilter, + true + ); + } + + // Remove any stray rows that might be descendants of `childFolder`. + for (let row of [...this.containerList.querySelectorAll("li")]) { + if (row.uri.startsWith(childFolder.URI + "/")) { + row.remove(); + } + } + }, + + changeUnreadCount(folder, newValue) { + if (newValue > 0) { + this._addFolder(folder); + } + }, + + changeAccountOrder() { + folderPane._reapplyServerOrder(this.containerList); + }, + }, + favorite: { + name: "favorite", + active: false, + canBeCompact: true, + + _favoriteFilter(folder) { + return folder.flags & Ci.nsMsgFolderFlags.Favorite; + }, + + initServer(server) { + this.addFolder(null, server.rootFolder); + }, + + addFolder(unused, folder) { + this._addFolder(folder); + for (let subFolder of folder.getFoldersWithFlags( + Ci.nsMsgFolderFlags.Favorite + )) { + this._addFolder(subFolder); + } + }, + + _addFolder(folder) { + if ( + !this._favoriteFilter(folder) || + folderPane.getRowForFolder(folder, this.name) + ) { + return; + } + + if (folderPane._isCompact) { + folderPane._insertInServerOrder( + this.containerList, + folderPane._createFolderRow(this.name, folder, "both") + ); + return; + } + + folderPane._addFolderAndAncestors( + this.containerList, + folder, + this.name + ); + }, + + removeFolder(parentFolder, childFolder) { + folderPane._removeFolderAndAncestors( + childFolder, + this.name, + this._favoriteFilter + ); + + // If the folder is being moved, `childFolder.parent` is null so the + // above code won't remove ancestors. Do this now. + if (!childFolder.parent && parentFolder) { + folderPane._removeFolderAndAncestors( + parentFolder, + this.name, + this._favoriteFilter, + true + ); + } + + // Remove any stray rows that might be descendants of `childFolder`. + for (let row of [...this.containerList.querySelectorAll("li")]) { + if (row.uri.startsWith(childFolder.URI + "/")) { + row.remove(); + } + } + }, + + changeFolderFlag(folder, oldValue, newValue) { + oldValue &= Ci.nsMsgFolderFlags.Favorite; + newValue &= Ci.nsMsgFolderFlags.Favorite; + + if (oldValue == newValue) { + return; + } + + if (oldValue) { + if ( + folderPane._isCompact || + !folder.getFolderWithFlags(Ci.nsMsgFolderFlags.Favorite) + ) { + folderPane._removeFolderAndAncestors( + folder, + this.name, + this._favoriteFilter + ); + } + } else { + this._addFolder(folder); + } + }, + + changeAccountOrder() { + folderPane._reapplyServerOrder(this.containerList); + }, + }, + recent: { + name: "recent", + active: false, + canBeCompact: false, + + init() { + let folders = FolderUtils.getMostRecentFolders( + MailServices.accounts.allFolders, + Services.prefs.getIntPref("mail.folder_widget.max_recent"), + "MRUTime" + ); + for (let folder of folders) { + let folderRow = folderPane._createFolderRow( + this.name, + folder, + "both" + ); + this.containerList.appendChild(folderRow); + } + }, + + removeFolder(parentFolder, childFolder) { + folderPane.getRowForFolder(childFolder)?.remove(); + }, + }, + tags: { + name: "tags", + active: false, + canBeCompact: false, + + init() { + this._smartServer = MailServices.accounts.findServer( + "nobody", + "smart mailboxes", + "none" + ); + if (!this._smartServer) { + this._smartServer = MailServices.accounts.createIncomingServer( + "nobody", + "smart mailboxes", + "none" + ); + // We don't want the "smart" server/account leaking out into the ui in + // other places, so set it as hidden. + this._smartServer.hidden = true; + let account = MailServices.accounts.createAccount(); + account.incomingServer = this._smartServer; + } + this._smartServer.prettyName = + messengerBundle.GetStringFromName("unifiedAccountName"); + let smartRoot = this._smartServer.rootFolder.QueryInterface( + Ci.nsIMsgLocalMailFolder + ); + this._tagsFolder = + smartRoot.getChildWithURI(`${smartRoot.URI}/tags`, false, false) ?? + smartRoot.createLocalSubfolder("tags"); + this._tagsFolder.QueryInterface(Ci.nsIMsgLocalMailFolder); + + for (let tag of MailServices.tags.getAllTags()) { + try { + let folder = this._getVirtualFolder(tag); + this.containerList.appendChild( + folderPane._createTagRow(this.name, folder, tag) + ); + } catch (ex) { + console.error(ex); + } + } + MailServices.accounts.saveVirtualFolders(); + }, + + /** + * Get or create a virtual folder searching messages for `tag`. + * + * @param {nsIMsgTag} tag + * @returns {nsIMsgFolder} + */ + _getVirtualFolder(tag) { + let folder = this._tagsFolder.getChildWithURI( + `${this._tagsFolder.URI}/${encodeURIComponent(tag.key)}`, + false, + false + ); + if (folder) { + return folder; + } + + folder = this._tagsFolder.createLocalSubfolder(tag.key); + folder.flags |= Ci.nsMsgFolderFlags.Virtual; + folder.prettyName = tag.tag; + + let msgDatabase = folder.msgDatabase; + let folderInfo = msgDatabase.dBFolderInfo; + + folderInfo.setCharProperty( + "searchStr", + `AND (tag,contains,${tag.key})` + ); + folderInfo.setCharProperty("searchFolderUri", "*"); + folderInfo.setUint32Property( + "searchFolderFlag", + Ci.nsMsgFolderFlags.Inbox + ); + folderInfo.setBooleanProperty("searchOnline", false); + msgDatabase.summaryValid = true; + msgDatabase.close(true); + + this._tagsFolder.notifyFolderAdded(folder); + return folder; + }, + + /** + * Update the UI to match changes in a tag. If the tag is no longer + * valid (i.e. it's been deleted) the row representing it will be + * removed. If the tag is new, a row for it will be created. + * + * @param {string} prefName - The full name of the preference that + * changed causing this code to run. + */ + changeTagFromPrefChange(prefName) { + let [, , key] = prefName.split("."); + if (!MailServices.tags.isValidKey(key)) { + let uri = `${this._tagsFolder.URI}/${encodeURIComponent(key)}`; + folderPane.getRowForFolder(uri)?.remove(); + return; + } + + let tag = MailServices.tags.getAllTags().find(t => t.key == key); + let folder = this._getVirtualFolder(tag); + let row = folderPane.getRowForFolder(folder); + folder.prettyName = tag.tag; + if (row) { + row.name = tag.tag; + row.icon.style.setProperty("--icon-color", tag.color); + } else { + this.containerList.appendChild( + folderPane._createTagRow(this.name, folder, tag) + ); + } + }, + }, + }, + + /** + * Initialize the folder pane if needed. + * @returns {Promise<void>} when the folder pane is initialized. + */ + async init() { + if (this._initialized) { + return; + } + if (window.openingState?.syntheticView) { + // Just avoid initialising the pane. We won't be using it. The folder + // listener is still required, because it does other things too. + MailServices.mailSession.AddFolderListener( + folderListener, + Ci.nsIFolderListener.all + ); + return; + } + + try { + // We could be here before `loadPostAccountWizard` loads the virtual + // folders, and we need them, so do it now. + MailServices.accounts.loadVirtualFolders(); + } catch (e) { + console.error(e); + } + + await FolderTreeProperties.ready; + + this._modeTemplate = document.getElementById("modeTemplate"); + this._folderTemplate = document.getElementById("folderTemplate"); + + this._isCompact = + Services.xulStore.getValue(XULSTORE_URL, "folderTree", "compact") === + "true"; + let activeModes = Services.xulStore.getValue( + XULSTORE_URL, + "folderTree", + "mode" + ); + activeModes = activeModes.split(","); + this.activeModes = activeModes; + + // Don't await anything between the active modes being initialised (the + // line above) and the listener being added. Otherwise folders may appear + // while we're not listening. + MailServices.mailSession.AddFolderListener( + folderListener, + Ci.nsIFolderListener.all + ); + + Services.prefs.addObserver("mail.accountmanager.accounts", this); + Services.prefs.addObserver("mailnews.tags.", this); + + Services.obs.addObserver(this, "folder-color-changed"); + Services.obs.addObserver(this, "folder-color-preview"); + Services.obs.addObserver(this, "search-folders-changed"); + Services.obs.addObserver(this, "folder-properties-changed"); + + folderTree.addEventListener("auxclick", this); + folderTree.addEventListener("contextmenu", this); + folderTree.addEventListener("collapsed", this); + folderTree.addEventListener("expanded", this); + folderTree.addEventListener("dragstart", this); + folderTree.addEventListener("dragover", this); + folderTree.addEventListener("dragleave", this); + folderTree.addEventListener("drop", this); + + document.getElementById("folderPaneHeaderBar").hidden = + this.isFolderPaneHeaderHidden(); + const folderPaneGetMessages = document.getElementById( + "folderPaneGetMessages" + ); + folderPaneGetMessages.addEventListener("click", () => { + top.MsgGetMessagesForAccount(); + }); + folderPaneGetMessages.addEventListener("contextmenu", event => { + document + .getElementById("folderPaneGetMessagesContext") + .openPopup(event.target, { triggerEvent: event }); + }); + document + .getElementById("folderPaneWriteMessage") + .addEventListener("click", event => { + top.MsgNewMessage(event); + }); + folderPaneGetMessages.hidden = this.isFolderPaneGetMsgsBtnHidden(); + document.getElementById("folderPaneWriteMessage").hidden = + this.isFolderPaneNewMsgBtnHidden(); + this.moreContext = document.getElementById("folderPaneMoreContext"); + this.folderPaneModeContext = document.getElementById( + "folderPaneModeContext" + ); + + document + .getElementById("folderPaneMoreButton") + .addEventListener("click", event => { + this.moreContext.openPopup(event.target, { triggerEvent: event }); + }); + this.subFolderContext = document.getElementById( + "folderModesContextMenuPopup" + ); + document + .getElementById("folderModesContextMenuPopup") + .addEventListener("click", event => { + this.subFolderContext.openPopup(event.target, { triggerEvent: event }); + }); + this.updateFolderRowUIElements(); + this.updateWidgets(); + + this._initialized = true; + }, + + uninit() { + if (!this._initialized) { + return; + } + Services.prefs.removeObserver("mail.accountmanager.accounts", this); + Services.prefs.removeObserver("mailnews.tags.", this); + Services.obs.removeObserver(this, "folder-color-changed"); + Services.obs.removeObserver(this, "folder-color-preview"); + Services.obs.removeObserver(this, "search-folders-changed"); + Services.obs.removeObserver(this, "folder-properties-changed"); + }, + + handleEvent(event) { + switch (event.type) { + case "select": + this._onSelect(event); + break; + case "auxclick": + if (event.button == 1) { + this._onMiddleClick(event); + } + break; + case "contextmenu": + this._onContextMenu(event); + break; + case "collapsed": + this._onCollapsed(event); + break; + case "expanded": + this._onExpanded(event); + break; + case "dragstart": + this._onDragStart(event); + break; + case "dragover": + this._onDragOver(event); + break; + case "dragleave": + this._clearDropTarget(event); + break; + case "drop": + this._onDrop(event); + break; + } + }, + + observe(subject, topic, data) { + switch (topic) { + case "nsPref:changed": + if (data == "mail.accountmanager.accounts") { + this._forAllActiveModes("changeAccountOrder"); + } else if ( + data.startsWith("mailnews.tags.") && + this._modes.tags.active + ) { + // The tags service isn't updated until immediately after the + // preferences change, so go to the back of the event queue before + // updating the UI. + setTimeout(() => this._modes.tags.changeTagFromPrefChange(data)); + } + break; + case "search-folders-changed": + if (this._modes.smart.active) { + subject.QueryInterface(Ci.nsIMsgFolder); + if (subject.server == this._modes.smart._smartServer) { + this._modes.smart.changeSearchedFolders(subject); + } + } + break; + case "folder-properties-changed": + this.updateFolderProperties(subject.QueryInterface(Ci.nsIMsgFolder)); + break; + case "folder-color-changed": + case "folder-color-preview": + this._changeRows(subject, row => row.setIconColor(data)); + break; + } + }, + + /** + * Whether the folder pane has been initialized. + * + * @type {boolean} + */ + get isInitialized() { + return this._initialized; + }, + + /** + * If the local folders are currently hidden. + * + * @returns {boolean} + */ + get hideLocalFolders() { + this._hideLocalFolders = this.isItemHidden("folderPaneLocalFolders"); + return this._hideLocalFolders; + }, + + /** + * Reload the folder tree when the option changes. + * + * @param {boolean} - True if local folders should be hidden. + */ + set hideLocalFolders(value) { + if (value == this._hideLocalFolders) { + return; + } + + this._hideLocalFolders = value; + for (let mode of Object.values(this._modes)) { + if (!mode.active) { + continue; + } + mode.containerList.replaceChildren(); + this._initMode(mode); + } + this.updateFolderRowUIElements(); + }, + + /** + * Toggle the folder modes requested by the user. + * + * @param {Event} event - The DOMEvent. + */ + toggleFolderMode(event) { + let currentModes = this.activeModes; + let mode = event.target.getAttribute("value"); + let index = this.activeModes.indexOf(mode); + + if (event.target.hasAttribute("checked")) { + if (index == -1) { + currentModes.push(mode); + } + } else if (index >= 0) { + currentModes.splice(index, 1); + } + this.activeModes = currentModes; + this.toggleCompactViewMenuItem(); + + if (this.activeModes.length == 1 && this.activeModes.at(0) == "all") { + this.updateContextCheckedFolderMode(); + } + }, + + toggleCompactViewMenuItem() { + let subMenuCompactBtn = document.querySelector( + "#folderPaneMoreContextCompactToggle" + ); + if (this.canBeCompact) { + subMenuCompactBtn.removeAttribute("disabled"); + return; + } + subMenuCompactBtn.setAttribute("disabled", "true"); + }, + + /** + * Ensure all the folder modes menuitems in the pane header context menu are + * checked to reflect the currently active modes. + */ + updateContextCheckedFolderMode() { + for (let item of document.querySelectorAll(".folder-pane-mode")) { + if (this.activeModes.includes(item.value)) { + item.setAttribute("checked", true); + continue; + } + item.removeAttribute("checked"); + } + }, + + /** + * Ensures all the folder pane mode context menuitems in the folder + * pane mode context menu are checked to reflect the current compact mode. + * @param {Event} event - The DOMEvent. + */ + onFolderPaneModeContextOpening(event) { + this.mode = event.target.closest("[data-mode]")?.dataset.mode; + + // If folder mode is at the top or the only one, + // it can't be moved up, so disable "Move Up". + const moveUpMenuItem = this.folderPaneModeContext.querySelector( + "#folderPaneModeMoveUp" + ); + moveUpMenuItem.removeAttribute("disabled"); + // Apply attribute mode to context menu option to allow + // for sorting later + if (this.activeModes.at(0) == this.mode) { + moveUpMenuItem.setAttribute("disabled", "true"); + } + + // If folder mode is at the bottom or the only one, + // it can't be moved down, so disable "Move Down". + const moveDownMenuItem = this.folderPaneModeContext.querySelector( + "#folderPaneModeMoveDown" + ); + moveDownMenuItem.removeAttribute("disabled"); + // Apply attribute mode to context menu option to allow + // for sorting later + if (this.activeModes.at(-1) == this.mode) { + moveDownMenuItem.setAttribute("disabled", "true"); + } + + let compactMenuItem = this.folderPaneModeContext.querySelector( + "#compactFolderButton" + ); + compactMenuItem.removeAttribute("checked"); + compactMenuItem.removeAttribute("disabled"); + if (!this.canModeBeCompact(this.mode)) { + compactMenuItem.setAttribute("disabled", "true"); + return; + } + if (this.isCompact) { + compactMenuItem.setAttribute("checked", true); + } + }, + + /** + * Toggles the compact mode of the active modes that allow it. + * + * @param {Event} event - The DOMEvent. + */ + compactFolderToggle(event) { + this.isCompact = event.target.hasAttribute("checked"); + }, + + /** + * Moves active folder mode up + * + * @param {Event} event - The DOMEvent. + */ + moveFolderModeUp(event) { + let currentModes = this.activeModes; + const mode = this.mode; + const index = currentModes.indexOf(mode); + + if (index > 0) { + const prev = currentModes[index - 1]; + currentModes[index - 1] = currentModes[index]; + currentModes[index] = prev; + } + this.activeModes = currentModes; + }, + + /** + * Moves active folder mode down + * + * @param {Event} event - The DOMEvent. + */ + moveFolderModeDown(event) { + let currentModes = this.activeModes; + const mode = this.mode; + const index = currentModes.indexOf(mode); + + if (index < currentModes.length - 1) { + const next = currentModes[index + 1]; + currentModes[index + 1] = currentModes[index]; + currentModes[index] = next; + } + this.activeModes = currentModes; + }, + + /** + * The names of all active modes. + * + * @type {string[]} + */ + get activeModes() { + return Array.from(folderTree.children, li => li.dataset.mode); + }, + + set activeModes(modes) { + modes = modes.filter(m => m in this._modes); + if (modes.length == 0) { + modes = ["all"]; + } + for (let name of Object.keys(this._modes)) { + this._toggleMode(name, modes.includes(name)); + } + for (let name of modes) { + let { container, containerHeader } = this._modes[name]; + containerHeader.hidden = modes.length == 1; + folderTree.appendChild(container); + } + Services.xulStore.setValue( + XULSTORE_URL, + "folderTree", + "mode", + this.activeModes.join(",") + ); + this.updateFolderRowUIElements(); + }, + + /** + * Do any of the active modes have a compact variant? + * + * @type {boolean} + */ + get canBeCompact() { + return Object.values(this._modes).some( + mode => mode.active && mode.canBeCompact + ); + }, + + /** + * Do any of the active modes have a compact variant? + * + * @param {string} mode + * @type {boolean} + */ + canModeBeCompact(mode) { + return Object.values(this._modes).some( + m => m.name == mode && m.active && m.canBeCompact + ); + }, + + /** + * Are compact variants enabled? + * + * @type {boolean} + */ + get isCompact() { + return this._isCompact; + }, + + set isCompact(value) { + if (this._isCompact == value) { + return; + } + this._isCompact = value; + for (let mode of Object.values(this._modes)) { + if (!mode.active || !mode.canBeCompact) { + continue; + } + + mode.containerList.replaceChildren(); + this._initMode(mode); + } + Services.xulStore.setValue(XULSTORE_URL, "folderTree", "compact", value); + }, + + /** + * Show or hide a folder tree mode. + * + * @param {string} modeName + * @param {boolean} active + */ + _toggleMode(modeName, active) { + if (!(modeName in this._modes)) { + throw new Error(`Unknown folder tree mode: ${modeName}`); + } + let mode = this._modes[modeName]; + if (mode.active == active) { + return; + } + + if (!active) { + mode.container.remove(); + delete mode.container; + mode.active = false; + return; + } + + let container = + this._modeTemplate.content.firstElementChild.cloneNode(true); + container.dataset.mode = modeName; + + mode.container = container; + mode.containerHeader = container.querySelector(".mode-container"); + mode.containerHeader.querySelector(".mode-name").textContent = + messengerBundle.GetStringFromName( + modeName == "tags" ? "tag" : `folderPaneModeHeader_${modeName}` + ); + mode.containerList = container.querySelector("ul"); + this._initMode(mode); + mode.active = true; + container.querySelector(".mode-button").addEventListener("click", event => { + this.onFolderPaneModeContextOpening(event); + this.folderPaneModeContext.openPopup(event.target, { + triggerEvent: event, + }); + }); + }, + + /** + * Initialize a folder mode with all visible accounts. + * + * @param {object} mode - One of the folder modes from `folderPane._modes`. + */ + _initMode(mode) { + if (typeof mode.init == "function") { + try { + mode.init(); + } catch (e) { + console.warn(`Error intiating ${mode.name} mode.`, e); + if (typeof mode.regenerateMode != "function") { + return; + } + mode.containerList.replaceChildren(); + mode.regenerateMode(); + } + } + if (typeof mode.initServer != "function") { + return; + } + + // `.accounts` is used here because it is ordered, `.allServers` isn't. + for (let account of MailServices.accounts.accounts) { + // Skip local folders if they're hidden. + if ( + account.incomingServer.type == "none" && + folderPane.hideLocalFolders + ) { + continue; + } + // Skip IM accounts. + if (account.incomingServer.type == "im") { + continue; + } + // Skip POP3 accounts that are deferred to another account. + if ( + account.incomingServer instanceof Ci.nsIPop3IncomingServer && + account.incomingServer.deferredToAccount + ) { + continue; + } + mode.initServer(account.incomingServer); + } + }, + + /** + * Create a FolderTreeRow representing a server. + * + * @param {string} modeName - The name of the mode this row belongs to. + * @param {nsIMsgIncomingServer} server - The server the row represents. + * @returns {FolderTreeRow} + */ + _createServerRow(modeName, server) { + let row = document.createElement("li", { is: "folder-tree-row" }); + row.modeName = modeName; + row.setServer(server); + return row; + }, + + /** + * Create a FolderTreeRow representing a folder. + * + * @param {string} modeName - The name of the mode this row belongs to. + * @param {nsIMsgFolder} folder - The folder the row represents. + * @param {"folder"|"server"|"both"} nameStyle + * @returns {FolderTreeRow} + */ + _createFolderRow(modeName, folder, nameStyle) { + let row = document.createElement("li", { is: "folder-tree-row" }); + row.modeName = modeName; + row.setFolder(folder, nameStyle); + return row; + }, + + /** + * Create a FolderTreeRow representing a virtual folder for a tag. + * + * @param {string} modeName - The name of the mode this row belongs to. + * @param {nsIMsgFolder} folder - The virtual folder the row represents. + * @param {nsIMsgTag} tag - The tag the virtual folder searches for. + * @returns {FolderTreeRow} + */ + _createTagRow(modeName, folder, tag) { + let row = document.createElement("li", { is: "folder-tree-row" }); + row.modeName = modeName; + row.setFolder(folder); + row.dataset.tagKey = tag.key; + row.icon.style.setProperty("--icon-color", tag.color); + return row; + }, + + /** + * Add a server row to the given list in the correct sort order. + * + * @param {HTMLUListElement} list + * @param {FolderTreeRow} serverRow + * @returns {FolderTreeRow} + */ + _insertInServerOrder(list, serverRow) { + let serverKeys = MailServices.accounts.accounts.map( + a => a.incomingServer.key + ); + let index = serverKeys.indexOf(serverRow.dataset.serverKey); + for (let row of list.children) { + let i = serverKeys.indexOf(row.dataset.serverKey); + + if (i > index) { + return list.insertBefore(serverRow, row); + } + if (i < index) { + continue; + } + + if (row.folderSortOrder > serverRow.folderSortOrder) { + return list.insertBefore(serverRow, row); + } + if (row.folderSortOrder < serverRow.folderSortOrder) { + continue; + } + + if (FolderTreeRow.nameCollator.compare(row.name, serverRow.name) > 0) { + return list.insertBefore(serverRow, row); + } + } + return list.appendChild(serverRow); + }, + + _reapplyServerOrder(list) { + let selected = list.querySelector("li.selected"); + let serverKeys = MailServices.accounts.accounts.map( + a => a.incomingServer.key + ); + let serverRows = [...list.children]; + serverRows.sort( + (a, b) => + serverKeys.indexOf(a.dataset.serverKey) - + serverKeys.indexOf(b.dataset.serverKey) + ); + list.replaceChildren(...serverRows); + if (selected) { + setTimeout(() => selected.classList.add("selected")); + } + }, + + /** + * Adds a row representing a folder and any missing rows for ancestors of + * the folder. + * + * @param {HTMLUListElement} containerList - The list to add folders to. + * @param {nsIMsgFolder} folder + * @param {string} modeName - The name of the mode this row belongs to. + * @returns {FolderTreeRow} + */ + _addFolderAndAncestors(containerList, folder, modeName) { + let folderRow = folderPane.getRowForFolder(folder, modeName); + if (folderRow) { + return folderRow; + } + + if (folder.isServer) { + let serverRow = folderPane._createServerRow(modeName, folder.server); + this._insertInServerOrder(containerList, serverRow); + return serverRow; + } + + let parentRow = this._addFolderAndAncestors( + containerList, + folderPane._getNonGmailParent(folder), + modeName + ); + folderRow = folderPane._createFolderRow(modeName, folder); + parentRow.insertChildInOrder(folderRow); + return folderRow; + }, + + /** + * @callback folderFilterCallback + * @param {FolderTreeRow} row + * @returns {boolean} - True if the folder should have a row in the tree. + */ + /** + * Removes the row representing a folder and the rows for any ancestors of + * the folder, as long as they don't have other descendants or match + * `filterFunction`. + * + * @param {nsIMsgFolder} folder + * @param {string} modeName - The name of the mode this row belongs to. + * @param {folderFilterCallback} [filterFunction] - Optional callback to stop + * ascending. + * @param {boolean=false} childAlreadyGone - Is this function being called + * to remove the parent of a row that's already been removed? + */ + _removeFolderAndAncestors( + folder, + modeName, + filterFunction, + childAlreadyGone = false + ) { + let folderRow = folderPane.getRowForFolder(folder, modeName); + if (folderPane._isCompact) { + folderRow?.remove(); + return; + } + + // If we get to a row for a folder that doesn't exist, or has children + // other than the one being removed, don't go any further. + if ( + !folderRow || + folderRow.childList.childElementCount > (childAlreadyGone ? 0 : 1) + ) { + return; + } + + // Otherwise, move up the folder tree. + let parentFolder = folderPane._getNonGmailParent(folder); + if ( + parentFolder && + (typeof filterFunction != "function" || !filterFunction(parentFolder)) + ) { + this._removeFolderAndAncestors(parentFolder, modeName, filterFunction); + } + + // Remove the row for this folder. + folderRow.remove(); + }, + + /** + * Add all subfolders to a row representing a folder. Called recursively, + * so all descendants are ultimately added. + * + * @param {nsIMsgFolder} parentFolder + * @param {FolderTreeRow} parentRow - The row representing `parentFolder`. + * @param {string} modeName - The name of the mode this row belongs to. + * @param {folderFilterCallback} [filterFunction] - Optional callback to add + * only some subfolders to the row. + */ + _addSubFolders(parentFolder, parentRow, modeName, filterFunction) { + let subFolders; + try { + subFolders = parentFolder.subFolders; + } catch (ex) { + console.error( + new Error(`Unable to access the subfolders of ${parentFolder.URI}`, { + cause: ex, + }) + ); + } + if (!subFolders?.length) { + return; + } + + for (let i = 0; i < subFolders.length; i++) { + let folder = subFolders[i]; + if (this._isGmailFolder(folder)) { + subFolders.splice(i, 1, ...folder.subFolders); + } + } + + subFolders.sort((a, b) => a.compareSortKeys(b)); + + for (let folder of subFolders) { + if (typeof filterFunction == "function" && !filterFunction(folder)) { + continue; + } + let folderRow = folderPane._createFolderRow(modeName, folder); + this._addSubFolders(folder, folderRow, modeName, filterFunction); + parentRow.childList.appendChild(folderRow); + } + }, + + /** + * Get the first row representing a folder, even if it is hidden. + * + * @param {nsIMsgFolder|string} folderOrURI - The folder to find, or its URI. + * @param {string?} modeName - If given, only look in the folders for this + * mode, otherwise look in the whole tree. + * @returns {FolderTreeRow} + */ + getRowForFolder(folderOrURI, modeName) { + if (folderOrURI instanceof Ci.nsIMsgFolder) { + folderOrURI = folderOrURI.URI; + } + + let modeNames = modeName ? [modeName] : this.activeModes; + for (let name of modeNames) { + let id = FolderTreeRow.makeRowID(name, folderOrURI); + // Look in the mode's container. The container may or may not be + // attached to the document at this point. + let row = this._modes[name].containerList.querySelector( + `#${CSS.escape(id)}` + ); + if (row) { + return row; + } + } + + return null; + }, + + /** + * Loop through all currently active modes and call the required function if + * it exists. + * + * @param {string} functionName - The name of the function to call. + * @param {...any} args - The list of arguments to pass to the function. + */ + _forAllActiveModes(functionName, ...args) { + for (let mode of Object.values(this._modes)) { + if (!mode.active || typeof mode[functionName] != "function") { + continue; + } + try { + mode[functionName](...args); + } catch (ex) { + console.error(ex); + } + } + }, + + /** + * We deliberately hide the [Gmail] (or [Google Mail] in some cases) folder + * from the folder tree. This function determines if a folder is that folder. + * + * @param {nsIMsgFolder} folder + * @returns {boolean} + */ + _isGmailFolder(folder) { + return ( + folder?.parent?.isServer && + folder.server instanceof Ci.nsIImapIncomingServer && + folder.server.isGMailServer && + folder.noSelect + ); + }, + + /** + * If a folder is the [Gmail] folder, returns the parent folder, otherwise + * returns the given folder. + * + * @param {nsIMsgFolder} folder + * @returns {nsIMsgFolder} + */ + _getNonGmailFolder(folder) { + return this._isGmailFolder(folder) ? folder.parent : folder; + }, + + /** + * Returns the parent folder of a given folder, or if that is the [Gmail] + * folder returns the grandparent of the given folder. + * + * @param {nsIMsgFolder} folder + * @returns {nsIMsgFolder} + */ + _getNonGmailParent(folder) { + return this._getNonGmailFolder(folder.parent); + }, + + /** + * Update the folder pane UI and add rows for all newly created folders. + * + * @param {?nsIMsgFolder} parentFolder - The parent of the newly created + * folder. + * @param {nsIMsgFolder} childFolder - The newly created folder. + */ + addFolder(parentFolder, childFolder) { + if (!parentFolder) { + // A server folder was added, so check if we need to update actions. + this.updateWidgets(); + } + + if (this._isGmailFolder(childFolder)) { + return; + } + + parentFolder = this._getNonGmailFolder(parentFolder); + this._forAllActiveModes("addFolder", parentFolder, childFolder); + }, + + /** + * Update the folder pane UI and remove rows for all removed folders. + * + * @param {?nsIMsgFolder} parentFolder - The parent of the removed folder. + * @param {nsIMsgFolder} childFolder - The removed folder. + */ + removeFolder(parentFolder, childFolder) { + if (!parentFolder) { + // A server folder was removed, so check if we need to update actions. + this.updateWidgets(); + } + + parentFolder = this._getNonGmailFolder(parentFolder); + this._forAllActiveModes("removeFolder", parentFolder, childFolder); + }, + + /** + * Update the list of folders if the current mode rely on specific flags. + * + * @param {nsIMsgFolder} item - The target folder. + * @param {nsMsgFolderFlags} oldValue - The old flag value. + * @param {nsMsgFolderFlags} newValue - The updated flag value. + */ + changeFolderFlag(item, oldValue, newValue) { + this._forAllActiveModes("changeFolderFlag", item, oldValue, newValue); + this._changeRows(item, row => row.setFolderTypeFromFolder(item)); + }, + + /** + * Update the list of folders to reflect current properties. + * + * @param {nsIMsgFolder} item - The folder whose data to use. + */ + updateFolderProperties(item) { + this._forAllActiveModes("updateFolderProperties", item); + this._changeRows(item, row => row.setFolderPropertiesFromFolder(item)); + }, + + /** + * @callback folderRowChangeCallback + * @param {FolderTreeRow} row + */ + /** + * Perform a function on all rows representing a folder. + * + * @param {nsIMsgFolder|string} folderOrURI - The folder to change, or its URI. + * @param {folderRowChangeCallback} callback + */ + _changeRows(folderOrURI, callback) { + if (folderOrURI instanceof Ci.nsIMsgFolder) { + folderOrURI = folderOrURI.URI; + } + for (let row of folderTree.querySelectorAll("li")) { + if (row.uri == folderOrURI) { + callback(row); + } + } + }, + + /** + * Get the folder from the URI by looping through the list of folders and + * finding a matching URI. + * + * @param {string} uri + * @returns {?FolderTreeRow} + */ + getFolderFromUri(uri) { + for (let folder of folderTree.querySelectorAll("li")) { + if (folder.uri == uri) { + return folder; + } + } + return [...folderTree.querySelectorAll("li")]?.find(f => f.uri == uri); + }, + + /** + * Called when a folder's new messages state changes. + * + * @param {nsIMsgFolder} folder + * @param {boolean} hasNewMessages + */ + changeNewMessages(folder, hasNewMessages) { + this._changeRows(folder, row => { + // Find the nearest visible ancestor and update it. + let collapsedAncestor = row.parentElement?.closest("li.collapsed"); + while (collapsedAncestor) { + const next = collapsedAncestor.parentElement?.closest("li.collapsed"); + if (!next) { + collapsedAncestor.updateNewMessages(hasNewMessages); + break; + } + collapsedAncestor = next; + } + + // Update the row itself. + row.updateNewMessages(hasNewMessages); + }); + }, + + /** + * Called when a folder's unread count changes, to update the UI. + * + * @param {nsIMsgFolder} folder + * @param {integer} newValue + */ + changeUnreadCount(folder, newValue) { + this._changeRows(folder, row => { + // Find the nearest visible ancestor and update it. + let collapsedAncestor = row.parentElement?.closest("li.collapsed"); + while (collapsedAncestor) { + const next = collapsedAncestor.parentElement?.closest("li.collapsed"); + if (!next) { + collapsedAncestor.updateUnreadMessageCount(); + break; + } + collapsedAncestor = next; + } + + // Update the row itself. + row.updateUnreadMessageCount(); + }); + + if (this._modes.unread.active && !folder.server.hidden) { + this._modes.unread.changeUnreadCount(folder, newValue); + } + }, + + /** + * Called when a folder's total count changes, to update the UI. + * + * @param {nsIMsgFolder} folder + * @param {integer} newValue + */ + changeTotalCount(folder, newValue) { + this._changeRows(folder, row => { + // Find the nearest visible ancestor and update it. + let collapsedAncestor = row.parentElement?.closest("li.collapsed"); + while (collapsedAncestor) { + const next = collapsedAncestor.parentElement?.closest("li.collapsed"); + if (!next) { + collapsedAncestor.updateTotalMessageCount(); + break; + } + collapsedAncestor = next; + } + + // Update the row itself. + row.updateTotalMessageCount(); + }); + }, + + /** + * Called when a server's `prettyName` changes, to update the UI. + * + * @param {nsIMsgFolder} folder + * @param {string} name + */ + changeServerName(folder, name) { + for (let row of folderTree.querySelectorAll( + `li[data-server-key="${folder.server.key}"]` + )) { + row.setServerName(name); + } + }, + + /** + * Update the UI widget to reflect the real folder size when the "FolderSize" + * property changes. + * + * @param {nsIMsgFolder} folder + */ + changeFolderSize(folder) { + if (folderPane.isItemVisible("folderPaneFolderSize")) { + this._changeRows(folder, row => row.updateSizeCount(false, folder)); + } + }, + + _onSelect(event) { + const isSynthetic = gViewWrapper?.isSynthetic; + threadPane.saveSelection(); + threadPane.hideIgnoredMessageNotification(); + if (!isSynthetic) { + // Don't clear the message pane for synthetic views, as a message may have + // already been selected in restoreState(). + messagePane.clearAll(); + } + + let uri = folderTree.rows[folderTree.selectedIndex]?.uri; + if (!uri) { + gFolder = null; + return; + } + gFolder = MailServices.folderLookup.getFolderForURL(uri); + + // Bail out if this is synthetic view, such as a gloda search. + if (isSynthetic) { + return; + } + + document.head.querySelector(`link[rel="icon"]`).href = + FolderUtils.getFolderIcon(gFolder); + + // Clean up any existing view wrapper. This will invalidate the thread tree. + gViewWrapper?.close(); + + if (gFolder.isServer) { + document.title = gFolder.server.prettyName; + gViewWrapper = gDBView = threadTree.view = null; + + MailE10SUtils.loadURI( + accountCentralBrowser, + `chrome://messenger/content/msgAccountCentral.xhtml?folderURI=${encodeURIComponent( + gFolder.URI + )}` + ); + document.body.classList.add("account-central"); + accountCentralBrowser.hidden = false; + } else { + document.title = `${gFolder.name} - ${gFolder.server.prettyName}`; + document.body.classList.remove("account-central"); + accountCentralBrowser.hidden = true; + + quickFilterBar.activeElement = null; + threadPane.restoreColumns(); + + gViewWrapper = new DBViewWrapper(dbViewWrapperListener); + + threadPane.scrollToNewMessage = + !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) && + gFolder.hasNewMessages && + Services.prefs.getBoolPref("mailnews.scroll_to_new_message"); + if (threadPane.scrollToNewMessage) { + threadPane.forgetSelection(uri); + } + + gViewWrapper.open(gFolder); + + // At this point `dbViewWrapperListener.onCreatedView` gets called, + // setting up gDBView and scrolling threadTree to the right end. + + threadPane.updateListRole( + !gViewWrapper?.showThreaded && !gViewWrapper?.showGroupedBySort + ); + threadPane.restoreSortIndicator(); + threadPaneHeader.onFolderSelected(); + } + + this._updateStatusQuota(); + + window.dispatchEvent( + new CustomEvent("folderURIChanged", { bubbles: true, detail: uri }) + ); + }, + + /** + * Update the quotaPanel to reflect current folder quota status. + */ + _updateStatusQuota() { + if (top.window.document.getElementById("status-bar").hidden) { + return; + } + const quotaPanel = top.window.document.getElementById("quotaPanel"); + if (!(gFolder && gFolder instanceof Ci.nsIMsgImapMailFolder)) { + quotaPanel.hidden = true; + return; + } + + let tabListener = event => { + // Hide the pane if the new tab ain't us. + quotaPanel.hidden = + top.window.document.getElementById("tabmail").currentAbout3Pane == + this.window; + }; + top.window.document.removeEventListener("TabSelect", tabListener); + + // For display on main window panel only include quota names containing + // "STORAGE" or "MESSAGE". This will exclude unusual quota names containing + // items like "MAILBOX" and "LEVEL" from the panel bargraph. All quota names + // will still appear on the folder properties quota window. + // Note: Quota name is typically something like "User Quota / STORAGE". + let folderQuota = gFolder + .getQuota() + .filter( + quota => + quota.name.toUpperCase().includes("STORAGE") || + quota.name.toUpperCase().includes("MESSAGE") + ); + if (!folderQuota.length) { + quotaPanel.hidden = true; + return; + } + // If folderQuota not empty, find the index of the element with highest + // percent usage and determine if it is above the panel display threshold. + let quotaUsagePercentage = q => + Number((100n * BigInt(q.usage)) / BigInt(q.limit)); + let highest = folderQuota.reduce((acc, current) => + quotaUsagePercentage(acc) > quotaUsagePercentage(current) ? acc : current + ); + let percent = quotaUsagePercentage(highest); + if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.show") + ) { + quotaPanel.hidden = true; + } else { + quotaPanel.hidden = false; + top.window.document.addEventListener("TabSelect", tabListener); + + top.window.document + .getElementById("quotaMeter") + .setAttribute("value", percent); + + let usage; + let limit; + if (/STORAGE/i.test(highest.name)) { + let messenger = Cc["@mozilla.org/messenger;1"].createInstance( + Ci.nsIMessenger + ); + usage = messenger.formatFileSize(highest.usage * 1024); + limit = messenger.formatFileSize(highest.limit * 1024); + } else { + usage = highest.usage; + limit = highest.limit; + } + + top.window.document.getElementById("quotaLabel").value = `${percent}%`; + top.window.document.l10n.setAttributes( + top.window.document.getElementById("quotaLabel"), + "quota-panel-percent-used", + { percent, usage, limit } + ); + if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.warning") + ) { + quotaPanel.classList.remove("alert-warning", "alert-critical"); + } else if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.critical") + ) { + quotaPanel.classList.remove("alert-critical"); + quotaPanel.classList.add("alert-warning"); + } else { + quotaPanel.classList.remove("alert-warning"); + quotaPanel.classList.add("alert-critical"); + } + } + }, + + _onMiddleClick(event) { + if ( + event.target.closest(".mode-container") || + folderTree.selectedIndex == -1 + ) { + return; + } + const row = event.target.closest("li"); + if (!row) { + return; + } + + top.MsgOpenNewTabForFolders( + [MailServices.folderLookup.getFolderForURL(row.uri)], + { + event, + folderPaneVisible: !paneLayout.folderPaneSplitter.isCollapsed, + messagePaneVisible: !paneLayout.messagePaneSplitter.isCollapsed, + } + ); + }, + + _onContextMenu(event) { + if (folderTree.selectedIndex == -1) { + return; + } + + let popup = document.getElementById("folderPaneContext"); + + if (event.button == 2) { + // Mouse + if (event.target.closest(".mode-container")) { + return; + } + let row = event.target.closest("li"); + if (!row) { + return; + } + if (row.uri != gFolder.URI) { + // The right-clicked-on folder is not `gFolder`. Tell the context menu + // to use it instead. This override lasts until the context menu fires + // a "popuphidden" event. + folderPaneContextMenu.setOverrideFolder( + MailServices.folderLookup.getFolderForURL(row.uri) + ); + row.classList.add("context-menu-target"); + } + popup.openPopupAtScreen(event.screenX, event.screenY, true); + } else { + // Keyboard + let row = folderTree.getRowAtIndex(folderTree.selectedIndex); + popup.openPopup(row, "after_end", 0, 0, true); + } + + event.preventDefault(); + }, + + _onCollapsed({ target }) { + if (target.uri) { + let mode = target.closest("[data-mode]").dataset.mode; + FolderTreeProperties.setIsExpanded(target.uri, mode, false); + } + target.updateUnreadMessageCount(); + target.updateTotalMessageCount(); + target.updateNewMessages(); + }, + + _onExpanded({ target }) { + if (target.uri) { + let mode = target.closest("[data-mode]").dataset.mode; + FolderTreeProperties.setIsExpanded(target.uri, mode, true); + } + + const updateRecursively = row => { + row.updateUnreadMessageCount(); + row.updateTotalMessageCount(); + row.updateNewMessages(); + if (row.classList.contains("collapsed")) { + return; + } + for (const child of row.childList.children) { + updateRecursively(child); + } + }; + + updateRecursively(target); + + // Get server type. IMAP is the only server type that does folder discovery. + let folder = MailServices.folderLookup.getFolderForURL(target.uri); + if (folder.server.type == "imap") { + if (folder.isServer) { + folder.server.performExpand(top.msgWindow); + } else { + folder.QueryInterface(Ci.nsIMsgImapMailFolder); + folder.performExpand(top.msgWindow); + } + } + }, + + _onDragStart(event) { + let row = event.target.closest(`li[is="folder-tree-row"]`); + if (!row) { + event.preventDefault(); + return; + } + + let folder = MailServices.folderLookup.getFolderForURL(row.uri); + if (!folder || folder.isServer) { + event.preventDefault(); + return; + } + if (folder.server.type == "nntp") { + event.dataTransfer.mozSetDataAt("text/x-moz-newsfolder", folder, 0); + event.dataTransfer.effectAllowed = "move"; + return; + } + + event.dataTransfer.mozSetDataAt("text/x-moz-folder", folder, 0); + event.dataTransfer.effectAllowed = "copyMove"; + }, + + _onDragOver(event) { + const copyKey = + AppConstants.platform == "macosx" ? event.altKey : event.ctrlKey; + + event.dataTransfer.dropEffect = "none"; + event.preventDefault(); + + let row = event.target.closest("li"); + this._timedExpand(row); + if (!row) { + return; + } + + let targetFolder = MailServices.folderLookup.getFolderForURL(row.uri); + if (!targetFolder) { + return; + } + + let types = Array.from(event.dataTransfer.mozTypesAt(0)); + if (types.includes("text/x-moz-message")) { + if (targetFolder.isServer || !targetFolder.canFileMessages) { + return; + } + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let msgHdr = top.messenger.msgHdrFromURI( + event.dataTransfer.mozGetDataAt("text/x-moz-message", i) + ); + // Don't allow drop onto original folder. + if (msgHdr.folder == targetFolder) { + return; + } + } + event.dataTransfer.dropEffect = copyKey ? "copy" : "move"; + } else if (types.includes("text/x-moz-folder")) { + // If cannot create subfolders then don't allow drop here. + if (!targetFolder.canCreateSubfolders) { + return; + } + + let sourceFolder = event.dataTransfer + .mozGetDataAt("text/x-moz-folder", 0) + .QueryInterface(Ci.nsIMsgFolder); + + // Don't allow to drop on itself. + if (targetFolder == sourceFolder) { + return; + } + // Don't copy within same server. + if (sourceFolder.server == targetFolder.server && copyKey) { + return; + } + // Don't allow immediate child to be dropped onto its parent. + if (targetFolder == sourceFolder.parent) { + return; + } + // Don't allow dragging of virtual folders across accounts. + if ( + sourceFolder.getFlag(Ci.nsMsgFolderFlags.Virtual) && + sourceFolder.server != targetFolder.server + ) { + return; + } + // Don't allow parent to be dropped on its ancestors. + if (sourceFolder.isAncestorOf(targetFolder)) { + return; + } + // If there is a folder that can't be renamed, don't allow it to be + // dropped if it is not to "Local Folders" or is to the same account. + if ( + !sourceFolder.canRename && + (targetFolder.server.type != "none" || + sourceFolder.server == targetFolder.server) + ) { + return; + } + event.dataTransfer.dropEffect = copyKey ? "copy" : "move"; + } else if (types.includes("application/x-moz-file")) { + if (targetFolder.isServer || !targetFolder.canFileMessages) { + return; + } + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let extFile = event.dataTransfer + .mozGetDataAt("application/x-moz-file", i) + .QueryInterface(Ci.nsIFile); + if (!extFile.isFile() || !/\.eml$/i.test(extFile.leafName)) { + return; + } + } + event.dataTransfer.dropEffect = "copy"; + } else if (types.includes("text/x-moz-newsfolder")) { + let folder = event.dataTransfer + .mozGetDataAt("text/x-moz-newsfolder", 0) + .QueryInterface(Ci.nsIMsgFolder); + if ( + targetFolder.isServer || + targetFolder.server.type != "nntp" || + folder == targetFolder || + folder.server != targetFolder.server + ) { + return; + } + event.dataTransfer.dropEffect = "move"; + } else if ( + types.includes("text/x-moz-url-data") || + types.includes("text/x-moz-url") + ) { + // Allow subscribing to feeds by dragging an url to a feed account. + if ( + targetFolder.server.type == "rss" && + !targetFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Trash, true) && + event.dataTransfer.items.length == 1 && + FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer) + ) { + return; + } + event.dataTransfer.dropEffect = "link"; + } else { + return; + } + + this._clearDropTarget(); + row.classList.add("drop-target"); + }, + + /** + * Set a timer to expand `row` in 500ms. If called again before the timer + * expires and with a different row, the timer is cleared and a new one + * started. If `row` is falsy or isn't collapsed the timer is cleared. + * + * @param {HTMLLIElement?} row + */ + _timedExpand(row) { + if (this._expandRow == row) { + return; + } + if (this._expandTimer) { + clearTimeout(this._expandTimer); + } + if (!row?.classList.contains("collapsed")) { + return; + } + this._expandRow = row; + this._expandTimer = setTimeout(() => { + folderTree.expandRow(this._expandRow); + delete this._expandRow; + delete this._expandTimer; + }, 1000); + }, + + _clearDropTarget() { + folderTree.querySelector(".drop-target")?.classList.remove("drop-target"); + }, + + _onDrop(event) { + this._timedExpand(); + this._clearDropTarget(); + if (event.dataTransfer.dropEffect == "none") { + // Somehow this is possible. It should not be possible. + return; + } + + let row = event.target.closest("li"); + if (!row) { + return; + } + + let targetFolder = MailServices.folderLookup.getFolderForURL(row.uri); + + let types = Array.from(event.dataTransfer.mozTypesAt(0)); + if (types.includes("text/x-moz-message")) { + let array = []; + let sourceFolder; + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let msgHdr = top.messenger.msgHdrFromURI( + event.dataTransfer.mozGetDataAt("text/x-moz-message", i) + ); + if (!i) { + sourceFolder = msgHdr.folder; + } + array.push(msgHdr); + } + let isMove = event.dataTransfer.dropEffect == "move"; + let isNews = sourceFolder.flags & Ci.nsMsgFolderFlags.Newsgroup; + if (!sourceFolder.canDeleteMessages || isNews) { + isMove = false; + } + + Services.prefs.setStringPref( + "mail.last_msg_movecopy_target_uri", + targetFolder.URI + ); + Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove); + // ### ugh, so this won't work with cross-folder views. We would + // really need to partition the messages by folder. + if (isMove) { + dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete(); + } + MailServices.copy.copyMessages( + sourceFolder, + array, + targetFolder, + isMove, + null, + top.msgWindow, + true + ); + } else if (types.includes("text/x-moz-folder")) { + let sourceFolder = event.dataTransfer + .mozGetDataAt("text/x-moz-folder", 0) + .QueryInterface(Ci.nsIMsgFolder); + let isMove = event.dataTransfer.dropEffect == "move"; + isMove = folderPaneContextMenu.transferFolder( + isMove, + sourceFolder, + targetFolder + ); + // Save in prefs the target folder URI and if this was a move or copy. + // This is to fill in the next folder or message context menu item + // "Move|Copy to <TargetFolderName> Again". + Services.prefs.setStringPref( + "mail.last_msg_movecopy_target_uri", + targetFolder.URI + ); + Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", isMove); + } else if (types.includes("application/x-moz-file")) { + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let extFile = event.dataTransfer + .mozGetDataAt("application/x-moz-file", i) + .QueryInterface(Ci.nsIFile); + if (extFile.isFile() && /\.eml$/i.test(extFile.leafName)) { + MailServices.copy.copyFileMessage( + extFile, + targetFolder, + null, + false, + 1, + "", + null, + top.msgWindow + ); + } + } + } else if (types.includes("text/x-moz-newsfolder")) { + let folder = event.dataTransfer + .mozGetDataAt("text/x-moz-newsfolder", 0) + .QueryInterface(Ci.nsIMsgFolder); + + let mode = row.closest("li[data-mode]").dataset.mode; + let newsRoot = targetFolder.rootFolder.QueryInterface( + Ci.nsIMsgNewsFolder + ); + newsRoot.reorderGroup(folder, targetFolder); + setTimeout( + () => (folderTree.selectedRow = this.getRowForFolder(folder, mode)) + ); + } else if ( + types.includes("text/x-moz-url-data") || + types.includes("text/x-moz-url") + ) { + // This is a potential rss feed. A link image as well as link text url + // should be handled; try to extract a url from non moz apps as well. + let feedURI = FeedUtils.getFeedUriFromDataTransfer(event.dataTransfer); + FeedUtils.subscribeToFeed(feedURI.spec, targetFolder); + } + + event.preventDefault(); + }, + + /** + * Opens the dialog to create a new sub-folder, and creates it if the user + * accepts. + * + * @param {?nsIMsgFolder} aParent - The parent for the new subfolder. + */ + newFolder(aParent) { + let folder = aParent; + + // Make sure we actually can create subfolders. + if (!folder?.canCreateSubfolders) { + // Check if we can create them at the root, otherwise use the default + // account as root folder. + let rootMsgFolder = folder.server.rootMsgFolder; + folder = rootMsgFolder.canCreateSubfolders + ? rootMsgFolder + : top.GetDefaultAccountRootFolder(); + } + + if (!folder) { + return; + } + + let dualUseFolders = true; + if (folder.server instanceof Ci.nsIImapIncomingServer) { + dualUseFolders = folder.server.dualUseFolders; + } + + function newFolderCallback(aName, aFolder) { + // createSubfolder can throw an exception, causing the newFolder dialog + // to not close and wait for another input. + // TODO: Rewrite this logic and also move the opening of alert dialogs from + // nsMsgLocalMailFolder::CreateSubfolderInternal to here (bug 831190#c16). + if (!aName) { + return; + } + aFolder.createSubfolder(aName, top.msgWindow); + // Don't call the rebuildAfterChange() here as we'll need to wait for the + // new folder to be properly created before rebuilding the tree. + } + + window.openDialog( + "chrome://messenger/content/newFolderDialog.xhtml", + "", + "chrome,modal,resizable=no,centerscreen", + { folder, dualUseFolders, okCallback: newFolderCallback } + ); + }, + + /** + * Opens the dialog to edit the properties for a folder + * + * @param {nsIMsgFolder} [folder] - Folder to edit, if not the selected one. + * @param {string} [tabID] - Id of initial tab to select in the folder + * properties dialog. + */ + editFolder(folder = gFolder, tabID) { + // If this is actually a server, send it off to that controller + if (folder.isServer) { + top.MsgAccountManager(null, folder.server); + return; + } + + if (folder.getFlag(Ci.nsMsgFolderFlags.Virtual)) { + this.editVirtualFolder(folder); + return; + } + let title = messengerBundle.GetStringFromName("folderProperties"); + + function editFolderCallback(newName, oldName) { + if (newName != oldName) { + folder.rename(newName, top.msgWindow); + } + } + + async function rebuildSummary() { + if (folder.locked) { + folder.throwAlertMsg("operationFailedFolderBusy", top.msgWindow); + return; + } + if (folder.supportsOffline) { + // Remove the offline store, if any. + await IOUtils.remove(folder.filePath.path, { recursive: true }).catch( + console.error + ); + } + + // We may be rebuilding a folder that is not the displayed one. + // TODO: Close any open views of this folder. + + // Send a notification that we are triggering a database rebuild. + MailServices.mfn.notifyFolderReindexTriggered(folder); + + folder.msgDatabase.summaryValid = false; + + const msgDB = folder.msgDatabase; + msgDB.summaryValid = false; + try { + folder.closeAndBackupFolderDB(""); + } catch (e) { + // In a failure, proceed anyway since we're dealing with problems + folder.ForceDBClosed(); + } + if (gFolder == folder) { + gViewWrapper?.close(); + folder.updateFolder(top.msgWindow); + folderTree.dispatchEvent(new CustomEvent("select")); + } else { + folder.updateFolder(top.msgWindow); + } + } + + window.openDialog( + "chrome://messenger/content/folderProps.xhtml", + "", + "chrome,modal,centerscreen", + { + folder, + serverType: folder.server.type, + msgWindow: top.msgWindow, + title, + okCallback: editFolderCallback, + tabID, + name: folder.prettyName, + rebuildSummaryCallback: rebuildSummary, + } + ); + }, + + /** + * Opens the dialog to rename a particular folder, and does the renaming if + * the user clicks OK in that dialog + * + * @param [aFolder] - The folder to rename, if different than the currently + * selected one. + */ + renameFolder(aFolder) { + let folder = aFolder; + + function renameCallback(aName, aUri) { + if (aUri != folder.URI) { + console.error("got back a different folder to rename!"); + } + + // Actually do the rename. + folder.rename(aName, top.msgWindow); + } + window.openDialog( + "chrome://messenger/content/renameFolderDialog.xhtml", + "", + "chrome,modal,centerscreen", + { + preselectedURI: folder.URI, + okCallback: renameCallback, + name: folder.prettyName, + } + ); + }, + + /** + * Deletes a folder from its parent. Also handles unsubscribe from newsgroups + * if the selected folder/s happen to be nntp. + * + * @param [folder] - The folder to delete, if not the selected one. + */ + deleteFolder(folder) { + // For newsgroups, "delete" means "unsubscribe". + if ( + folder.server.type == "nntp" && + !folder.getFlag(Ci.nsMsgFolderFlags.Virtual) + ) { + top.MsgUnsubscribe([folder]); + return; + } + + const canDelete = folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk, false) + ? FolderUtils.canRenameDeleteJunkMail(folder.URI) + : folder.deletable; + + if (!canDelete) { + throw new Error("Can't delete folder: " + folder.name); + } + + if (folder.getFlag(Ci.nsMsgFolderFlags.Virtual)) { + let confirmation = messengerBundle.GetStringFromName( + "confirmSavedSearchDeleteMessage" + ); + let title = messengerBundle.GetStringFromName("confirmSavedSearchTitle"); + if ( + Services.prompt.confirmEx( + window, + title, + confirmation, + Services.prompt.STD_YES_NO_BUTTONS + + Services.prompt.BUTTON_POS_1_DEFAULT, + "", + "", + "", + "", + {} + ) != 0 + ) { + /* the yes button is in position 0 */ + return; + } + } + + try { + folder.deleteSelf(top.msgWindow); + } catch (ex) { + // Ignore known errors from canceled warning dialogs. + const NS_MSG_ERROR_COPY_FOLDER_ABORTED = 0x8055001a; + if (ex.result != NS_MSG_ERROR_COPY_FOLDER_ABORTED) { + throw ex; + } + } + }, + + /** + * Prompts the user to confirm and empties the trash for the selected folder. + * The folder and its children are only emptied if it has the proper Trash flag. + * + * @param [aFolder] - The trash folder to empty. If unspecified or not a trash + * folder, the currently selected server's trash folder is used. + */ + emptyTrash(aFolder) { + let folder = aFolder; + if (!folder.getFlag(Ci.nsMsgFolderFlags.Trash)) { + folder = folder.rootFolder.getFolderWithFlags(Ci.nsMsgFolderFlags.Trash); + } + if (!folder) { + return; + } + + if (!this._checkConfirmationPrompt("emptyTrash", folder)) { + return; + } + + // Check if this is a top-level smart folder. If so, we're going + // to empty all the trash folders. + if (FolderUtils.isSmartVirtualFolder(folder)) { + for (let server of MailServices.accounts.allServers) { + for (let trash of server.rootFolder.getFoldersWithFlags( + Ci.nsMsgFolderFlags.Trash + )) { + trash.emptyTrash(null); + } + } + } else { + folder.emptyTrash(null); + } + }, + + /** + * Deletes everything (folders and messages) in the selected folder. + * The folder is only emptied if it has the proper Junk flag. + * + * @param {nsIMsgFolder} folder - The folder to empty. + * @param {boolean} [prompt=true] - If the user should be prompted. + */ + emptyJunk(folder, prompt = true) { + if (!folder || !folder.getFlag(Ci.nsMsgFolderFlags.Junk)) { + return; + } + + if (prompt && !this._checkConfirmationPrompt("emptyJunk", folder)) { + return; + } + + if (FolderUtils.isSmartVirtualFolder(folder)) { + // This is the unified junk folder. + let wrappedFolder = VirtualFolderHelper.wrapVirtualFolder(folder); + for (let searchFolder of wrappedFolder.searchFolders) { + this.emptyJunk(searchFolder, false); + } + return; + } + + // Delete any subfolders this folder might have + for (let subFolder of folder.subFolders) { + folder.propagateDelete(subFolder, true); + } + + let messages = [...folder.messages]; + if (!messages.length) { + return; + } + + // Now delete the messages + folder.deleteMessages(messages, top.msgWindow, true, false, null, false); + }, + + /** + * Compacts the given folder. + * + * @param {nsIMsgFolder} folder + */ + compactFolder(folder) { + // Can't compact folders that have just been compacted. + if (folder.server.type != "imap" && !folder.expungedBytes) { + return; + } + + folder.compact(null, top.msgWindow); + }, + + /** + * Compacts all folders for the account that the given folder belongs to. + * + * @param {nsIMsgFolder} folder + */ + compactAllFoldersForAccount(folder) { + folder.rootFolder.compactAll(null, top.msgWindow); + }, + + /** + * Opens the dialog to create a new virtual folder + * + * @param aName - The default name for the new folder. + * @param aSearchTerms - The search terms associated with the folder. + * @param aParent - The folder to run the search terms on. + */ + newVirtualFolder(aName, aSearchTerms, aParent) { + let folder = aParent || top.GetDefaultAccountRootFolder(); + if (!folder) { + return; + } + + let name = folder.prettyName; + if (aName) { + name += "-" + aName; + } + + window.openDialog( + "chrome://messenger/content/virtualFolderProperties.xhtml", + "", + "chrome,modal,centerscreen,resizable=yes", + { + folder, + searchTerms: aSearchTerms, + newFolderName: name, + } + ); + }, + + editVirtualFolder(aFolder) { + let folder = aFolder; + + function editVirtualCallback() { + if (gFolder == folder) { + folderTree.dispatchEvent(new CustomEvent("select")); + } + } + window.openDialog( + "chrome://messenger/content/virtualFolderProperties.xhtml", + "", + "chrome,modal,centerscreen,resizable=yes", + { + folder, + editExistingFolder: true, + onOKCallback: editVirtualCallback, + msgWindow: top.msgWindow, + } + ); + }, + + /** + * Prompts for confirmation, if the user hasn't already chosen the "don't ask + * again" option. + * + * @param aCommand - The command to prompt for. + * @param aFolder - The folder for which the confirmation is requested. + */ + _checkConfirmationPrompt(aCommand, aFolder) { + // If no folder was specified, reject the operation. + if (!aFolder) { + return false; + } + + let showPrompt = !Services.prefs.getBoolPref( + "mailnews." + aCommand + ".dontAskAgain", + false + ); + + if (showPrompt) { + let checkbox = { value: false }; + let title = messengerBundle.formatStringFromName( + aCommand + "FolderTitle", + [aFolder.prettyName] + ); + let msg = messengerBundle.GetStringFromName(aCommand + "FolderMessage"); + let ok = + Services.prompt.confirmEx( + window, + title, + msg, + Services.prompt.STD_YES_NO_BUTTONS, + null, + null, + null, + messengerBundle.GetStringFromName(aCommand + "DontAsk"), + checkbox + ) == 0; + if (checkbox.value) { + Services.prefs.setBoolPref( + "mailnews." + aCommand + ".dontAskAgain", + true + ); + } + if (!ok) { + return false; + } + } + return true; + }, + + /** + * Update those UI elements that rely on the presence of a server to function. + */ + updateWidgets() { + this._updateGetMessagesWidgets(); + this._updateWriteMessageWidgets(); + }, + + _updateGetMessagesWidgets() { + const canGetMessages = MailServices.accounts.allServers.some( + s => s.type != "none" + ); + document.getElementById("folderPaneGetMessages").disabled = !canGetMessages; + }, + + _updateWriteMessageWidgets() { + const canWriteMessages = MailServices.accounts.allIdentities.length; + document.getElementById("folderPaneWriteMessage").disabled = + !canWriteMessages; + }, + + isFolderPaneGetMsgsBtnHidden() { + return this.isItemHidden("folderPaneGetMessages"); + }, + + isFolderPaneNewMsgBtnHidden() { + return this.isItemHidden("folderPaneWriteMessage"); + }, + + isFolderPaneHeaderHidden() { + return this.isItemHidden("folderPaneHeaderBar"); + }, + + isItemHidden(item) { + return Services.xulStore.getValue(XULSTORE_URL, item, "hidden") == "true"; + }, + + isItemVisible(item) { + return Services.xulStore.getValue(XULSTORE_URL, item, "visible") == "true"; + }, + + /** + * Ensure the pane header context menu items are correctly checked. + */ + updateContextMenuCheckedItems() { + for (let item of document.querySelectorAll(".folder-pane-option")) { + switch (item.id) { + case "folderPaneHeaderToggleGetMessages": + this.isFolderPaneGetMsgsBtnHidden() + ? item.removeAttribute("checked") + : item.setAttribute("checked", true); + break; + case "folderPaneHeaderToggleNewMessage": + this.isFolderPaneNewMsgBtnHidden() + ? item.removeAttribute("checked") + : item.setAttribute("checked", true); + break; + case "folderPaneHeaderToggleTotalCount": + this.isTotalMsgCountVisible() + ? item.setAttribute("checked", true) + : item.removeAttribute("checked"); + break; + case "folderPaneMoreContextCompactToggle": + this.isCompact + ? item.setAttribute("checked", true) + : item.removeAttribute("checked"); + this.toggleCompactViewMenuItem(); + break; + case "folderPaneHeaderToggleFolderSize": + this.isItemVisible("folderPaneFolderSize") + ? item.setAttribute("checked", true) + : item.removeAttribute("checked"); + break; + case "folderPaneHeaderToggleLocalFolders": + this.isItemHidden("folderPaneLocalFolders") + ? item.setAttribute("checked", true) + : item.removeAttribute("checked"); + break; + default: + item.removeAttribute("checked"); + break; + } + } + }, + + toggleGetMsgsBtn(event) { + let show = event.target.hasAttribute("checked"); + document.getElementById("folderPaneGetMessages").hidden = !show; + + this.updateXULStoreAttribute("folderPaneGetMessages", "hidden", show); + }, + + toggleNewMsgBtn(event) { + let show = event.target.hasAttribute("checked"); + document.getElementById("folderPaneWriteMessage").hidden = !show; + + this.updateXULStoreAttribute("folderPaneWriteMessage", "hidden", show); + }, + + toggleHeader(show) { + document.getElementById("folderPaneHeaderBar").hidden = !show; + this.updateXULStoreAttribute("folderPaneHeaderBar", "hidden", show); + }, + + updateXULStoreAttribute(element, attribute, value) { + Services.xulStore.setValue( + XULSTORE_URL, + element, + attribute, + value ? "false" : "true" + ); + }, + + /** + * Ensure the folder rows UI elements reflect the state set by the user. + */ + updateFolderRowUIElements() { + this.toggleTotalCountBadge(); + this.toggleFolderSizes(this.isItemVisible("folderPaneFolderSize")); + }, + + /** + * Check XULStore to see if the total message count badges should be hidden. + */ + isTotalMsgCountVisible() { + return this.isItemVisible("totalMsgCount"); + }, + + /** + * Toggle the total message count badges and update the XULStore. + */ + toggleTotal(event) { + let show = !event.target.hasAttribute("checked"); + this.updateXULStoreAttribute("totalMsgCount", "visible", show); + this.toggleTotalCountBadge(); + }, + + toggleTotalCountBadge() { + const isHidden = !this.isTotalMsgCountVisible(); + for (let row of document.querySelectorAll(`li[is="folder-tree-row"]`)) { + row.toggleTotalCountBadgeVisibility(isHidden); + } + }, + + /** + * Toggle the folder size option and update the XULStore. + */ + toggleFolderSize(event) { + let show = !event.target.hasAttribute("checked"); + this.updateXULStoreAttribute("folderPaneFolderSize", "visible", show); + this.toggleFolderSizes(!show); + }, + + /** + * Toggle the folder size info on each folder. + */ + toggleFolderSizes(visible) { + const isHidden = !visible; + for (let row of document.querySelectorAll(`li[is="folder-tree-row"]`)) { + row.updateSizeCount(isHidden); + } + }, + + /** + * Toggle the hiding of the local folders and update the XULStore. + */ + toggleLocalFolders(event) { + let isHidden = event.target.hasAttribute("checked"); + this.updateXULStoreAttribute("folderPaneLocalFolders", "hidden", !isHidden); + folderPane.hideLocalFolders = isHidden; + }, + + /** + * Populate the "Get Messages" context menu with all available servers that + * we can fetch data for. + */ + updateGetMessagesContextMenu() { + const menupopup = document.getElementById("folderPaneGetMessagesContext"); + while (menupopup.lastElementChild.classList.contains("server")) { + menupopup.lastElementChild.remove(); + } + + // Get all servers in the proper sorted order. + const servers = FolderUtils.allAccountsSorted(true) + .map(a => a.incomingServer) + .filter(s => s.rootFolder.isServer && s.type != "none"); + for (let server of servers) { + const menuitem = document.createXULElement("menuitem"); + menuitem.classList.add("menuitem-iconic", "server"); + menuitem.dataset.serverType = server.type; + menuitem.dataset.serverSecure = server.isSecure; + menuitem.label = server.prettyName; + menuitem.addEventListener("command", () => + top.MsgGetMessagesForAccount(server.rootFolder) + ); + menupopup.appendChild(menuitem); + } + }, +}; + +/** + * Represents a single row in the folder tree. The row can be for a server or + * a folder. Use `folderPane._createServerRow` or `folderPane._createFolderRow` + * to create rows. + */ +class FolderTreeRow extends HTMLLIElement { + /** + * Used for comparing folder names. This matches the collator used in + * `nsMsgDBFolder::createCollationKeyGenerator`. + * @type {Intl.Collator} + */ + static nameCollator = new Intl.Collator(undefined, { sensitivity: "base" }); + + /** + * Creates an identifier unique for the given mode name and folder URI. + * + * @param {string} modeName + * @param {string} uri + * @returns {string} + */ + static makeRowID(modeName, uri) { + return `${modeName}-${btoa(MailStringUtils.stringToByteString(uri))}`; + } + + /** + * The name of the folder tree mode this row belongs to. + * @type {string} + */ + modeName; + /** + * The URI of the folder represented by this row. + * @type {string} + */ + uri; + /** + * How many times this row is nested. 1 or greater. + * @type {integer} + */ + depth; + /** + * The sort order of this row's associated folder. + * @type {integer} + */ + folderSortOrder; + + /** @type {HTMLSpanElement} */ + nameLabel; + /** @type {HTMLImageElement} */ + icon; + /** @type {HTMLSpanElement} */ + unreadCountLabel; + /** @type {HTMLUListElement} */ + totalCountLabel; + /** @type {HTMLSpanElement} */ + folderSizeLabel; + /** @type {HTMLUListElement} */ + childList; + + constructor() { + super(); + this.setAttribute("is", "folder-tree-row"); + this.append(folderPane._folderTemplate.content.cloneNode(true)); + this.nameLabel = this.querySelector(".name"); + this.icon = this.querySelector(".icon"); + this.unreadCountLabel = this.querySelector(".unread-count"); + this.totalCountLabel = this.querySelector(".total-count"); + this.folderSizeLabel = this.querySelector(".folder-size"); + this.childList = this.querySelector("ul"); + } + + connectedCallback() { + // Set the correct CSS `--depth` variable based on where this row was + // inserted into the tree. + let parent = this.parentNode.closest(`li[is="folder-tree-row"]`); + this.depth = parent ? parent.depth + 1 : 1; + this.childList.style.setProperty("--depth", this.depth); + } + + /** + * The name to display for this folder or server. + * + * @type {string} + */ + get name() { + return this.nameLabel.textContent; + } + + set name(value) { + if (this.name != value) { + this.nameLabel.textContent = value; + this.#updateAriaLabel(); + } + } + + /** + * Format and set the name label of this row. + */ + _setName() { + switch (this._nameStyle) { + case "server": + this.name = this._serverName; + break; + case "folder": + this.name = this._folderName; + break; + case "both": + this.name = `${this._folderName} - ${this._serverName}`; + break; + } + } + + /** + * The number of unread messages for this folder. + * + * @type {integer} + */ + get unreadCount() { + return parseInt(this.unreadCountLabel.textContent, 10) || 0; + } + + set unreadCount(value) { + this.classList.toggle("unread", value > 0); + // Avoid setting `textContent` if possible, each change notifies the + // MutationObserver on `folderTree`, and there could be *many* changes. + let textNode = this.unreadCountLabel.firstChild; + if (textNode) { + textNode.nodeValue = value; + } else { + this.unreadCountLabel.textContent = value; + } + this.#updateAriaLabel(); + } + + /** + * The total number of messages for this folder. + * + * @type {integer} + */ + get totalCount() { + return parseInt(this.totalCountLabel.textContent, 10) || 0; + } + + set totalCount(value) { + this.classList.toggle("total", value > 0); + this.totalCountLabel.textContent = value; + this.#updateAriaLabel(); + } + + /** + * The folder size for this folder. + * + * @type {integer} + */ + get folderSize() { + return this.folderSizeLabel.textContent; + } + + set folderSize(value) { + this.folderSizeLabel.textContent = value; + this.#updateAriaLabel(); + } + + #updateAriaLabel() { + // Collect the various strings and fluent IDs to build the full string for + // the folder aria-label. + let ariaLabelPromises = []; + ariaLabelPromises.push(this.name); + + // If unread messages. + const count = this.unreadCount; + if (count > 0) { + ariaLabelPromises.push( + document.l10n.formatValue("folder-pane-unread-aria-label", { count }) + ); + } + + // If total messages is visible. + if (folderPane.isTotalMsgCountVisible()) { + ariaLabelPromises.push( + document.l10n.formatValue("folder-pane-total-aria-label", { + count: this.totalCount, + }) + ); + } + + if (folderPane.isItemVisible("folderPaneFolderSize")) { + ariaLabelPromises.push(this.folderSize); + } + + Promise.allSettled(ariaLabelPromises).then(results => { + const folderLabel = results + .map(settledPromise => settledPromise.value ?? "") + .filter(value => value.trim() != "") + .join(", "); + this.setAttribute("aria-label", folderLabel); + this.title = folderLabel; + }); + } + + /** + * Set some common properties based on the URI for this row. + * `this.modeName` must be set before calling this function. + * + * @param {string} uri + */ + _setURI(uri) { + this.id = FolderTreeRow.makeRowID(this.modeName, uri); + this.uri = uri; + if (!FolderTreeProperties.getIsExpanded(uri, this.modeName)) { + this.classList.add("collapsed"); + } + this.setIconColor(); + } + + /** + * Set the icon color to the given color, or if none is given the value from + * FolderTreeProperties, or the default. + * + * @param {string?} iconColor + */ + setIconColor(iconColor) { + if (!iconColor) { + iconColor = FolderTreeProperties.getColor(this.uri); + } + this.icon.style.setProperty("--icon-color", iconColor ?? ""); + } + + /** + * Set some properties based on the server for this row. + * + * @param {nsIMsgIncomingServer} server + */ + setServer(server) { + this._setURI(server.rootFolder.URI); + this.dataset.serverKey = server.key; + this.dataset.serverType = server.type; + this.dataset.serverSecure = server.isSecure; + this._nameStyle = "server"; + this._serverName = server.prettyName; + this._setName(); + const isCollapsed = this.classList.contains("collapsed"); + if (isCollapsed) { + this.unreadCount = server.rootFolder.getNumUnread(isCollapsed); + this.totalCount = server.rootFolder.getTotalMessages(isCollapsed); + } + this.setFolderPropertiesFromFolder(server.rootFolder); + } + + /** + * Set some properties based on the folder for this row. + * + * @param {nsIMsgFolder} folder + * @param {"folder"|"server"|"both"} nameStyle + */ + setFolder(folder, nameStyle = "folder") { + this._setURI(folder.URI); + this.dataset.serverKey = folder.server.key; + this.setFolderTypeFromFolder(folder); + this.setFolderPropertiesFromFolder(folder); + this._nameStyle = nameStyle; + this._serverName = folder.server.prettyName; + this._folderName = folder.abbreviatedName; + this._setName(); + const isCollapsed = this.classList.contains("collapsed"); + this.unreadCount = folder.getNumUnread(isCollapsed); + this.totalCount = folder.getTotalMessages(isCollapsed); + if (folderPane.isItemVisible("folderPaneFolderSize")) { + this.folderSize = this.formatFolderSize(folder.sizeOnDisk); + } + this.folderSortOrder = folder.sortOrder; + if (folder.noSelect) { + this.classList.add("noselect-folder"); + } else { + this.setAttribute("draggable", "true"); + } + } + + /** + * Update new message state of the row. + * + * @param {boolean} [notifiedOfNewMessages=false] - When true there are new + * messages on the server, but they may not yet be downloaded locally. + */ + updateNewMessages(notifiedOfNewMessages = false) { + const folder = MailServices.folderLookup.getFolderForURL(this.uri); + const foldersHaveNewMessages = this.classList.contains("collapsed") + ? folder.hasFolderOrSubfolderNewMessages + : folder.hasNewMessages; + this.classList.toggle( + "new-messages", + notifiedOfNewMessages || foldersHaveNewMessages + ); + } + + updateUnreadMessageCount() { + this.unreadCount = MailServices.folderLookup + .getFolderForURL(this.uri) + .getNumUnread(this.classList.contains("collapsed")); + } + + updateTotalMessageCount() { + const folder = MailServices.folderLookup.getFolderForURL(this.uri); + this.totalCount = folder.getTotalMessages( + this.classList.contains("collapsed") + ); + if (folderPane.isItemVisible("folderPaneFolderSize")) { + this.updateSizeCount(false, folder); + } + } + + updateSizeCount(isHidden, folder = null) { + this.folderSizeLabel.hidden = isHidden; + if (!isHidden) { + folder = folder ?? MailServices.folderLookup.getFolderForURL(this.uri); + this.folderSize = this.formatFolderSize(folder.sizeOnDisk); + } + } + + /** + * Format the folder file size to display in the folder pane. + * + * @param {integer} size - The folder size on disk. + * @returns {string} - The formatted folder size. + */ + formatFolderSize(size) { + return size / 1024 < 1 ? "" : top.messenger.formatFileSize(size, true); + } + + /** + * Update the visibility of the total count badge. + * + * @param {boolean} isHidden + */ + toggleTotalCountBadgeVisibility(isHidden) { + this.totalCountLabel.hidden = isHidden; + this.#updateAriaLabel(); + } + + /** + * Sets the folder type property based on the folder for the row. + * + * @param {nsIMsgFolder} folder + */ + setFolderTypeFromFolder(folder) { + let folderType = FolderUtils.getSpecialFolderString(folder); + if (folderType != "none") { + this.dataset.folderType = folderType.toLowerCase(); + } + } + + /** + * Sets folder properties based on the folder for the row. + * + * @param {nsIMsgFolder} folder + */ + setFolderPropertiesFromFolder(folder) { + if (folder.server.type != "rss") { + return; + } + let urls = !folder.isServer ? FeedUtils.getFeedUrlsInFolder(folder) : null; + if (urls?.length == 1) { + let url = urls[0]; + this.icon.style = `content: url("page-icon:${url}"); background-image: none;`; + } + let props = FeedUtils.getFolderProperties(folder); + for (let name of ["hasError", "isBusy", "isPaused"]) { + if (props.includes(name)) { + this.dataset[name] = "true"; + } else { + delete this.dataset[name]; + } + } + } + + /** + * Update this row's name label to match the new `prettyName` of the server. + * + * @param {string} name + */ + setServerName(name) { + this._serverName = name; + if (this._nameStyle != "folder") { + this._setName(); + } + } + + /** + * Add a child row in the correct sort order. + * + * @param {FolderTreeRow} newChild + * @returns {FolderTreeRow} + */ + insertChildInOrder(newChild) { + let { folderSortOrder, name } = newChild; + for (let child of this.childList.children) { + if (folderSortOrder < child.folderSortOrder) { + return this.childList.insertBefore(newChild, child); + } + if ( + folderSortOrder == child.folderSortOrder && + FolderTreeRow.nameCollator.compare(name, child.name) < 0 + ) { + return this.childList.insertBefore(newChild, child); + } + } + return this.childList.appendChild(newChild); + } +} +customElements.define("folder-tree-row", FolderTreeRow, { extends: "li" }); + +/** + * Header area of the message list pane. + */ +var threadPaneHeader = { + /** + * The header bar element. + * @type {?HTMLElement} + */ + bar: null, + /** + * The h2 element receiving the folder name. + * @type {?HTMLHeadElement} + */ + folderName: null, + /** + * The span element receiving the message count. + * @type {?HTMLSpanElement} + */ + folderCount: null, + /** + * The quick filter toolbar toggle button. + * @type {?HTMLButtonElement} + */ + filterButton: null, + /** + * The display options button opening the popup. + * @type {?HTMLButtonElement} + */ + displayButton: null, + /** + * If the header area is hidden. + * @type {boolean} + */ + isHidden: false, + + init() { + this.isHidden = + Services.xulStore.getValue(XULSTORE_URL, "threadPaneHeader", "hidden") === + "true"; + this.bar = document.getElementById("threadPaneHeaderBar"); + this.bar.hidden = this.isHidden; + + this.folderName = document.getElementById("threadPaneFolderName"); + this.folderCount = document.getElementById("threadPaneFolderCount"); + this.selectedCount = document.getElementById("threadPaneSelectedCount"); + this.filterButton = document.getElementById("threadPaneQuickFilterButton"); + this.filterButton.addEventListener("click", () => + goDoCommand("cmd_toggleQuickFilterBar") + ); + window.addEventListener("qfbtoggle", this); + this.onQuickFilterToggle(); + + this.displayButton = document.getElementById("threadPaneDisplayButton"); + this.displayContext = document.getElementById("threadPaneDisplayContext"); + this.displayButton.addEventListener("click", event => { + this.displayContext.openPopup(event.target, { triggerEvent: event }); + }); + }, + + uninit() { + window.removeEventListener("qfbtoggle", this); + }, + + handleEvent(event) { + switch (event.type) { + case "qfbtoggle": + this.onQuickFilterToggle(); + break; + } + }, + + /** + * Update the context menu to reflect the currently selected display options. + * + * @param {Event} event - The popupshowing DOMEvent. + */ + updateDisplayContextMenu(event) { + if (event.target.id != "threadPaneDisplayContext") { + return; + } + const isTableLayout = document.body.classList.contains("layout-table"); + document + .getElementById( + isTableLayout ? "threadPaneTableView" : "threadPaneCardsView" + ) + .setAttribute("checked", "true"); + }, + + /** + * Update the menuitems inside the thread pane sort menupopup. + * + * @param {Event} event - The popupshowing DOMEvent. + */ + updateThreadPaneSortMenu(event) { + if (event.target.id != "menu_threadPaneSortPopup") { + return; + } + + const hiddenColumns = threadPane.columns + .filter(c => c.hidden) + .map(c => c.sortKey); + + // Update menuitem to reflect sort key. + for (const menuitem of event.target.querySelectorAll(`[name="sortby"]`)) { + const sortKey = menuitem.getAttribute("value"); + menuitem.setAttribute( + "checked", + gViewWrapper.primarySortType == Ci.nsMsgViewSortType[sortKey] + ); + if (hiddenColumns.includes(sortKey)) { + menuitem.setAttribute("disabled", "true"); + } else { + menuitem.removeAttribute("disabled"); + } + } + + // Update sort direction menu items. + event.target + .querySelector(`[value="ascending"]`) + .setAttribute("checked", gViewWrapper.isSortedAscending); + event.target + .querySelector(`[value="descending"]`) + .setAttribute("checked", !gViewWrapper.isSortedAscending); + + // Update the threaded and groupedBy menu items. + event.target + .querySelector(`[value="threaded"]`) + .setAttribute("checked", gViewWrapper.showThreaded); + event.target + .querySelector(`[value="unthreaded"]`) + .setAttribute("checked", gViewWrapper.showUnthreaded); + event.target + .querySelector(`[value="group"]`) + .setAttribute("checked", gViewWrapper.showGroupedBySort); + }, + + /** + * Change the display view of the message list pane. + * + * @param {DOMEvent} event - The click event. + */ + changePaneView(event) { + const view = event.target.value; + Services.xulStore.setValue(XULSTORE_URL, "threadPane", "view", view); + threadPane.updateThreadView(view); + }, + + /** + * Update the quick filter button based on the quick filter bar state. + */ + onQuickFilterToggle() { + const active = quickFilterBar.filterer.visible; + this.filterButton.setAttribute("aria-pressed", active.toString()); + }, + + /** + * Toggle the visibility of the message list pane header. + */ + toggleThreadPaneHeader() { + this.isHidden = !this.isHidden; + this.bar.hidden = this.isHidden; + + Services.xulStore.setValue( + XULSTORE_URL, + "threadPaneHeader", + "hidden", + this.isHidden + ); + // Trigger a data refresh if we're revealing the header. + if (!this.isHidden) { + this.onFolderSelected(); + } + }, + + /** + * Update the header data when the selected folder changes. + */ + onFolderSelected() { + // Bail out if the pane is hidden as we don't need to update anything. + if (this.isHidden) { + return; + } + + // Hide any potential stale data if we don't have a folder. + if (!gFolder && !gDBView && !gViewWrapper?.isSynthetic) { + this.folderName.hidden = true; + this.folderCount.hidden = true; + this.selectedCount.hidden = true; + return; + } + + const folderName = gFolder?.abbreviatedName ?? document.title; + this.folderName.textContent = folderName; + this.folderName.title = folderName; + document.l10n.setAttributes( + this.folderCount, + "thread-pane-folder-message-count", + { count: gFolder?.getTotalMessages(false) || gDBView?.rowCount || 0 } + ); + + this.folderName.hidden = false; + this.folderCount.hidden = false; + }, + + /** + * Update the total message count in the header if the value changed for the + * currently selected folder. + * + * @param {nsIMsgFolder} folder - The folder updating the count. + * @param {integer} newValue + */ + updateFolderCount(folder, newValue) { + if (!gFolder || !folder || this.isHidden || folder.URI != gFolder.URI) { + return; + } + + document.l10n.setAttributes( + this.folderCount, + "thread-pane-folder-message-count", + { count: newValue } + ); + }, + + /** + * Count the number of currently selected messages and update the selected + * message count indicator. + */ + updateSelectedCount() { + // Bail out if the pane is hidden as we don't need to update anything. + if (this.isHidden) { + return; + } + + let count = gDBView?.getSelectedMsgHdrs().length; + if (count < 2) { + this.selectedCount.hidden = true; + return; + } + document.l10n.setAttributes( + this.selectedCount, + "thread-pane-folder-selected-count", + { count } + ); + this.selectedCount.hidden = false; + }, +}; + +var threadPane = { + /** + * Non-persistent storage of the last-selected items in each folder. + * Keys in this map are folder URIs. Values are objects containing an array + * of the selected messages and the current message. Messages are referenced + * by message key to account for possible changes in the folder. + * + * @type {Map<string, object>} + */ + _savedSelections: new Map(), + + /** + * This is set to true in folderPane._onSelect before opening the folder, if + * new messages have been received and the corresponding preference is set. + * + * @type {boolean} + */ + scrollToNewMessage: false, + + /** + * Set to true when a scrolling event (presumably by the user) is detected + * while messages are still loading in a newly created view. + * + * @type {boolean} + */ + scrollDetected: false, + + /** + * The first detected scrolling event is triggered by creating the view + * itself. This property is then set to false. + * + * @type {boolean} + */ + isFirstScroll: true, + + columns: getDefaultColumns(gFolder), + + cardColumns: getDefaultColumnsForCardsView(gFolder), + + async init() { + quickFilterBar.init(); + + this.setUpTagStyles(); + Services.prefs.addObserver("mailnews.tags.", this); + + Services.obs.addObserver(this, "addrbook-displayname-changed"); + + // Ensure TreeView and its classes are properly defined. + await customElements.whenDefined("tree-view-table-row"); + + threadTree = document.getElementById("threadTree"); + this.treeTable = threadTree.table; + this.treeTable.editable = true; + this.treeTable.setPopupMenuTemplates([ + "threadPaneApplyColumnMenu", + "threadPaneApplyViewMenu", + ]); + threadTree.setAttribute( + "rows", + !Services.xulStore.hasValue(XULSTORE_URL, "threadPane", "view") || + Services.xulStore.getValue(XULSTORE_URL, "threadPane", "view") == + "cards" + ? "thread-card" + : "thread-row" + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "selectDelay", + "mailnews.threadpane_select_delay", + null, + (name, oldValue, newValue) => (threadTree.dataset.selectDelay = newValue) + ); + threadTree.dataset.selectDelay = this.selectDelay; + + window.addEventListener("uidensitychange", () => { + this.densityChange(); + threadTree.reset(); + }); + this.densityChange(); + + XPCOMUtils.defineLazyGetter(this, "notificationBox", () => { + let container = document.getElementById("threadPaneNotificationBox"); + return new MozElements.NotificationBox(element => + container.append(element) + ); + }); + + this.treeTable.addEventListener("shift-column", event => { + this.onColumnShifted(event.detail); + }); + this.treeTable.addEventListener("reorder-columns", event => { + this.onColumnsReordered(event.detail); + }); + this.treeTable.addEventListener("column-resized", event => { + this.treeTable.setColumnsWidths(XULSTORE_URL, event); + }); + this.treeTable.addEventListener("columns-changed", event => { + this.onColumnsVisibilityChanged(event.detail); + }); + this.treeTable.addEventListener("sort-changed", event => { + this.onSortChanged(event.detail); + }); + this.treeTable.addEventListener("restore-columns", () => { + this.restoreDefaultColumns(); + }); + this.treeTable.addEventListener("toggle-flag", event => { + gDBView.applyCommandToIndices( + event.detail.isFlagged + ? Ci.nsMsgViewCommandType.unflagMessages + : Ci.nsMsgViewCommandType.flagMessages, + [event.detail.index] + ); + }); + this.treeTable.addEventListener("toggle-unread", event => { + gDBView.applyCommandToIndices( + event.detail.isUnread + ? Ci.nsMsgViewCommandType.markMessagesRead + : Ci.nsMsgViewCommandType.markMessagesUnread, + [event.detail.index] + ); + }); + this.treeTable.addEventListener("toggle-spam", event => { + gDBView.applyCommandToIndices( + event.detail.isJunk + ? Ci.nsMsgViewCommandType.unjunk + : Ci.nsMsgViewCommandType.junk, + [event.detail.index] + ); + }); + this.treeTable.addEventListener("thread-changed", () => { + sortController.toggleThreaded(); + }); + this.treeTable.addEventListener("request-delete", event => { + gDBView.applyCommandToIndices(Ci.nsMsgViewCommandType.deleteMsg, [ + event.detail.index, + ]); + }); + + this.updateClassList(); + + threadTree.addEventListener("contextmenu", this); + threadTree.addEventListener("dblclick", this); + threadTree.addEventListener("auxclick", this); + threadTree.addEventListener("keypress", this); + threadTree.addEventListener("select", this); + threadTree.table.body.addEventListener("dragstart", this); + threadTree.addEventListener("dragover", this); + threadTree.addEventListener("drop", this); + threadTree.addEventListener("expanded", this); + threadTree.addEventListener("collapsed", this); + threadTree.addEventListener("scroll", this); + }, + + uninit() { + Services.prefs.removeObserver("mailnews.tags.", this); + Services.obs.removeObserver(this, "addrbook-displayname-changed"); + }, + + handleEvent(event) { + const notOnEmptySpace = event.target !== threadTree; + switch (event.type) { + case "contextmenu": + if (notOnEmptySpace) { + this._onContextMenu(event); + } + break; + case "dblclick": + if (notOnEmptySpace) { + this._onDoubleClick(event); + } + break; + case "auxclick": + if (event.button == 1 && notOnEmptySpace) { + this._onMiddleClick(event); + } + break; + case "keypress": + this._onKeyPress(event); + break; + case "select": + this._onSelect(event); + break; + case "dragstart": + this._onDragStart(event); + break; + case "dragover": + this._onDragOver(event); + break; + case "drop": + this._onDrop(event); + break; + case "expanded": + case "collapsed": + if (event.detail == threadTree.selectedIndex) { + // The selected index hasn't changed, but a collapsed row represents + // multiple messages, so for our purposes the selection has changed. + threadTree.dispatchEvent(new CustomEvent("select")); + } + break; + case "scroll": + if (this.isFirstScroll) { + this.isFirstScroll = false; + break; + } + this.scrollDetected = true; + break; + } + }, + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + this.setUpTagStyles(); + } else if (topic == "addrbook-displayname-changed") { + // This runs the when mail.displayname.version preference observer is + // notified/the mail.displayname.version number has been updated. + threadTree.invalidate(); + } + }, + + /** + * Update the CSS classes of the thread tree based on the current folder. + */ + updateClassList() { + if (!gFolder) { + threadTree.classList.remove("is-outgoing"); + return; + } + + threadTree.classList.toggle("is-outgoing", isOutgoing(gFolder)); + }, + + /** + * Temporarily select a different index from the actual selection, without + * visually changing or losing the current selection. + * + * @param {integer} index - The index of the clicked row. + */ + suppressSelect(index) { + this.saveSelection(); + threadTree._selection.selectEventsSuppressed = true; + threadTree._selection.select(index); + }, + + /** + * Clear the selection suppression and restore the previous selection. + */ + releaseSelection() { + threadTree._selection.selectEventsSuppressed = true; + this.restoreSelection({ notify: false }); + threadTree._selection.selectEventsSuppressed = false; + }, + + _onDoubleClick(event) { + if (event.target.closest("button") || event.target.closest("menupopup")) { + // Prevent item activation if double click happens on a button inside the + // row. E.g.: Thread toggle, spam, favorite, etc. or in a menupopup like + // the column picker. + return; + } + this._onItemActivate(event); + }, + + _onKeyPress(event) { + if (event.target.closest("thead")) { + // Bail out if the keypress happens in the table header. + return; + } + + if (event.key == "Enter") { + this._onItemActivate(event); + } + }, + + _onMiddleClick(event) { + const row = + event.target.closest(`tr[is^="thread-"]`) || + threadTree.getRowAtIndex(threadTree.currentIndex); + + const isSelected = gDBView.selection.isSelected(row.index); + if (!isSelected) { + // The middle-clicked row is not selected. Tell the activate item to use + // this instead. + this.suppressSelect(row.index); + } + this._onItemActivate(event); + if (!isSelected) { + this.releaseSelection(); + } + }, + + _onItemActivate(event) { + if ( + threadTree.selectedIndex < 0 || + gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY + ) { + return; + } + + let folder = gFolder || gDBView.hdrForFirstSelectedMessage.folder; + if (folder?.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)) { + commandController.doCommand("cmd_editDraftMsg", event); + } else if (folder?.isSpecialFolder(Ci.nsMsgFolderFlags.Templates, true)) { + commandController.doCommand("cmd_newMsgFromTemplate", event); + } else { + commandController.doCommand("cmd_openMessage", event); + } + }, + + /** + * Handle threadPane select events. + */ + _onSelect(event) { + if (!paneLayout.messagePaneVisible.isCollapsed && gDBView) { + messagePane.clearWebPage(); + switch (gDBView.numSelected) { + case 0: + messagePane.clearMessage(); + messagePane.clearMessages(); + threadPaneHeader.selectedCount.hidden = true; + break; + case 1: + if ( + gDBView.getFlagsAt(threadTree.selectedIndex) & MSG_VIEW_FLAG_DUMMY + ) { + messagePane.clearMessage(); + messagePane.clearMessages(); + threadPaneHeader.selectedCount.hidden = true; + } else { + let uri = gDBView.getURIForViewIndex(threadTree.selectedIndex); + messagePane.displayMessage(uri); + threadPaneHeader.updateSelectedCount(); + } + break; + default: + messagePane.displayMessages(gDBView.getSelectedMsgHdrs()); + threadPaneHeader.updateSelectedCount(); + break; + } + } + + // Update the state of the zoom commands, since the view has changed. + const commandsToUpdate = [ + "cmd_fullZoomReduce", + "cmd_fullZoomEnlarge", + "cmd_fullZoomReset", + "cmd_fullZoomToggle", + ]; + for (const command of commandsToUpdate) { + top.goUpdateCommand(command); + } + }, + + /** + * Handle threadPane drag events. + */ + _onDragStart(event) { + let row = event.target.closest(`tr[is^="thread-"]`); + if (!row) { + event.preventDefault(); + return; + } + + let messageURIs = gDBView.getURIsForSelection(); + if (!threadTree.selectedIndices.includes(row.index)) { + messageURIs = [gDBView.getURIForViewIndex(row.index)]; + } + + let noSubjectString = messengerBundle.GetStringFromName( + "defaultSaveMessageAsFileName" + ); + if (noSubjectString.endsWith(".eml")) { + noSubjectString = noSubjectString.slice(0, -4); + } + let longSubjectTruncator = messengerBundle.GetStringFromName( + "longMsgSubjectTruncator" + ); + // Clip the subject string to 124 chars to avoid problems on Windows, + // see NS_MAX_FILEDESCRIPTOR in m-c/widget/windows/nsDataObj.cpp . + const maxUncutNameLength = 124; + let maxCutNameLength = maxUncutNameLength - longSubjectTruncator.length; + let messages = new Map(); + + for (let [index, uri] of Object.entries(messageURIs)) { + let msgService = MailServices.messageServiceFromURI(uri); + let msgHdr = msgService.messageURIToMsgHdr(uri); + let subject = msgHdr.mime2DecodedSubject || ""; + if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) { + subject = "Re: " + subject; + } + + let uniqueFileName; + // If there is no subject, use a default name. + // If subject needs to be truncated, add a truncation character to indicate it. + if (!subject) { + uniqueFileName = noSubjectString; + } else { + uniqueFileName = + subject.length <= maxUncutNameLength + ? subject + : subject.substr(0, maxCutNameLength) + longSubjectTruncator; + } + let msgFileName = validateFileName(uniqueFileName); + let msgFileNameLowerCase = msgFileName.toLocaleLowerCase(); + + while (true) { + if (!messages.has(msgFileNameLowerCase)) { + messages.set(msgFileNameLowerCase, 1); + break; + } else { + let number = messages.get(msgFileNameLowerCase); + messages.set(msgFileNameLowerCase, number + 1); + let postfix = "-" + number; + msgFileName = msgFileName + postfix; + msgFileNameLowerCase = msgFileNameLowerCase + postfix; + } + } + + msgFileName = msgFileName + ".eml"; + + // This type should be unnecessary, but getFlavorData can't get at + // text/x-moz-message for some reason. + event.dataTransfer.mozSetDataAt("text/plain", uri, index); + event.dataTransfer.mozSetDataAt("text/x-moz-message", uri, index); + event.dataTransfer.mozSetDataAt( + "text/x-moz-url", + msgService.getUrlForUri(uri).spec, + index + ); + // When dragging messages to the filesystem: + // - Windows fetches this value and writes it to a file. + // - Linux does the same if there are multiple files, but for a single + // file it uses the flavor data provider below. + // - MacOS always uses the flavor data provider. + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-url", + msgService.getUrlForUri(uri).spec, + index + ); + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise", + this._flavorDataProvider, + index + ); + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-dest-filename", + msgFileName.replace(/(.{74}).*(.{10})$/u, "$1...$2"), + index + ); + } + + event.dataTransfer.effectAllowed = "copyMove"; + let bcr = row.getBoundingClientRect(); + event.dataTransfer.setDragImage( + row, + event.clientX - bcr.x, + event.clientY - bcr.y + ); + }, + + /** + * Handle threadPane dragover events. + */ + _onDragOver(event) { + if (event.target.closest("thead")) { + return; // Only allow dropping in the body. + } + // Must prevent default. Otherwise dropEffect gets cleared. + event.preventDefault(); + event.dataTransfer.dropEffect = "none"; + let types = Array.from(event.dataTransfer.mozTypesAt(0)); + let targetFolder = gFolder; + if (types.includes("application/x-moz-file")) { + if (targetFolder.isServer || !targetFolder.canFileMessages) { + return; + } + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let extFile = event.dataTransfer + .mozGetDataAt("application/x-moz-file", i) + .QueryInterface(Ci.nsIFile); + if (!extFile.isFile() || !/\.eml$/i.test(extFile.leafName)) { + return; + } + } + event.dataTransfer.dropEffect = "copy"; + } + }, + + /** + * Handle threadPane drop events. + */ + _onDrop(event) { + if (event.target.closest("thead")) { + return; // Only allow dropping in the body. + } + event.preventDefault(); + for (let i = 0; i < event.dataTransfer.mozItemCount; i++) { + let extFile = event.dataTransfer + .mozGetDataAt("application/x-moz-file", i) + .QueryInterface(Ci.nsIFile); + if (extFile.isFile() && /\.eml$/i.test(extFile.leafName)) { + MailServices.copy.copyFileMessage( + extFile, + gFolder, + null, + false, + 1, + "", + null, + top.msgWindow + ); + } + } + }, + + _onContextMenu(event, retry = false) { + let row = + event.target.closest(`tr[is^="thread-"]`) || + threadTree.getRowAtIndex(threadTree.currentIndex); + const isMouse = event.button == 2; + if (!isMouse) { + if (threadTree.selectedIndex < 0) { + return; + } + // Scroll selected row we're triggering the context menu for into view. + threadTree.scrollToIndex(threadTree.currentIndex, true); + if (!row) { + row = threadTree.getRowAtIndex(threadTree.currentIndex); + // Try again once in the next frame. + if (!row && !retry) { + window.requestAnimationFrame(() => this._onContextMenu(event, true)); + return; + } + } + } + if (!row || gDBView.getFlagsAt(row.index) & MSG_VIEW_FLAG_DUMMY) { + return; + } + + mailContextMenu.setAsThreadPaneContextMenu(); + let popup = document.getElementById("mailContext"); + + if (isMouse) { + if (!gDBView.selection.isSelected(row.index)) { + // The right-clicked-on row is not selected. Tell the context menu to + // use it instead. This override lasts until the context menu fires + // a "popuphidden" event. + mailContextMenu.setOverrideSelection(row.index); + row.classList.add("context-menu-target"); + } + popup.openPopupAtScreen(event.screenX, event.screenY, true); + } else { + popup.openPopup(row, "after_end", 0, 0, true); + } + + event.preventDefault(); + }, + + _flavorDataProvider: { + QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]), + + getFlavorData(transferable, flavor, data) { + if (flavor !== "application/x-moz-file-promise") { + return; + } + + let fileName = {}; + transferable.getTransferData( + "application/x-moz-file-promise-dest-filename", + fileName + ); + fileName.value.QueryInterface(Ci.nsISupportsString); + + let destDir = {}; + transferable.getTransferData( + "application/x-moz-file-promise-dir", + destDir + ); + destDir.value.QueryInterface(Ci.nsIFile); + + let file = destDir.value.clone(); + file.append(fileName.value.data); + + let messageURI = {}; + transferable.getTransferData("text/plain", messageURI); + messageURI.value.QueryInterface(Ci.nsISupportsString); + + top.messenger.saveAs(messageURI.value.data, true, null, file.path, true); + }, + }, + + _jsTree: { + QueryInterface: ChromeUtils.generateQI(["nsIMsgJSTree"]), + _inBatch: false, + beginUpdateBatch() { + this._inBatch = true; + }, + endUpdateBatch() { + this._inBatch = false; + }, + ensureRowIsVisible(index) { + if (!this._inBatch) { + threadTree.scrollToIndex(index, true); + } + }, + invalidate() { + if (!this._inBatch) { + threadTree.reset(); + if (threadPane) { + threadPane.isFirstScroll = true; + threadPane.scrollDetected = false; + threadPane.scrollToLatestRowIfNoSelection(); + } + } + }, + invalidateRange(startIndex, endIndex) { + if (!this._inBatch) { + threadTree.invalidateRange(startIndex, endIndex); + } + }, + rowCountChanged(index, count) { + if (!this._inBatch) { + threadTree.rowCountChanged(index, count); + } + }, + get currentIndex() { + return threadTree.currentIndex; + }, + set currentIndex(index) { + threadTree.currentIndex = index; + }, + }, + + /** + * Tell the tree and the view about each other. `nsITreeView.setTree` can't + * be used because it needs a XULTreeElement and threadTree isn't one. + * (Strictly speaking the shim passed here isn't a tree either but it does + * implement the required methods.) + * + * @param {nsIMsgDBView} view + */ + setTreeView(view) { + threadTree.view = gDBView = view; + // Clear the batch flag. Don't call `endUpdateBatch` as that may change in + // future leading to unintended consequences. + this._jsTree._inBatch = false; + view.setJSTree(this._jsTree); + }, + + setUpTagStyles() { + if (this.tagStyle) { + this.tagStyle.remove(); + } + this.tagStyle = document.head.appendChild(document.createElement("style")); + + for (let { color, key } of MailServices.tags.getAllTags()) { + if (!color) { + continue; + } + let selector = MailServices.tags.getSelectorForKey(key); + let contrast = TagUtils.isColorContrastEnough(color) ? "black" : "white"; + this.tagStyle.sheet.insertRule( + `tr[data-properties~="${selector}"] { + --tag-color: ${color}; + --tag-contrast-color: ${contrast}; + }` + ); + } + }, + + /** + * Make the list rows density aware. + */ + densityChange() { + // The class ThreadRow can't be referenced because it's declared in a + // different scope. But we can get it from customElements. + let rowClass = customElements.get("thread-row"); + let cardClass = customElements.get("thread-card"); + switch (UIDensity.prefValue) { + case UIDensity.MODE_COMPACT: + rowClass.ROW_HEIGHT = 18; + cardClass.ROW_HEIGHT = 40; + break; + case UIDensity.MODE_TOUCH: + rowClass.ROW_HEIGHT = 32; + cardClass.ROW_HEIGHT = 52; + break; + default: + rowClass.ROW_HEIGHT = 26; + cardClass.ROW_HEIGHT = 46; + break; + } + }, + + /** + * Store the current thread tree selection. + */ + saveSelection() { + // Identifying messages by key doesn't reliably work on on cross-folder views since + // the msgKey may not be unique. + if (gFolder && gDBView && !gViewWrapper?.isMultiFolder) { + this._savedSelections.set(gFolder.URI, { + currentKey: gDBView.getKeyAt(threadTree.currentIndex), + // In views which are "grouped by sort", getting the key for collapsed dummy rows + // returns the key of the first group member, so we would restore something that + // wasn't selected. So filter them out. + selectedKeys: threadTree.selectedIndices + .filter(i => !gViewWrapper.isGroupedByHeaderAtIndex(i)) + .map(gDBView.getKeyAt), + }); + } + }, + + /** + * Forget any saved selection of the given folder. This is useful if you're + * going to set the selection after switching to the folder. + * + * @param {string} folderURI + */ + forgetSelection(folderURI) { + this._savedSelections.delete(folderURI); + }, + + /** + * Restore the previously saved thread tree selection. + * + * @param {boolean} [discard=true] - If false, the selection data is kept for + * another call of this function, unless all selections could already be + * restored in this run. + * @param {boolean} [notify=true] - Whether a change in "select" event + * should be fired. + * @param {boolean} [expand=true] - Try to expand threads containing selected + * messages. + */ + restoreSelection({ discard = true, notify = true, expand = true } = {}) { + if (!this._savedSelections.has(gFolder?.URI) || !threadTree.view) { + return; + } + + let { currentKey, selectedKeys } = this._savedSelections.get(gFolder.URI); + let currentIndex = nsMsgViewIndex_None; + let indices = new Set(); + for (let key of selectedKeys) { + let index = gDBView.findIndexFromKey(key, expand); + // While the first message in a collapsed group returns the index of the + // dummy row, other messages return none. To be consistent, we don't + // select the dummy row in any case. + if ( + index != nsMsgViewIndex_None && + !gViewWrapper.isGroupedByHeaderAtIndex(index) + ) { + indices.add(index); + if (key == currentKey) { + currentIndex = index; + } + continue; + } + // Since it does not seem to be possible to reliably find the dummy row + // for a message in a group, we continue. + if (gViewWrapper.showGroupedBySort) { + continue; + } + // The message for this key can't be found. Perhaps the thread it's in + // has been collapsed? Select the root message in that case. + try { + const folder = + gViewWrapper.isVirtual && gViewWrapper.isSingleFolder + ? gViewWrapper._underlyingFolders[0] + : gFolder; + const msgHdr = folder.GetMessageHeader(key); + const thread = gDBView.getThreadContainingMsgHdr(msgHdr); + const rootMsgHdr = thread.getRootHdr(); + index = gDBView.findIndexOfMsgHdr(rootMsgHdr, false); + if (index != nsMsgViewIndex_None) { + indices.add(index); + if (key == currentKey) { + currentIndex = index; + } + } + } catch (ex) { + console.error(ex); + } + } + threadTree.setSelectedIndices(indices.values(), !notify); + + if (currentIndex != nsMsgViewIndex_None) { + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. + threadTree.currentIndex = currentIndex; + threadTree.style.scrollBehavior = null; + } + + // If all selections have already been restored, discard them as well. + if (discard || gDBView.selection.count == selectedKeys.length) { + this._savedSelections.delete(gFolder.URI); + } + }, + + /** + * Scroll to the most relevant end of the tree, but only if no rows are + * selected. + */ + scrollToLatestRowIfNoSelection() { + if (!gDBView || gDBView.selection.count > 0 || gDBView.rowCount <= 0) { + return; + } + if ( + gViewWrapper.sortImpliesTemporalOrdering && + gViewWrapper.isSortedAscending + ) { + threadTree.scrollToIndex(gDBView.rowCount - 1, true); + } else { + threadTree.scrollToIndex(0, true); + } + }, + + /** + * Re-collapse threads expanded by nsMsgQuickSearchDBView if necessary. + */ + ensureThreadStateForQuickSearchView() { + // nsMsgQuickSearchDBView::SortThreads leaves all threads expanded in any + // case. + if ( + gViewWrapper.isSingleFolder && + gViewWrapper.search.hasSearchTerms && + gViewWrapper.showThreaded && + !gViewWrapper._threadExpandAll + ) { + window.threadPane.saveSelection(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + window.threadPane.restoreSelection(); + } + }, + + /** + * Restore the collapsed or expanded state of threads. + */ + restoreThreadState() { + if ( + gViewWrapper._threadExpandAll && + !(gViewWrapper.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll) + ) { + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll); + } + if ( + !gViewWrapper._threadExpandAll && + gViewWrapper.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ) { + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + } + }, + + /** + * Restore the chevron icon indicating the current sort order. + */ + restoreSortIndicator() { + if (!gDBView) { + return; + } + this.updateSortIndicator( + sortController.convertSortTypeToColumnID(gViewWrapper.primarySortType) + ); + }, + + /** + * Update the columns object and force the refresh of the thread pane to apply + * the updated state. This is usually called when changing folders. + */ + restoreColumns() { + this.restoreColumnsState(); + this.updateColumns(); + }, + + /** + * Restore the visibility and order of the columns for the current folder. + */ + restoreColumnsState() { + // Always fetch a fresh array of columns for the cards view even if we don't + // have a folder defined. + this.cardColumns = getDefaultColumnsForCardsView(gFolder); + this.updateClassList(); + + // Avoid doing anything if no folder has been loaded yet. + if (!gFolder) { + return; + } + + // A missing folder database will throw an error so we need to handle that. + let msgDatabase; + try { + msgDatabase = gFolder.msgDatabase; + } catch { + return; + } + + const stringState = + msgDatabase.dBFolderInfo.getCharProperty("columnStates"); + if (!stringState) { + // If we don't have a previously saved state, make sure to enforce the + // default columns for the currently visible folder, otherwise the table + // layout will maintain whatever state is currently set from the previous + // folder, which it doesn't reflect reality. + this.columns = getDefaultColumns(gFolder); + return; + } + + this.applyPersistedColumnsState(JSON.parse(stringState)); + }, + + /** + * Update the current columns to match a previously saved state. + * + * @param {JSON} columnStates - The parsed JSON of a previously saved state. + */ + applyPersistedColumnsState(columnStates) { + this.columns.forEach(c => { + c.hidden = !columnStates[c.id]?.visible; + c.ordinal = columnStates[c.id]?.ordinal ?? 0; + }); + // Sort columns by ordinal. + this.columns.sort(function (a, b) { + return a.ordinal - b.ordinal; + }); + }, + + /** + * Force an update of the thread tree to reflect the columns change. + * + * @param {boolean} isSimple - If the columns structure only requires a simple + * update and not a full reset of the entire table header. + */ + updateColumns(isSimple = false) { + if (!this.rowTemplate) { + this.rowTemplate = document.getElementById("threadPaneRowTemplate"); + } + + // Update the row template to match the column properties. + for (let column of this.columns) { + let cell = this.rowTemplate.content.querySelector( + `.${column.id.toLowerCase()}-column` + ); + cell.hidden = column.hidden; + this.rowTemplate.content.appendChild(cell); + } + + if (isSimple) { + this.treeTable.updateColumns(this.columns); + } else { + // The order of the columns have changed, which warrants a rebuild of the + // full table header. + this.treeTable.setColumns(this.columns); + } + this.treeTable.restoreColumnsWidths(XULSTORE_URL); + }, + + /** + * Restore the default columns visibility and order and save the change. + */ + restoreDefaultColumns() { + this.columns = getDefaultColumns(gFolder, gViewWrapper?.isSynthetic); + this.cardColumns = getDefaultColumnsForCardsView(gFolder); + this.updateClassList(); + this.updateColumns(); + threadTree.reset(); + this.persistColumnStates(); + }, + + /** + * Shift the ordinal of a column by one based on the visible columns. + * + * @param {object} data - The detail object of the bubbled event. + */ + onColumnShifted(data) { + const column = data.column; + const forward = data.forward; + + const columnToShift = this.columns.find(c => c.id == column); + const currentPosition = this.columns.indexOf(columnToShift); + + let delta = forward ? 1 : -1; + let newPosition = currentPosition + delta; + // Account for hidden columns to find the correct new position. + while (this.columns.at(newPosition).hidden) { + newPosition += delta; + } + + // Get the column in the current new position before shuffling the array. + const destinationTH = document.getElementById( + this.columns.at(newPosition).id + ); + + this.columns.splice( + newPosition, + 0, + this.columns.splice(currentPosition, 1)[0] + ); + + // Update the ordinal of the columns to reflect the new positions. + this.columns.forEach((column, index) => { + column.ordinal = index; + }); + + this.persistColumnStates(); + this.updateColumns(true); + threadTree.reset(); + + // Swap the DOM elements. + const originalTH = document.getElementById(column); + if (forward) { + destinationTH.after(originalTH); + } else { + destinationTH.before(originalTH); + } + // Restore the focus so we can continue shifting if needed. + document.getElementById(`${column}Button`).focus(); + }, + + onColumnsReordered(data) { + this.columns = data.columns; + + this.persistColumnStates(); + this.updateColumns(true); + threadTree.reset(); + }, + + /** + * Update the list of visible columns based on the users' selection. + * + * @param {object} data - The detail object of the bubbled event. + */ + onColumnsVisibilityChanged(data) { + let column = data.value; + let checked = data.target.hasAttribute("checked"); + + let changedColumn = this.columns.find(c => c.id == column); + changedColumn.hidden = !checked; + + this.persistColumnStates(); + this.updateColumns(true); + threadTree.reset(); + }, + + /** + * Save the current visibility of the columns in the folder database. + */ + persistColumnStates() { + let newState = {}; + for (const column of this.columns) { + newState[column.id] = { + visible: !column.hidden, + ordinal: column.ordinal, + }; + } + + if (gViewWrapper.isSynthetic) { + let syntheticView = gViewWrapper._syntheticView; + if ("setPersistedSetting" in syntheticView) { + syntheticView.setPersistedSetting("columns", newState); + } + return; + } + + if (!gFolder) { + return; + } + + // A missing folder database will throw an error so we need to handle that. + let msgDatabase; + try { + msgDatabase = gFolder.msgDatabase; + } catch { + return; + } + + msgDatabase.dBFolderInfo.setCharProperty( + "columnStates", + JSON.stringify(newState) + ); + msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit); + }, + + /** + * Trigger a sort change when the user clicks on the table header. + * + * @param {object} data - The detail of the custom event. + */ + onSortChanged(data) { + const sortColumn = sortController.convertSortTypeToColumnID( + gViewWrapper.primarySortType + ); + const column = data.column; + + // A click happened on the column that is already used to sort the list. + if (sortColumn == column) { + if (gViewWrapper.isSortedAscending) { + sortController.sortDescending(); + } else { + sortController.sortAscending(); + } + this.updateSortIndicator(column); + return; + } + + const sortName = this.columns.find(c => c.id == data.column).sortKey; + sortController.sortThreadPane(sortName); + this.updateSortIndicator(column); + }, + + /** + * Update the classes on the table header to reflect the sorting order. + * + * @param {string} column - The ID of column affecting the sorting order. + */ + updateSortIndicator(column) { + this.treeTable + .querySelector(".sorting") + ?.classList.remove("sorting", "ascending", "descending"); + this.treeTable + .querySelector(`#${column} button`) + ?.classList.add( + "sorting", + gViewWrapper.isSortedAscending ? "ascending" : "descending" + ); + }, + + /** + * Prompt the user to confirm applying the current columns state to the chosen + * folder and its children. + * + * @param {nsIMsgFolder} folder - The chosen message folder. + * @param {boolean} [useChildren=false] - If the requested action should be + * propagated to the child folders. + */ + async confirmApplyColumns(folder, useChildren = false) { + const msgFluentID = useChildren + ? "apply-current-columns-to-folder-with-children-message" + : "apply-current-columns-to-folder-message"; + let [title, message] = await document.l10n.formatValues([ + "apply-changes-to-folder-title", + { id: msgFluentID, args: { name: folder.name } }, + ]); + if (Services.prompt.confirm(null, title, message)) { + this._applyColumns(folder, useChildren); + } + }, + + /** + * Apply the current columns state to the chosen folder and its children, + * if specified. + * + * @param {nsIMsgFolder} destFolder - The chosen folder. + * @param {boolean} useChildren - True if the changes should affect the child + * folders of the chosen folder. + */ + _applyColumns(destFolder, useChildren) { + // Avoid doing anything if no folder has been loaded yet. + if (!gFolder || !destFolder) { + return; + } + + // Get the current state from the columns array, not the saved state in the + // database in order to make sure we're getting the currently visible state. + let columnState = {}; + for (const column of this.columns) { + columnState[column.id] = { + visible: !column.hidden, + ordinal: column.ordinal, + }; + } + + // Swaps "From" and "Recipient" if only one is shown. This is useful for + // copying an incoming folder's columns to and from an outgoing folder. + let columStateString = JSON.stringify(columnState); + let swappedColumnStateString; + if (columnState.senderCol.visible != columnState.recipientCol.visible) { + const backedSenderColumn = columnState.senderCol; + columnState.senderCol = columnState.recipientCol; + columnState.recipientCol = backedSenderColumn; + swappedColumnStateString = JSON.stringify(columnState); + } else { + swappedColumnStateString = columStateString; + } + + const currentFolderIsOutgoing = isOutgoing(gFolder); + + /** + * Update the columnStates property of the folder database and forget the + * reference to prevent memory bloat. + * + * @param {nsIMsgFolder} folder - The message folder. + */ + const commitColumnsState = folder => { + if (folder.isServer) { + return; + } + // Check if the destination folder we're trying to update matches the same + // special state of the folder we're getting the column state from. + const colStateString = + isOutgoing(folder) == currentFolderIsOutgoing + ? columStateString + : swappedColumnStateString; + + folder.msgDatabase.dBFolderInfo.setCharProperty( + "columnStates", + colStateString + ); + folder.msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit); + // Force the reference to be forgotten. + folder.msgDatabase = null; + }; + + if (!useChildren) { + commitColumnsState(destFolder); + return; + } + + // Loop through all the child folders and apply the same column state. + MailUtils.takeActionOnFolderAndDescendents( + destFolder, + commitColumnsState + ).then(() => { + Services.obs.notifyObservers( + gViewWrapper.displayedFolder, + "msg-folder-columns-propagated" + ); + }); + }, + + /** + * Prompt the user to confirm applying the current view sate to the chosen + * folder and its children. + * + * @param {nsIMsgFolder} folder - The chosen message folder. + * @param {boolean} [useChildren=false] - If the requested action should be + * propagated to the child folders. + */ + async confirmApplyView(folder, useChildren = false) { + const msgFluentID = useChildren + ? "apply-current-view-to-folder-with-children-message" + : "apply-current-view-to-folder-message"; + let [title, message] = await document.l10n.formatValues([ + { id: "apply-changes-to-folder-title" }, + { id: msgFluentID, args: { name: folder.name } }, + ]); + if (Services.prompt.confirm(null, title, message)) { + this._applyView(folder, useChildren); + } + }, + + /** + * Apply the current view flags, sorting key, and sorting order to another + * folder and its children, if specified. + * + * @param {nsIMsgFolder} destFolder - The chosen folder. + * @param {boolean} useChildren - True if the changes should affect the child + * folders of the chosen folder. + */ + _applyView(destFolder, useChildren) { + const viewFlags = gViewWrapper.dbView.viewFlags; + const sortType = gViewWrapper.dbView.sortType; + const sortOrder = gViewWrapper.dbView.sortOrder; + + /** + * Update the view state flags of the folder database and forget the + * reference to prevent memory bloat. + * + * @param {nsIMsgFolder} folder - The message folder. + */ + const commitViewState = folder => { + if (folder.isServer) { + return; + } + folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags; + folder.msgDatabase.dBFolderInfo.sortType = sortType; + folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder; + // Null out to avoid memory bloat. + folder.msgDatabase = null; + }; + + if (!useChildren) { + commitViewState(destFolder); + return; + } + + MailUtils.takeActionOnFolderAndDescendents( + destFolder, + commitViewState + ).then(() => { + Services.obs.notifyObservers( + gViewWrapper.displayedFolder, + "msg-folder-views-propagated" + ); + }); + }, + + /** + * Hide any notifications about ignored threads. + */ + hideIgnoredMessageNotification() { + this.notificationBox.removeTransientNotifications(); + }, + + /** + * Show a notification in the thread pane footer, allowing the user to learn + * more about the ignore thread feature, and also allowing undo ignore thread. + * + * @param {nsIMsgDBHdr[]} messages - The messages being ignored. + * @param {boolean} subthreadOnly - If true, ignoring only `messages` and + * their subthreads, otherwise ignoring the whole thread. + */ + showIgnoredMessageNotification(messages, subthreadOnly) { + let threadIds = new Set(); + messages.forEach(function (msg) { + if (!threadIds.has(msg.threadId)) { + threadIds.add(msg.threadId); + } + }); + + let buttons = [ + { + label: messengerBundle.GetStringFromName("learnMoreAboutIgnoreThread"), + accessKey: messengerBundle.GetStringFromName( + "learnMoreAboutIgnoreThreadAccessKey" + ), + popup: null, + callback(aNotificationBar, aButton) { + let url = Services.prefs.getCharPref( + "mail.ignore_thread.learn_more_url" + ); + top.openContentTab(url); + return true; // Keep notification open. + }, + }, + { + label: messengerBundle.GetStringFromName( + !subthreadOnly ? "undoIgnoreThread" : "undoIgnoreSubthread" + ), + accessKey: messengerBundle.GetStringFromName( + !subthreadOnly + ? "undoIgnoreThreadAccessKey" + : "undoIgnoreSubthreadAccessKey" + ), + isDefault: true, + popup: null, + callback(aNotificationBar, aButton) { + messages.forEach(function (msg) { + let msgDb = msg.folder.msgDatabase; + if (subthreadOnly) { + msgDb.markHeaderKilled(msg, false, null); + } else if (threadIds.has(msg.threadId)) { + let thread = msgDb.getThreadContainingMsgHdr(msg); + msgDb.markThreadIgnored( + thread, + thread.getChildKeyAt(0), + false, + null + ); + threadIds.delete(msg.threadId); + } + }); + // Invalidation should be unnecessary but the back end doesn't + // notify us properly and resists attempts to fix this. + threadTree.reset(); + threadTree.table.body.focus(); + return false; // Close notification. + }, + }, + ]; + + if (threadIds.size == 1) { + let ignoredThreadText = messengerBundle.GetStringFromName( + !subthreadOnly ? "ignoredThreadFeedback" : "ignoredSubthreadFeedback" + ); + let subj = messages[0].mime2DecodedSubject || ""; + if (subj.length > 45) { + subj = subj.substring(0, 45) + "…"; + } + let text = ignoredThreadText.replace("#1", subj); + + this.notificationBox.appendNotification( + "ignoreThreadInfo", + { + label: text, + priority: this.notificationBox.PRIORITY_INFO_MEDIUM, + }, + buttons + ); + } else { + let ignoredThreadText = messengerBundle.GetStringFromName( + !subthreadOnly ? "ignoredThreadsFeedback" : "ignoredSubthreadsFeedback" + ); + + const { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" + ); + let text = PluralForm.get(threadIds.size, ignoredThreadText).replace( + "#1", + threadIds.size + ); + this.notificationBox.appendNotification( + "ignoreThreadsInfo", + { + label: text, + priority: this.notificationBox.PRIORITY_INFO_MEDIUM, + }, + buttons + ); + } + }, + + /** + * Update the display view of the message list. Current supported options are + * table and cards. + * + * @param {string} view - The view type. + */ + updateThreadView(view) { + switch (view) { + case "table": + document.body.classList.add("layout-table"); + threadTree?.setAttribute("rows", "thread-row"); + break; + case "cards": + default: + document.body.classList.remove("layout-table"); + threadTree?.setAttribute("rows", "thread-card"); + break; + } + }, + + /** + * Update the ARIA Role of the tree view table body to properly communicate + * to assistive techonology the type of list we're rendering and toggles the + * threaded class on the tree table header. + * + * @param {boolean} isListbox - If the list should have a listbox role. + */ + updateListRole(isListbox) { + threadTree.table.body.setAttribute("role", isListbox ? "listbox" : "tree"); + if (isListbox) { + threadTree.table.header.classList.remove("threaded"); + } else { + threadTree.table.header.classList.add("threaded"); + } + }, +}; + +var messagePane = { + async init() { + webBrowser = document.getElementById("webBrowser"); + // Attach the progress listener for the webBrowser. For the messageBrowser this + // happens in the "aboutMessageLoaded" event from aboutMessage.js. + top.contentProgress.addProgressListenerToBrowser(webBrowser); + + messageBrowser = document.getElementById("messageBrowser"); + messageBrowser.docShell.allowDNSPrefetch = false; + + multiMessageBrowser = document.getElementById("multiMessageBrowser"); + multiMessageBrowser.docShell.allowDNSPrefetch = false; + + if (messageBrowser.contentDocument.readyState != "complete") { + await new Promise(resolve => { + messageBrowser.addEventListener("load", () => resolve(), { + capture: true, + once: true, + }); + }); + } + + if (multiMessageBrowser.contentDocument.readyState != "complete") { + await new Promise(resolve => { + multiMessageBrowser.addEventListener("load", () => resolve(), { + capture: true, + once: true, + }); + }); + } + }, + + /** + * Ensure all message pane browsers are blank. + */ + clearAll() { + this.clearWebPage(); + this.clearMessage(); + this.clearMessages(); + }, + + /** + * Ensure the web page browser is blank, unless the start page is shown. + */ + clearWebPage() { + if (!this._keepStartPageOpen) { + webBrowser.hidden = true; + MailE10SUtils.loadAboutBlank(webBrowser); + } + }, + + /** + * Display a web page in the web page browser. If `url` is not given, or is + * "about:blank", the web page browser is cleared and hidden. + * + * @param {string} url - The URL to load. + * @param {object} [params] - Any params to pass to MailE10SUtils.loadURI. + */ + displayWebPage(url, params) { + if (!paneLayout.messagePaneVisible) { + return; + } + if (!url || url == "about:blank") { + this._keepStartPageOpen = false; + this.clearWebPage(); + return; + } + + this.clearMessage(); + this.clearMessages(); + + MailE10SUtils.loadURI(webBrowser, url, params); + webBrowser.hidden = false; + }, + + /** + * Ensure the message browser is not displaying a message. + */ + clearMessage() { + messageBrowser.hidden = true; + messageBrowser.contentWindow.displayMessage(); + }, + + /** + * Display a single message in the message browser. If `messageURI` is not + * given, the message browser is cleared and hidden. + * + * @param {string} messageURI + */ + displayMessage(messageURI) { + if (!paneLayout.messagePaneVisible) { + return; + } + if (!messageURI) { + this.clearMessage(); + return; + } + + this._keepStartPageOpen = false; + messagePane.clearWebPage(); + messagePane.clearMessages(); + + messageBrowser.contentWindow.displayMessage(messageURI, gViewWrapper); + messageBrowser.hidden = false; + }, + + /** + * Ensure the multi-message browser is not displaying messages. + */ + clearMessages() { + multiMessageBrowser.hidden = true; + multiMessageBrowser.contentWindow.gMessageSummary.clear(); + }, + + /** + * Display messages in the multi-message browser. For a single message, use + * `displayMessage` instead. If `messages` is not given, or an empty array, + * the multi-message browser is cleared and hidden. + * + * @param {nsIMsgDBHdr[]} messages + */ + displayMessages(messages = []) { + if (!paneLayout.messagePaneVisible) { + return; + } + if (messages.length == 0) { + this.clearMessages(); + return; + } + + this._keepStartPageOpen = false; + messagePane.clearWebPage(); + messagePane.clearMessage(); + + let getThreadId = function (message) { + return gDBView.getThreadContainingMsgHdr(message).getRootHdr().messageKey; + }; + + let oneThread = true; + let firstThreadId = getThreadId(messages[0]); + for (let i = 1; i < messages.length; i++) { + if (getThreadId(messages[i]) != firstThreadId) { + oneThread = false; + break; + } + } + + multiMessageBrowser.contentWindow.gMessageSummary.summarize( + oneThread ? "thread" : "multipleselection", + messages, + gDBView, + function (messages) { + threadTree.selectedIndices = messages + .map(m => gDBView.findIndexOfMsgHdr(m, true)) + .filter(i => i != nsMsgViewIndex_None); + } + ); + + multiMessageBrowser.hidden = false; + window.dispatchEvent(new CustomEvent("MsgsLoaded", { bubbles: true })); + }, + + /** + * Show the start page in the web page browser. The start page will remain + * shown until a message is displayed. + */ + showStartPage() { + this._keepStartPageOpen = true; + let url = Services.urlFormatter.formatURLPref("mailnews.start_page.url"); + if (/^mailbox:|^imap:|^pop:|^s?news:|^nntp:/i.test(url)) { + console.warn(`Can't use ${url} as mailnews.start_page.url`); + Services.prefs.clearUserPref("mailnews.start_page.url"); + url = Services.urlFormatter.formatURLPref("mailnews.start_page.url"); + } + messagePane.displayWebPage(url); + }, +}; + +function restoreState({ + folderPaneVisible, + messagePaneVisible, + folderURI, + syntheticView, + first = false, + title = null, +} = {}) { + if (folderPaneVisible === undefined) { + folderPaneVisible = folderURI || !syntheticView; + } + paneLayout.folderPaneSplitter.isCollapsed = !folderPaneVisible; + paneLayout.folderPaneSplitter.isDisabled = syntheticView; + + if (messagePaneVisible === undefined) { + messagePaneVisible = + Services.xulStore.getValue( + XULSTORE_URL, + "messagepaneboxwrapper", + "collapsed" + ) !== "true"; + } + paneLayout.messagePaneSplitter.isCollapsed = !messagePaneVisible; + + if (folderURI) { + displayFolder(folderURI); + } else if (syntheticView) { + // In a synthetic view check if we have a previously edited column layout to + // restore. + if ("getPersistedSetting" in syntheticView) { + let columnsState = syntheticView.getPersistedSetting("columns"); + if (!columnsState) { + threadPane.restoreDefaultColumns(); + return; + } + + threadPane.applyPersistedColumnsState(columnsState); + threadPane.updateColumns(); + } else { + // Otherwise restore the default synthetic columns. + threadPane.restoreDefaultColumns(); + } + + gViewWrapper = new DBViewWrapper(dbViewWrapperListener); + gViewWrapper.openSynthetic(syntheticView); + gDBView = gViewWrapper.dbView; + + if ("selectedMessage" in syntheticView) { + threadTree.selectedIndex = gDBView.findIndexOfMsgHdr( + syntheticView.selectedMessage, + true + ); + } else { + // So that nsMsgSearchDBView::GetHdrForFirstSelectedMessage works from + // the beginning. + threadTree.currentIndex = 0; + } + + document.title = title; + document.body.classList.remove("account-central"); + accountCentralBrowser.hidden = true; + threadPaneHeader.onFolderSelected(); + } + + if ( + first && + messagePaneVisible && + Services.prefs.getBoolPref("mailnews.start_page.enabled") + ) { + messagePane.showStartPage(); + } +} + +/** + * Set up the given folder to be selected in the folder pane. + * @param {nsIMsgFolder|string} folder - The folder to display, or its URI. + */ +function displayFolder(folder) { + let folderURI = folder instanceof Ci.nsIMsgFolder ? folder.URI : folder; + if (folderTree.selectedRow?.uri == folderURI) { + // Already set to display the right folder. Make sure not not to change + // to the same folder in a different folder mode. + return; + } + + let row = folderPane.getRowForFolder(folderURI); + if (!row) { + return; + } + + let collapsedAncestor = row.parentNode.closest("#folderTree li.collapsed"); + while (collapsedAncestor) { + folderTree.expandRow(collapsedAncestor); + collapsedAncestor = collapsedAncestor.parentNode.closest( + "#folderTree li.collapsed" + ); + } + folderTree.selectedRow = row; +} + +/** + * Update the thread pane selection if it doesn't already match `msgHdr`. + * The selected folder will be changed if necessary. If the selection + * changes, the message pane will also be updated (via a "select" event). + * + * @param {nsIMsgDBHdr} msgHdr + */ +function selectMessage(msgHdr) { + if ( + gDBView?.numSelected == 1 && + gDBView.hdrForFirstSelectedMessage == msgHdr + ) { + return; + } + + let index = threadTree.view?.findIndexOfMsgHdr(msgHdr, true); + // Change to correct folder if needed. We might not be in a folder, or the + // message might not be found in the current folder. + if (index === undefined || index === nsMsgViewIndex_None) { + threadPane.forgetSelection(msgHdr.folder.URI); + displayFolder(msgHdr.folder.URI); + index = threadTree.view.findIndexOfMsgHdr(msgHdr, true); + threadTree.scrollToIndex(index, true); + } + threadTree.selectedIndex = index; +} + +var folderListener = { + QueryInterface: ChromeUtils.generateQI(["nsIFolderListener"]), + onFolderAdded(parentFolder, childFolder) { + folderPane.addFolder(parentFolder, childFolder); + folderPane.updateFolderRowUIElements(); + }, + onMessageAdded(parentFolder, msg) {}, + onFolderRemoved(parentFolder, childFolder) { + folderPane.removeFolder(parentFolder, childFolder); + if (childFolder == gFolder) { + gFolder = null; + gViewWrapper?.close(true); + } + }, + onMessageRemoved(parentFolder, msg) {}, + onFolderPropertyChanged(folder, property, oldValue, newValue) {}, + onFolderIntPropertyChanged(folder, property, oldValue, newValue) { + switch (property) { + case "BiffState": + folderPane.changeNewMessages( + folder, + newValue === Ci.nsIMsgFolder.nsMsgBiffState_NewMail + ); + break; + case "FolderFlag": + folderPane.changeFolderFlag(folder, oldValue, newValue); + break; + case "FolderSize": + folderPane.changeFolderSize(folder); + break; + case "TotalUnreadMessages": + if (oldValue == newValue) { + break; + } + folderPane.changeUnreadCount(folder, newValue); + break; + case "TotalMessages": + if (oldValue == newValue) { + break; + } + folderPane.changeTotalCount(folder, newValue); + threadPaneHeader.updateFolderCount(folder, newValue); + break; + } + }, + onFolderBoolPropertyChanged(folder, property, oldValue, newValue) { + switch (property) { + case "isDeferred": + if (newValue) { + folderPane.removeFolder(null, folder); + } else { + folderPane.addFolder(null, folder); + for (let f of folder.descendants) { + folderPane.addFolder(f.parent, f); + } + } + break; + case "NewMessages": + folderPane.changeNewMessages(folder, newValue); + break; + } + }, + onFolderUnicharPropertyChanged(folder, property, oldValue, newValue) { + switch (property) { + case "Name": + if (folder.isServer) { + folderPane.changeServerName(folder, newValue); + } + break; + } + }, + onFolderPropertyFlagChanged(folder, property, oldFlag, newFlag) {}, + onFolderEvent(folder, event) { + if (event == "RenameCompleted") { + // If a folder is renamed, we get an `onFolderAdded` notification for + // the folder but we are not notified about the descendants. + for (let f of folder.descendants) { + folderPane.addFolder(f.parent, f); + } + } + }, +}; + +/** + * Custom element for rows in the thread tree. + */ +customElements.whenDefined("tree-view-table-row").then(() => { + class ThreadRow extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 22; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.setAttribute("draggable", "true"); + this.appendChild(threadPane.rowTemplate.content.cloneNode(true)); + } + + get index() { + return super.index; + } + + set index(index) { + super.index = index; + + let textColumns = []; + for (let column of threadPane.columns) { + // No need to update the text of this cell if it's hidden, the selection + // column, or an icon column that doesn't match a specific flag. + if (column.hidden || column.icon || column.select) { + continue; + } + textColumns.push(column.id); + } + + // XPCOM calls here must be keep to a minimum. Collect all of the + // required data in one go. + let properties = {}; + let threadLevel = {}; + let cellTexts = this.view.cellDataForColumns( + index, + textColumns, + properties, + threadLevel + ); + + // Collect the various strings and fluent IDs to build the full string for + // the message row aria-label. + let ariaLabelPromises = []; + + const propertiesSet = new Set(properties.value.split(" ")); + const isDummyRow = propertiesSet.has("dummy"); + + this.dataset.properties = properties.value.trim(); + + for (let column of threadPane.columns) { + // Skip this column if it's hidden or it's the "select" column, since + // the selection state is communicated via the aria-activedescendant. + if (column.hidden || column.select) { + continue; + } + let cell = this.querySelector(`.${column.id.toLowerCase()}-column`); + let textIndex = textColumns.indexOf(column.id); + + // Special case for the subject column. + if (column.id == "subjectCol") { + const div = cell.querySelector(".subject-line"); + + // Indent child message of this thread. + div.style.setProperty( + "--thread-level", + gViewWrapper.showGroupedBySort ? 0 : threadLevel.value + ); + + let imageFluentID = this.#getMessageIndicatorString(propertiesSet); + const image = div.querySelector("img"); + if (imageFluentID && !isDummyRow) { + document.l10n.setAttributes(image, imageFluentID); + } else { + image.removeAttribute("data-l10n-id"); + image.alt = ""; + } + + const span = div.querySelector("span"); + cell.title = span.textContent = cellTexts[textIndex]; + ariaLabelPromises.push(cellTexts[textIndex]); + continue; + } + + if (column.id == "threadCol") { + let buttonL10nId, labelString; + if (propertiesSet.has("ignore")) { + buttonL10nId = "tree-list-view-row-ignored-thread-button"; + labelString = "tree-list-view-row-ignored-thread"; + } else if (propertiesSet.has("ignoreSubthread")) { + buttonL10nId = "tree-list-view-row-ignored-subthread-button"; + labelString = "tree-list-view-row-ignored-subthread"; + } else if (propertiesSet.has("watch")) { + buttonL10nId = "tree-list-view-row-watched-thread-button"; + labelString = "tree-list-view-row-watched-thread"; + } else if (this.classList.contains("children")) { + buttonL10nId = "tree-list-view-row-thread-button"; + } + + let button = cell.querySelector("button"); + if (buttonL10nId) { + document.l10n.setAttributes(button, buttonL10nId); + } + if (labelString) { + ariaLabelPromises.push(document.l10n.formatValue(labelString)); + } + continue; + } + + if (column.id == "flaggedCol") { + let button = cell.querySelector("button"); + if (propertiesSet.has("flagged")) { + document.l10n.setAttributes(button, "tree-list-view-row-flagged"); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-flagged-cell-label") + ); + } else { + document.l10n.setAttributes(button, "tree-list-view-row-flag"); + } + continue; + } + + if (column.id == "junkStatusCol") { + let button = cell.querySelector("button"); + if (propertiesSet.has("junk")) { + document.l10n.setAttributes(button, "tree-list-view-row-spam"); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-spam-cell-label") + ); + } else { + document.l10n.setAttributes(button, "tree-list-view-row-not-spam"); + } + continue; + } + + if (column.id == "unreadButtonColHeader") { + let button = cell.querySelector("button"); + if (propertiesSet.has("read")) { + document.l10n.setAttributes(button, "tree-list-view-row-read"); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-read-cell-label") + ); + } else { + document.l10n.setAttributes(button, "tree-list-view-row-not-read"); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-unread-cell-label") + ); + } + continue; + } + + if (column.id == "attachmentCol" && propertiesSet.has("attach")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-attachments-cell-label") + ); + continue; + } + + if (textIndex >= 0) { + if (isDummyRow) { + cell.textContent = ""; + continue; + } + cell.textContent = cellTexts[textIndex]; + ariaLabelPromises.push(cellTexts[textIndex]); + } + } + + Promise.allSettled(ariaLabelPromises).then(results => { + this.setAttribute( + "aria-label", + results + .map(settledPromise => settledPromise.value ?? "") + .filter(value => value.trim() != "") + .join(", ") + ); + }); + } + + /** + * Find the fluent ID matching the current message state. + * + * @param {Set} propertiesSet - The Set() of properties for the row. + * @returns {?string} - The fluent ID string if we found one, otherwise null. + */ + #getMessageIndicatorString(propertiesSet) { + // Bail out early if this is a new message since it can't be anything else. + if (propertiesSet.has("new")) { + return "threadpane-message-new"; + } + + const isReplied = propertiesSet.has("replied"); + const isForwarded = propertiesSet.has("forwarded"); + const isRedirected = propertiesSet.has("redirected"); + + if (isReplied && !isForwarded && !isRedirected) { + return "threadpane-message-replied"; + } + + if (isRedirected && !isForwarded && !isReplied) { + return "threadpane-message-redirected"; + } + + if (isForwarded && !isReplied && !isRedirected) { + return "threadpane-message-forwarded"; + } + + if (isReplied && isForwarded && !isRedirected) { + return "threadpane-message-replied-forwarded"; + } + + if (isReplied && isRedirected && !isForwarded) { + return "threadpane-message-replied-redirected"; + } + + if (isForwarded && isRedirected && !isReplied) { + return "threadpane-message-forwarded-redirected"; + } + + if (isReplied && isForwarded && isRedirected) { + return "threadpane-message-replied-forwarded-redirected"; + } + + return null; + } + } + customElements.define("thread-row", ThreadRow, { extends: "tr" }); + + class ThreadCard extends customElements.get("tree-view-table-row") { + static ROW_HEIGHT = 46; + + connectedCallback() { + if (this.hasConnected) { + return; + } + + super.connectedCallback(); + + this.setAttribute("draggable", "true"); + + this.appendChild( + document + .getElementById("threadPaneCardTemplate") + .content.cloneNode(true) + ); + + this.senderLine = this.querySelector(".sender"); + this.subjectLine = this.querySelector(".subject"); + this.dateLine = this.querySelector(".date"); + this.starButton = this.querySelector(".button-star"); + this.tagIcon = this.querySelector(".tag-icon"); + } + + get index() { + return super.index; + } + + set index(index) { + super.index = index; + + // XPCOM calls here must be keep to a minimum. Collect all of the + // required data in one go. + let properties = {}; + let threadLevel = {}; + + let cellTexts = this.view.cellDataForColumns( + index, + threadPane.cardColumns, + properties, + threadLevel + ); + + // Collect the various strings and fluent IDs to build the full string for + // the message row aria-label. + let ariaLabelPromises = []; + + if (threadLevel.value) { + properties.value += " thread-children"; + } + const propertiesSet = new Set(properties.value.split(" ")); + this.dataset.properties = properties.value.trim(); + + this.subjectLine.textContent = cellTexts[0]; + this.subjectLine.title = cellTexts[0]; + this.senderLine.textContent = cellTexts[1]; + this.dateLine.textContent = cellTexts[2]; + this.tagIcon.title = cellTexts[3]; + + // Follow the layout order. + ariaLabelPromises.push(cellTexts[1]); + ariaLabelPromises.push(cellTexts[2]); + ariaLabelPromises.push(cellTexts[0]); + ariaLabelPromises.push(cellTexts[3]); + + if (propertiesSet.has("flagged")) { + document.l10n.setAttributes( + this.starButton, + "tree-list-view-row-flagged" + ); + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-flagged-cell-label") + ); + } else { + document.l10n.setAttributes(this.starButton, "tree-list-view-row-flag"); + } + + if (propertiesSet.has("junk")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-spam-cell-label") + ); + } + + if (propertiesSet.has("read")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-read-cell-label") + ); + } + + if (propertiesSet.has("unread")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-unread-cell-label") + ); + } + + if (propertiesSet.has("attach")) { + ariaLabelPromises.push( + document.l10n.formatValue("threadpane-attachments-cell-label") + ); + } + + Promise.allSettled(ariaLabelPromises).then(results => { + this.setAttribute( + "aria-label", + results + .map(settledPromise => settledPromise.value ?? "") + .filter(value => value.trim() != "") + .join(", ") + ); + }); + } + } + customElements.define("thread-card", ThreadCard, { + extends: "tr", + }); +}); + +commandController.registerCallback( + "cmd_newFolder", + (folder = gFolder) => folderPane.newFolder(folder), + () => folderPaneContextMenu.getCommandState("cmd_newFolder") +); +commandController.registerCallback("cmd_newVirtualFolder", (folder = gFolder) => + folderPane.newVirtualFolder(undefined, undefined, folder) +); +commandController.registerCallback( + "cmd_deleteFolder", + (folder = gFolder) => folderPane.deleteFolder(folder), + () => folderPaneContextMenu.getCommandState("cmd_deleteFolder") +); +commandController.registerCallback( + "cmd_renameFolder", + (folder = gFolder) => folderPane.renameFolder(folder), + () => folderPaneContextMenu.getCommandState("cmd_renameFolder") +); +commandController.registerCallback( + "cmd_compactFolder", + (folder = gFolder) => { + if (folder.isServer) { + folderPane.compactAllFoldersForAccount(folder); + } else { + folderPane.compactFolder(folder); + } + }, + () => folderPaneContextMenu.getCommandState("cmd_compactFolder") +); +commandController.registerCallback( + "cmd_emptyTrash", + (folder = gFolder) => folderPane.emptyTrash(folder), + () => folderPaneContextMenu.getCommandState("cmd_emptyTrash") +); +commandController.registerCallback( + "cmd_properties", + (folder = gFolder) => folderPane.editFolder(folder), + () => folderPaneContextMenu.getCommandState("cmd_properties") +); +commandController.registerCallback( + "cmd_toggleFavoriteFolder", + (folder = gFolder) => folder.toggleFlag(Ci.nsMsgFolderFlags.Favorite), + () => folderPaneContextMenu.getCommandState("cmd_toggleFavoriteFolder") +); + +// Delete commands, which change behaviour based on the active element. +// Note that `document.activeElement` refers to the active element in *this* +// document regardless of whether this document is the active one. +commandController.registerCallback( + "cmd_delete", + () => { + if (document.activeElement == folderTree) { + commandController.doCommand("cmd_deleteFolder"); + } else if (!quickFilterBar.domNode.contains(document.activeElement)) { + commandController.doCommand("cmd_deleteMessage"); + } + }, + () => { + if (document.activeElement == folderTree) { + return commandController.isCommandEnabled("cmd_deleteFolder"); + } + if ( + !quickFilterBar?.domNode || + quickFilterBar.domNode.contains(document.activeElement) + ) { + return false; + } + return commandController.isCommandEnabled("cmd_deleteMessage"); + } +); +commandController.registerCallback( + "cmd_shiftDelete", + () => { + commandController.doCommand("cmd_shiftDeleteMessage"); + }, + () => { + if ( + document.activeElement == folderTree || + !quickFilterBar?.domNode || + quickFilterBar.domNode.contains(document.activeElement) + ) { + return false; + } + return commandController.isCommandEnabled("cmd_shiftDeleteMessage"); + } +); + +commandController.registerCallback("cmd_viewClassicMailLayout", () => + Services.prefs.setIntPref("mail.pane_config.dynamic", 0) +); +commandController.registerCallback("cmd_viewWideMailLayout", () => + Services.prefs.setIntPref("mail.pane_config.dynamic", 1) +); +commandController.registerCallback("cmd_viewVerticalMailLayout", () => + Services.prefs.setIntPref("mail.pane_config.dynamic", 2) +); +commandController.registerCallback( + "cmd_toggleThreadPaneHeader", + () => threadPaneHeader.toggleThreadPaneHeader(), + () => gFolder && !gFolder.isServer +); +commandController.registerCallback( + "cmd_toggleFolderPane", + () => paneLayout.folderPaneSplitter.toggleCollapsed(), + () => !!gFolder +); +commandController.registerCallback("cmd_toggleMessagePane", () => { + paneLayout.messagePaneSplitter.toggleCollapsed(); +}); + +commandController.registerCallback( + "cmd_selectAll", + () => { + threadTree.selectAll(); + threadTree.table.body.focus(); + }, + () => !!gViewWrapper?.dbView +); +commandController.registerCallback( + "cmd_selectThread", + () => gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.selectThread), + () => !!gViewWrapper?.dbView +); +commandController.registerCallback( + "cmd_selectFlagged", + () => gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.selectFlagged), + () => !!gViewWrapper?.dbView +); +commandController.registerCallback( + "cmd_downloadFlagged", + () => + gViewWrapper.dbView.doCommand( + Ci.nsMsgViewCommandType.downloadFlaggedForOffline + ), + () => gFolder && !gFolder.isServer && MailOfflineMgr.isOnline() +); +commandController.registerCallback( + "cmd_downloadSelected", + () => + gViewWrapper.dbView.doCommand( + Ci.nsMsgViewCommandType.downloadSelectedForOffline + ), + () => + gFolder && + !gFolder.isServer && + MailOfflineMgr.isOnline() && + gViewWrapper.dbView.selectedCount > 0 +); + +var sortController = { + handleCommand(event) { + switch (event.target.value) { + case "ascending": + this.sortAscending(); + threadPane.restoreSortIndicator(); + break; + case "descending": + this.sortDescending(); + threadPane.restoreSortIndicator(); + break; + case "threaded": + this.sortThreaded(); + break; + case "unthreaded": + this.sortUnthreaded(); + break; + case "group": + this.groupBySort(); + break; + default: + if (event.target.value in Ci.nsMsgViewSortType) { + this.sortThreadPane(event.target.value); + threadPane.restoreSortIndicator(); + } + break; + } + }, + sortByThread() { + threadPane.updateListRole(false); + gViewWrapper.showThreaded = true; + this.sortThreadPane("byDate"); + }, + sortThreadPane(sortName) { + let sortType = Ci.nsMsgViewSortType[sortName]; + let grouped = gViewWrapper.showGroupedBySort; + gViewWrapper._threadExpandAll = Boolean( + gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ); + + if (!grouped) { + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. + gViewWrapper.sort(sortType, Ci.nsMsgViewSortOrder.ascending); + threadTree.style.scrollBehavior = null; + // Respect user's last expandAll/collapseAll choice, post sort direction change. + threadPane.restoreThreadState(); + return; + } + + // legacy behavior dictates we un-group-by-sort if we were. this probably + // deserves a UX call... + + // For non virtual folders, do not ungroup (which sorts by the going away + // sort) and then sort, as it's a double sort. + // For virtual folders, which are rebuilt in the backend in a grouped + // change, create a new view upfront rather than applying viewFlags. There + // are oddities just applying viewFlags, for example changing out of a + // custom column grouped xfvf view with the threads collapsed works (doesn't) + // differently than other variations. + // So, first set the desired sortType and sortOrder, then set viewFlags in + // batch mode, then apply it all (open a new view) with endViewUpdate(). + gViewWrapper.beginViewUpdate(); + gViewWrapper._sort = [[sortType, Ci.nsMsgViewSortOrder.ascending]]; + gViewWrapper.showGroupedBySort = false; + gViewWrapper.endViewUpdate(); + + // Virtual folders don't persist viewFlags well in the back end, + // due to a virtual folder being either 'real' or synthetic, so make + // sure it's done here. + if (gViewWrapper.isVirtual) { + gViewWrapper.dbView.viewFlags = gViewWrapper.viewFlags; + } + }, + reverseSortThreadPane() { + let grouped = gViewWrapper.showGroupedBySort; + gViewWrapper._threadExpandAll = Boolean( + gViewWrapper._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ); + + // Grouped By view is special for column click sort direction changes. + if (grouped) { + if (gDBView.selection.count) { + threadPane.saveSelection(); + } + + if (gViewWrapper.isSingleFolder) { + if (gViewWrapper.isVirtual) { + gViewWrapper.showGroupedBySort = false; + } else { + // Must ensure rows are collapsed and kExpandAll is unset. + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + } + } + } + + if (gViewWrapper.isSortedAscending) { + gViewWrapper.sortDescending(); + } else { + gViewWrapper.sortAscending(); + } + + // Restore Grouped By state post sort direction change. + if (grouped) { + if (gViewWrapper.isVirtual && gViewWrapper.isSingleFolder) { + this.groupBySort(); + } + // Restore Grouped By selection post sort direction change. + threadPane.restoreSelection(); + // Refresh dummy rows in case of collapseAll. + threadTree.invalidate(); + } + threadPane.restoreThreadState(); + }, + toggleThreaded() { + if (gViewWrapper.showThreaded) { + threadPane.updateListRole(true); + gViewWrapper.showUnthreaded = true; + } else { + threadPane.updateListRole(false); + gViewWrapper.showThreaded = true; + } + }, + sortThreaded() { + threadPane.updateListRole(false); + gViewWrapper.showThreaded = true; + }, + groupBySort() { + threadPane.updateListRole(false); + gViewWrapper.showGroupedBySort = true; + }, + sortUnthreaded() { + threadPane.updateListRole(true); + gViewWrapper.showUnthreaded = true; + }, + sortAscending() { + if (gViewWrapper.showGroupedBySort && gViewWrapper.isSingleFolder) { + if (gViewWrapper.isSortedDescending) { + this.reverseSortThreadPane(); + } + return; + } + + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. + gViewWrapper.sortAscending(); + threadPane.ensureThreadStateForQuickSearchView(); + threadTree.style.scrollBehavior = null; + }, + sortDescending() { + if (gViewWrapper.showGroupedBySort && gViewWrapper.isSingleFolder) { + if (gViewWrapper.isSortedAscending) { + this.reverseSortThreadPane(); + } + return; + } + + threadTree.style.scrollBehavior = "auto"; // Avoid smooth scroll. + gViewWrapper.sortDescending(); + threadPane.ensureThreadStateForQuickSearchView(); + threadTree.style.scrollBehavior = null; + }, + convertSortTypeToColumnID(sortKey) { + let columnID; + + // Hack to turn this into an integer, if it was a string. + // It would be a string if it came from XULStore.json. + sortKey = sortKey - 0; + + switch (sortKey) { + // In the case of None, we default to the date column. This appears to be + // the case in such instances as Global search, so don't complain about + // it. + case Ci.nsMsgViewSortType.byNone: + case Ci.nsMsgViewSortType.byDate: + columnID = "dateCol"; + break; + case Ci.nsMsgViewSortType.byReceived: + columnID = "receivedCol"; + break; + case Ci.nsMsgViewSortType.byAuthor: + columnID = "senderCol"; + break; + case Ci.nsMsgViewSortType.byRecipient: + columnID = "recipientCol"; + break; + case Ci.nsMsgViewSortType.bySubject: + columnID = "subjectCol"; + break; + case Ci.nsMsgViewSortType.byLocation: + columnID = "locationCol"; + break; + case Ci.nsMsgViewSortType.byAccount: + columnID = "accountCol"; + break; + case Ci.nsMsgViewSortType.byUnread: + columnID = "unreadButtonColHeader"; + break; + case Ci.nsMsgViewSortType.byStatus: + columnID = "statusCol"; + break; + case Ci.nsMsgViewSortType.byTags: + columnID = "tagsCol"; + break; + case Ci.nsMsgViewSortType.bySize: + columnID = "sizeCol"; + break; + case Ci.nsMsgViewSortType.byPriority: + columnID = "priorityCol"; + break; + case Ci.nsMsgViewSortType.byFlagged: + columnID = "flaggedCol"; + break; + case Ci.nsMsgViewSortType.byThread: + columnID = "threadCol"; + break; + case Ci.nsMsgViewSortType.byId: + columnID = "idCol"; + break; + case Ci.nsMsgViewSortType.byJunkStatus: + columnID = "junkStatusCol"; + break; + case Ci.nsMsgViewSortType.byAttachments: + columnID = "attachmentCol"; + break; + case Ci.nsMsgViewSortType.byCustom: + // TODO: either change try() catch to if (property exists) or restore + // the getColumnHandler() check. + try { + // getColumnHandler throws an error when the ID is not handled + columnID = gDBView.curCustomColumn; + } catch (e) { + // error - means no handler + dump( + "ConvertSortTypeToColumnID: custom sort key but no handler for column '" + + columnID + + "'\n" + ); + columnID = "dateCol"; + } + break; + case Ci.nsMsgViewSortType.byCorrespondent: + columnID = "correspondentCol"; + break; + default: + dump("unsupported sort key: " + sortKey + "\n"); + columnID = "dateCol"; + break; + } + return columnID; + }, +}; + +commandController.registerCallback( + "cmd_sort", + event => sortController.handleCommand(event), + () => !!gViewWrapper?.dbView +); + +commandController.registerCallback( + "cmd_expandAllThreads", + () => { + threadPane.saveSelection(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll); + gViewWrapper._threadExpandAll = true; + threadPane.restoreSelection(); + }, + () => !!gViewWrapper?.dbView +); +commandController.registerCallback( + "cmd_collapseAllThreads", + () => { + threadPane.saveSelection(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + gViewWrapper._threadExpandAll = false; + threadPane.restoreSelection({ expand: false }); + }, + () => !!gViewWrapper?.dbView +); + +function SwitchView(command) { + // when switching thread views, we might be coming out of quick search + // or a message view. + // first set view picker to all + if (gViewWrapper.mailViewIndex != 0) { + // MailViewConstants.kViewItemAll + gViewWrapper.setMailView(0); + } + + switch (command) { + // "All" threads and "Unread" threads don't change threading state + case "cmd_viewAllMsgs": + gViewWrapper.showUnreadOnly = false; + break; + case "cmd_viewUnreadMsgs": + gViewWrapper.showUnreadOnly = true; + break; + // "Threads with Unread" and "Watched Threads with Unread" force threading + case "cmd_viewWatchedThreadsWithUnread": + gViewWrapper.specialViewWatchedThreadsWithUnread = true; + break; + case "cmd_viewThreadsWithUnread": + gViewWrapper.specialViewThreadsWithUnread = true; + break; + // "Ignored Threads" toggles 'ignored' inclusion -- + // but it also resets 'With Unread' views to 'All' + case "cmd_viewIgnoredThreads": + gViewWrapper.showIgnored = !gViewWrapper.showIgnored; + break; + } +} + +commandController.registerCallback( + "cmd_viewAllMsgs", + () => SwitchView("cmd_viewAllMsgs"), + () => !!gDBView +); +commandController.registerCallback( + "cmd_viewThreadsWithUnread", + () => SwitchView("cmd_viewThreadsWithUnread"), + () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) +); +commandController.registerCallback( + "cmd_viewWatchedThreadsWithUnread", + () => SwitchView("cmd_viewWatchedThreadsWithUnread"), + () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) +); +commandController.registerCallback( + "cmd_viewUnreadMsgs", + () => SwitchView("cmd_viewUnreadMsgs"), + () => gDBView && gFolder && !(gFolder.flags & Ci.nsMsgFolderFlags.Virtual) +); +commandController.registerCallback( + "cmd_viewIgnoredThreads", + () => SwitchView("cmd_viewIgnoredThreads"), + () => !!gDBView +); + +commandController.registerCallback("cmd_goStartPage", () => { + // This is a user-triggered command, they must want to see the page, so show + // the message pane if it's hidden. + paneLayout.messagePaneSplitter.expand(); + messagePane.showStartPage(); +}); +commandController.registerCallback( + "cmd_print", + async () => { + let PrintUtils = top.PrintUtils; + if (!webBrowser.hidden) { + PrintUtils.startPrintWindow(webBrowser.browsingContext); + return; + } + let uris = gViewWrapper.dbView.getURIsForSelection(); + if (uris.length == 1) { + if (messageBrowser.hidden) { + // Load the only message in a hidden browser, then use the print preview UI. + let messageService = MailServices.messageServiceFromURI(uris[0]); + await PrintUtils.loadPrintBrowser( + messageService.getUrlForUri(uris[0]).spec + ); + PrintUtils.startPrintWindow( + PrintUtils.printBrowser.browsingContext, + {} + ); + } else { + PrintUtils.startPrintWindow( + messageBrowser.contentWindow.getMessagePaneBrowser().browsingContext, + {} + ); + } + return; + } + + // Multiple messages. Get the printer settings, then load the messages into + // a hidden browser and print them one at a time. + let ps = PrintUtils.getPrintSettings(); + Cc["@mozilla.org/widget/printdialog-service;1"] + .getService(Ci.nsIPrintDialogService) + .showPrintDialog(window, false, ps); + if (ps.isCancelled) { + return; + } + ps.printSilent = true; + + for (let uri of uris) { + let messageService = MailServices.messageServiceFromURI(uri); + await PrintUtils.loadPrintBrowser(messageService.getUrlForUri(uri).spec); + await PrintUtils.printBrowser.browsingContext.print(ps); + } + }, + () => { + if (!accountCentralBrowser?.hidden) { + return false; + } + if (webBrowser && !webBrowser.hidden) { + return true; + } + return gDBView && gDBView.numSelected > 0; + } +); +commandController.registerCallback( + "cmd_recalculateJunkScore", + () => analyzeMessagesForJunk(), + () => { + // We're going to take a conservative position here, because we really + // don't want people running junk controls on folders that are not + // enabled for junk. The junk type picks up possible dummy message headers, + // while the runJunkControls will prevent running on XF virtual folders. + return ( + commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.junk) && + commandController._getViewCommandStatus( + Ci.nsMsgViewCommandType.runJunkControls + ) + ); + } +); +commandController.registerCallback( + "cmd_runJunkControls", + () => filterFolderForJunk(gFolder), + () => + commandController._getViewCommandStatus( + Ci.nsMsgViewCommandType.runJunkControls + ) +); +commandController.registerCallback( + "cmd_deleteJunk", + () => deleteJunkInFolder(gFolder), + () => + commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.deleteJunk) +); + +commandController.registerCallback( + "cmd_killThread", + () => { + threadPane.hideIgnoredMessageNotification(); + if (!gFolder.msgDatabase.isIgnored(gDBView.keyForFirstSelectedMessage)) { + threadPane.showIgnoredMessageNotification( + gDBView.getSelectedMsgHdrs(), + false + ); + } + commandController._navigate(Ci.nsMsgNavigationType.toggleThreadKilled); + // Invalidation should be unnecessary but the back end doesn't notify us + // properly and resists attempts to fix this. + threadTree.reset(); + }, + () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic) +); +commandController.registerCallback( + "cmd_killSubthread", + () => { + threadPane.hideIgnoredMessageNotification(); + if (!gDBView.hdrForFirstSelectedMessage.isKilled) { + threadPane.showIgnoredMessageNotification( + gDBView.getSelectedMsgHdrs(), + true + ); + } + commandController._navigate(Ci.nsMsgNavigationType.toggleSubthreadKilled); + // Invalidation should be unnecessary but the back end doesn't notify us + // properly and resists attempts to fix this. + threadTree.reset(); + }, + () => gDBView?.numSelected >= 1 && (gFolder || gViewWrapper.isSynthetic) +); + +// Forward these commands directly to about:message. +commandController.registerCallback( + "cmd_find", + () => + this.messageBrowser.contentWindow.commandController.doCommand("cmd_find"), + () => this.messageBrowser && !this.messageBrowser.hidden +); +commandController.registerCallback( + "cmd_findAgain", + () => + this.messageBrowser.contentWindow.commandController.doCommand( + "cmd_findAgain" + ), + () => this.messageBrowser && !this.messageBrowser.hidden +); +commandController.registerCallback( + "cmd_findPrevious", + () => + this.messageBrowser.contentWindow.commandController.doCommand( + "cmd_findPrevious" + ), + () => this.messageBrowser && !this.messageBrowser.hidden +); + +/** + * Helper function for the zoom commands, which returns the browser that is + * currently visible in the message pane or null if no browser is visible. + * + * @returns {?XULElement} - A XUL browser or null. + */ +function visibleMessagePaneBrowser() { + if (webBrowser && !webBrowser.hidden) { + return webBrowser; + } + + if (messageBrowser && !messageBrowser.hidden) { + // If the message browser is the one visible, actually return the + // element showing the message's content, since that's the one zoom + // commands should apply to. + return messageBrowser.contentDocument.getElementById("messagepane"); + } + + if (multiMessageBrowser && !multiMessageBrowser.hidden) { + return multiMessageBrowser; + } + + return null; +} + +// Zoom. +commandController.registerCallback( + "cmd_fullZoomReduce", + () => top.ZoomManager.reduce(visibleMessagePaneBrowser()), + () => visibleMessagePaneBrowser() != null +); +commandController.registerCallback( + "cmd_fullZoomEnlarge", + () => top.ZoomManager.enlarge(visibleMessagePaneBrowser()), + () => visibleMessagePaneBrowser() != null +); +commandController.registerCallback( + "cmd_fullZoomReset", + () => top.ZoomManager.reset(visibleMessagePaneBrowser()), + () => visibleMessagePaneBrowser() != null +); +commandController.registerCallback( + "cmd_fullZoomToggle", + () => top.ZoomManager.toggleZoom(visibleMessagePaneBrowser()), + () => visibleMessagePaneBrowser() != null +); + +// Browser commands. +commandController.registerCallback( + "Browser:Back", + () => webBrowser.goBack(), + () => webBrowser?.canGoBack +); +commandController.registerCallback( + "Browser:Forward", + () => webBrowser.goForward(), + () => webBrowser?.canGoForward +); +commandController.registerCallback( + "cmd_reload", + () => webBrowser.reload(), + () => webBrowser && !webBrowser.busy +); +commandController.registerCallback( + "cmd_stop", + () => webBrowser.stop(), + () => webBrowser && webBrowser.busy +); + +// Attachments commands. +for (let command of [ + "cmd_openAllAttachments", + "cmd_saveAllAttachments", + "cmd_detachAllAttachments", + "cmd_deleteAllAttachments", +]) { + commandController.registerCallback( + command, + () => messageBrowser.contentWindow.commandController.doCommand(command), + () => + messageBrowser && + !messageBrowser.hidden && + messageBrowser.contentWindow.commandController.isCommandEnabled(command) + ); +} diff --git a/comm/mail/base/content/about3Pane.xhtml b/comm/mail/base/content/about3Pane.xhtml new file mode 100644 index 0000000000..084a5ac7ad --- /dev/null +++ b/comm/mail/base/content/about3Pane.xhtml @@ -0,0 +1,762 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +#filter substitution + +<!DOCTYPE html [ +<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" > +%messengerDTD; +<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd"> +%calendarDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + lightweightthemes="true"> +<head> + <meta charset="utf-8" /> + <title></title> + + <link rel="icon" href="chrome://messenger/skin/icons/new/compact/folder.svg" /> + + <link rel="stylesheet" href="chrome://messenger/skin/messenger.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/icons.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/colors.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/folderColors.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/folderMenus.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/shared/quickFilterBar.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/searchBox.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/about3Pane.css" /> + + <link rel="localization" href="messenger/about3Pane.ftl" /> + <link rel="localization" href="messenger/treeView.ftl" /> + <link rel="localization" href="messenger/messenger.ftl" /> + <link rel="localization" href="toolkit/global/textActions.ftl" /> + + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/junkCommands.js"></script> + <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script> + <script defer="defer" src="chrome://messenger/content/msgViewNavigation.js"></script> + <script defer="defer" src="chrome://messenger/content/quickFilterBar.js"></script> + <script defer="defer" src="chrome://messenger/content/pane-splitter.js"></script> + <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script> + <script defer="defer" type="module" src="chrome://messenger/content/tree-view.mjs"></script> + <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script> + <script defer="defer" src="chrome://messenger/content/mailContext.js"></script> + <script defer="defer" src="chrome://messenger/content/mailCommon.js"></script> + <script defer="defer" src="chrome://messenger/content/about3Pane.js"></script> +</head> +<body class="layout-classic"> + <div id="folderPane" class="collapsed-by-splitter no-overscroll" tabindex="-1"> + <div id="folderPaneHeaderBar" hidden="hidden"> +# Force a reverse tabindex to work alongside the `flex-direction: row-reverse` +# in order to guarantee a consistent end alignment of the `#folderPaneMoreButton`. + <button id="folderPaneMoreButton" + class="button button-flat icon-button icon-only" + data-l10n-id="folder-pane-more-menu-button" + type="button" + tabindex="3"></button> + <button id="folderPaneWriteMessage" + class="button button-primary icon-button" + data-l10n-id="folder-pane-write-message-button" + type="button" + tabindex="2" + disabled="disabled"></button> + <button id="folderPaneGetMessages" + class="button button-flat icon-button icon-only" + data-l10n-id="folder-pane-get-messages-button" + type="button" + tabindex="1" + disabled="disabled"></button> + </div> + <ul id="folderTree" is="tree-listbox" role="tree"></ul> + <template id="modeTemplate"> + <li class="unselectable"> + <div class="mode-container"> + <div class="mode-name"></div> + <button class="mode-button button button-flat icon-button icon-only" + type="button" + data-l10n-id="folder-pane-mode-context-button" + tabindex="-1"></button> + </div> + <ul></ul> + </li> + </template> + <template id="folderTemplate"> + <div class="container"> + <div class="twisty"> + <img class="twisty-icon" src="chrome://global/skin/icons/arrow-down-12.svg" alt="" /> + </div> + <div class="icon"></div> + <span class="name" tabindex="-1"></span> + <span class="folder-count-badge unread-count"></span> + <span class="folder-count-badge total-count" hidden="hidden"></span> + <span class="folder-size" hidden="hidden"></span> + </div> + <ul></ul> + </template> + </div> + <hr is="pane-splitter" id="folderPaneSplitter" + resize-direction="horizontal" + resize-id="folderPane" + collapse-width="100" /> + <div id="threadPane"> + <div id="threadPaneHeaderBar" class="list-header-bar"> + <div class="list-header-bar-container-start" + role="region" + aria-live="off"> + <h2 id="threadPaneFolderName" class="list-header-title"></h2> + <div id="threadPaneFolderCountContainer"> + <span id="threadPaneFolderCount" + class="thread-pane-count-info" + hidden="hidden"></span> + <span id="threadPaneSelectedCount" + class="thread-pane-count-info" + hidden="hidden"></span> + </div> + </div> + <div class="list-header-bar-container-end"> + <button id="threadPaneQuickFilterButton" + class="button icon-button check-button unified-toolbar-button" + data-l10n-id="quick-filter-button" + oncommand="cmd_toggleQuickFilterBar"> + <span data-l10n-id="quick-filter-button-label"></span> + </button> + <button id="threadPaneDisplayButton" + class="button button-flat icon-button icon-only" + data-l10n-id="thread-pane-header-display-button" + type="button"> + </button> + </div> + </div> +#include quickFilterBar.inc.xhtml + <tree-view id="threadTree" data-label-id="threadPaneFolderName"/> + <!-- Thread pane templates --> + <template id="threadPaneApplyColumnMenu"> + <xul:menu class="applyTo-menu" + data-l10n-id="apply-columns-to-menu" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <menupopup> + <menu class="applyToFolder-menu" + data-l10n-id="apply-current-view-to-folder" + oncommand="threadPane.confirmApplyColumns(event.target._folder);"> + <menupopup is="folder-menupopup" + class="applyToFolder" + showFileHereLabel="false" + position="start_before"></menupopup> + </menu> + <menu class="applyToFolderAndChildren-menu" + data-l10n-id="apply-current-view-to-folder-children" + oncommand="threadPane.confirmApplyColumns(event.target._folder, true);"> + <menupopup is="folder-menupopup" + class="applyToFolderAndChildren" + showFileHereLabel="true" + showAccountsFileHere="true" + position="start_before"></menupopup> + </menu> + </menupopup> + </xul:menu> + </template> + <template id="threadPaneApplyViewMenu"> + <xul:menu class="applyViewTo-menu" + data-l10n-id="apply-current-view-to-menu" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <menupopup> + <menu class="applyViewToFolder-menu" + data-l10n-id="apply-current-view-to-folder" + oncommand="threadPane.confirmApplyView(event.target._folder);"> + <menupopup is="folder-menupopup" + class="applyViewToFolder" + showFileHereLabel="true" + position="start_before"></menupopup> + </menu> + <menu class="applyViewToFolderAndChildren-menu" + data-l10n-id="apply-current-view-to-folder-children" + oncommand="threadPane.confirmApplyView(event.target._folder, true);"> + <menupopup is="folder-menupopup" + class="applyViewToFolderAndChildren" + showFileHereLabel="true" + showAccountsFileHere="true" + position="start_before"></menupopup> + </menu> + </menupopup> + </xul:menu> + </template> + <template id="threadPaneRowTemplate"> + <!-- This template must be kept in sync with thread-pane-columns.mjs. --> + <td class="selectcol-column" data-l10n-id="threadpane-cell-select"></td> + <td class="tree-view-row-thread threadcol-column button-column" data-l10n-id="threadpane-cell-thread"> + <button type="button" + class="button-flat tree-button-thread" + aria-hidden="true" + tabindex="-1"> + <img src="" alt="" /> + </button> + </td> + <td class="tree-view-row-flag flaggedcol-column button-column" data-l10n-id="threadpane-cell-flagged"> + <button type="button" + class="button-flat tree-button-flag" + aria-hidden="true" + tabindex="-1"> + <img src="" alt="" /> + </button> + </td> + <td class="attachmentcol-column button-column" data-l10n-id="threadpane-cell-attachments"> + <img src="" data-l10n-id="tree-list-view-row-attach" /> + </td> + <td class="subjectcol-column" data-l10n-id="threadpane-cell-subject"> + <div class="thread-container"> + <button type="button" + class="button button-flat button-reset twisty" + aria-hidden="true" + tabindex="-1"> + <img src="" alt="" class="twisty-icon" /> + </button> + <div class="subject-line" tabindex="-1"> + <img src="" alt="" /><span></span> + </div> + </div> + </td> + <td class="tree-view-row-unread unreadbuttoncolheader-column button-column" data-l10n-id="threadpane-cell-read-status"> + <button type="button" + class="button-flat tree-button-unread" + aria-hidden="true" + tabindex="-1"> + <img src="" alt="" /> + </button> + </td> + <td class="sendercol-column" data-l10n-id="threadpane-cell-sender"></td> + <td class="recipientcol-column" data-l10n-id="threadpane-cell-recipient"></td> + <td class="correspondentcol-column" data-l10n-id="threadpane-cell-correspondents"></td> + <td class="tree-view-row-spam junkstatuscol-column button-column" data-l10n-id="threadpane-cell-spam"> + <button type="button" + class="button-flat tree-button-spam" + aria-hidden="true" + tabindex="-1"> + <img src="" alt="" /> + </button> + </td> + <td class="datecol-column" data-l10n-id="threadpane-cell-date"></td> + <td class="receivedcol-column" data-l10n-id="threadpane-cell-received"></td> + <td class="statuscol-column" data-l10n-id="threadpane-cell-status"></td> + <td class="sizecol-column" data-l10n-id="threadpane-cell-size"></td> + <td class="tagscol-column" data-l10n-id="threadpane-cell-tags"></td> + <td class="accountcol-column" data-l10n-id="threadpane-cell-account"></td> + <td class="prioritycol-column" data-l10n-id="threadpane-cell-priority"></td> + <td class="unreadcol-column" data-l10n-id="threadpane-cell-unread"></td> + <td class="totalcol-column" data-l10n-id="threadpane-cell-total"></td> + <td class="locationcol-column" data-l10n-id="threadpane-cell-location"></td> + <td class="idcol-column" data-l10n-id="threadpane-cell-id"></td> + <td class="tree-view-row-delete deletecol-column button-column" data-l10n-id="threadpane-cell-delete"> + <button type="button" + class="button-flat tree-button-delete tree-button-request-delete" + tabindex="-1" + aria-hidden="true" + data-l10n-id="tree-list-view-row-delete"> + <img src="" alt="" /> + </button> + <button type="button" + class="button-flat tree-button-restore tree-button-request-delete" + tabindex="-1" + aria-hidden="true" + data-l10n-id="tree-list-view-row-restore"> + <img src="" alt="" /> + </button> + </td> + </template> + <template id="threadPaneCardTemplate"> + <td> + <div class="thread-card-container"> + <div class="thread-card-row"> + <span class="sender"></span> + <img class="state replied" src="" data-l10n-id="threadpane-message-replied" /> + <img class="state forwarded" src="" data-l10n-id="threadpane-message-forwarded" /> + <img class="state redirected" src="" data-l10n-id="threadpane-message-redirected" /> + <button class="button-spam tree-button-spam" + data-l10n-id="tree-list-view-row-spam" + aria-hidden="true" + tabindex="-1"> + </button> + <span class="date"></span> + </div> + <div class="thread-card-row"> + <div class="thread-card-subject-container"> + <button type="button" + class="button button-flat button-reset twisty" + aria-hidden="true" + tabindex="-1"> + <img src="" alt="" class="twisty-icon" /> + </button> + <span class="subject"></span> + </div> + <img class="attachment-icon" src="" data-l10n-id="tree-list-view-row-attach" /> + <img class="tag-icon" src="" alt="" hidden="hidden" /> + <button class="button-star tree-button-flag" + aria-hidden="true" + tabindex="-1"> + </button> + </div> + </div> + </td> + </template> + <div id="threadPaneNotificationBox"> + <!-- notificationbox will be added here lazily. --> + </div> + </div> + <hr is="pane-splitter" id="messagePaneSplitter" + resize-id="messagePane" + collapse-width="300" + collapse-height="100" /> + <div id="messagePane" class="collapsed-by-splitter"> + <xul:browser id="webBrowser" + type="content" + hidden="true" + nodefaultsrc="true" + context="browserContext" + autocompletepopup="PopupAutoComplete" + forcemessagemanager="true" + messagemanagergroup="single-page" + maychangeremoteness="true" /> + <xul:browser id="messageBrowser" + hidden="true" + src="about:message" /> + <xul:browser id="multiMessageBrowser" + type="content" + hidden="true" + context="aboutPagesContext" + src="chrome://messenger/content/multimessageview.xhtml" /> + </div> + <xul:browser id="accountCentralBrowser" hidden="true"/> +</body> +<popupset xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <menupopup id="folderPaneContext"> + <menuitem id="folderPaneContext-getMessages" + class="menuitem-iconic" + label="&folderContextGetMessages.label;" + accesskey="&folderContextGetMessages.accesskey;"/> + <menuitem id="folderPaneContext-pauseAllUpdates" + type="checkbox" + label="&folderContextPauseAllUpdates.label;" + accesskey="&folderContextPauseUpdates.accesskey;"/> + <menuitem id="folderPaneContext-pauseUpdates" + type="checkbox" + label="&folderContextPauseUpdates.label;" + accesskey="&folderContextPauseUpdates.accesskey;"/> + <menuseparator/> + <menuitem id="folderPaneContext-openNewTab" + class="menuitem-iconic" + label="&folderContextOpenNewTab.label;" + accesskey="&folderContextOpenNewTab.accesskey;"/> + <menuitem id="folderPaneContext-openNewWindow" + class="menuitem-iconic" + label="&folderContextOpenInNewWindow.label;" + accesskey="&folderContextOpenInNewWindow.accesskey;"/> + <menuitem id="folderPaneContext-searchMessages" + class="menuitem-iconic" + label="&folderContextSearchForMessages.label;" + accesskey="&folderContextSearchForMessages.accesskey;"/> + <menuitem id="folderPaneContext-subscribe" + class="menuitem-iconic" + label="&folderContextSubscribe.label;" + accesskey="&folderContextSubscribe.accesskey;"/> + <menuitem id="folderPaneContext-newsUnsubscribe" + class="menuitem-iconic" + label="&folderContextUnsubscribe.label;" + accesskey="&folderContextUnsubscribe.accesskey;"/> + <menuseparator/> + <menuitem id="folderPaneContext-new" + class="menuitem-iconic" + label="&folderContextNew.label;" + accesskey="&folderContextNew.accesskey;"/> + <menuitem id="folderPaneContext-remove" + class="menuitem-iconic" + label="&folderContextRemove.label;" + accesskey="&folderContextRemove.accesskey;"/> + <menuitem id="folderPaneContext-rename" + class="menuitem-iconic" + label="&folderContextRename.label;" + accesskey="&folderContextRename.accesskey;"/> + <menuseparator/> + <menu id="folderPaneContext-moveMenu" + class="menu-iconic" + label="&moveMsgToMenu.label;" + accesskey="&moveMsgToMenu.accesskey;"> + <menupopup is="folder-menupopup" id="folderContext-movePopup" + mode="filing" + showAccountsFileHere="true" + showFileHereLabel="true" + showRecent="true" + recentLabel="&contextMoveCopyMsgRecentMenu.label;" + recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;" + showFavorites="true" + favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;" + favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;" + showLast="true"/> + </menu> + <menu id="folderPaneContext-copyMenu" + class="menu-iconic" + label="©MsgToMenu.label;" + accesskey="©MsgToMenu.accesskey;"> + <menupopup is="folder-menupopup" id="folderContext-copyPopup" + mode="filing" + showAccountsFileHere="true" + showFileHereLabel="true" + showRecent="true" + recentLabel="&contextMoveCopyMsgRecentMenu.label;" + recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;" + showFavorites="true" + favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;" + favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;" + showLast="true"/> + </menu> + <menuseparator/> + <menuitem id="folderPaneContext-compact" + class="menuitem-iconic" + label="&folderContextCompact.label;" + accesskey="&folderContextCompact.accesskey;"/> + <menuitem id="folderPaneContext-markMailFolderAllRead" + class="menuitem-iconic" + label="&folderContextMarkMailFolderRead.label;" + accesskey="&folderContextMarkMailFolderRead.accesskey;"/> + <menuitem id="folderPaneContext-markNewsgroupAllRead" + class="menuitem-iconic" + label="&folderContextMarkNewsgroupRead.label;" + accesskey="&folderContextMarkNewsgroupRead.accesskey;"/> + <menuitem id="folderPaneContext-emptyTrash" + class="menuitem-iconic" + label="&folderContextEmptyTrash.label;" + accesskey="&folderContextEmptyTrash.accesskey;"/> + <menuitem id="folderPaneContext-emptyJunk" + class="menuitem-iconic" + label="&folderContextEmptyJunk.label;" + accesskey="&folderContextEmptyJunk.accesskey;"/> + <menuitem id="folderPaneContext-sendUnsentMessages" + class="menuitem-iconic" + label="&folderContextSendUnsentMessages.label;" + accesskey="&folderContextSendUnsentMessages.accesskey;"/> + <menuseparator/> + <menuitem id="folderPaneContext-favoriteFolder" + type="checkbox" + label="&folderContextFavoriteFolder.label;" + accesskey="&folderContextFavoriteFolder.accesskey;"/> + <menuitem id="folderPaneContext-properties" + class="menuitem-iconic" + label="&folderContextProperties2.label;" + accesskey="&folderContextProperties2.accesskey;"/> + <menuitem id="folderPaneContext-markAllFoldersRead" + class="menuitem-iconic" + label="&folderContextMarkAllFoldersRead.label;"/> + <menuseparator/> + <menuitem id="folderPaneContext-settings" + class="menuitem-iconic" + label="&folderContextSettings2.label;" + accesskey="&folderContextSettings2.accesskey;"/> + <menuitem id="folderPaneContext-manageTags" + class="menuitem-iconic" + label="&manageTags.label;" + accesskey="&manageTags.accesskey;"/> + <menuseparator/> + </menupopup> + <tooltip id="qfb-text-search-upsell"> + <div id="qfb-upsell-line-one" + data-l10n-id="quick-filter-bar-gloda-upsell-line1"></div> + <div id="qfb-upsell-line-two"></div> + </tooltip> + <menupopup id="folderPaneMoreContext" + class="no-accel-menupopup" + position="bottomleft topleft" + onpopupshowing="folderPane.updateContextMenuCheckedItems();"> + <menu id="folderModesContextMenu" + data-l10n-id="folder-pane-header-folder-modes" + position="bottomleft topleft"> + <menupopup id="folderModesContextMenuPopup" + onpopupshowing="folderPane.updateContextCheckedFolderMode();"> + <menuitem id="folderPaneMoreContextAllFolders" + class="folder-pane-mode" + value="all" + type="checkbox" + closemenu="none" + data-l10n-id="show-all-folders-label" + oncommand="folderPane.toggleFolderMode(event);"/> + <menuitem id="folderPaneMoreContextUnifiedFolders" + class="folder-pane-mode" + value="smart" + type="checkbox" + closemenu="none" + data-l10n-id="show-smart-folders-label" + oncommand="folderPane.toggleFolderMode(event);"/> + <menuitem id="folderPaneMoreContextUnreadFolders" + class="folder-pane-mode" + value="unread" + type="checkbox" name="viewmessages" + closemenu="none" + data-l10n-id="show-unread-folders-label" + oncommand="folderPane.toggleFolderMode(event);"/> + <menuitem id="folderPaneMoreContextFavoriteFolders" + class="folder-pane-mode" + value="favorite" + type="checkbox" + closemenu="none" + data-l10n-id="show-favorite-folders-label" + oncommand="folderPane.toggleFolderMode(event);"/> + <menuitem id="folderPaneMoreContextRecentFolders" + class="folder-pane-mode" + value="recent" + type="checkbox" + closemenu="none" + data-l10n-id="show-recent-folders-label" + oncommand="folderPane.toggleFolderMode(event);"/> + <menuseparator/> + <menuitem id="folderPaneMoreContextTags" + class="folder-pane-mode" + value="tags" + type="checkbox" + closemenu="none" + data-l10n-id="show-tags-folders-label" + oncommand="folderPane.toggleFolderMode(event);"/> + <menuseparator id="separatorAfterFolderModes"/> + <menuitem id="folderPaneMoreContextCompactToggle" + class="compact-folder-button folder-pane-option" + value="compact" + type="checkbox" + closemenu="none" + data-l10n-id="folder-pane-mode-context-toggle-compact-mode" + oncommand="folderPane.compactFolderToggle(event);"/> + </menupopup> + </menu> + <menuseparator id="separatorAfterFolderViewOptions"/> + <menuitem id="folderPaneHeaderToggleGetMessages" + class="folder-pane-option" + type="checkbox" + closemenu="none" + data-l10n-id="folder-pane-header-context-toggle-get-messages" + oncommand="folderPane.toggleGetMsgsBtn(event);"/> + <menuitem id="folderPaneHeaderToggleNewMessage" + class="folder-pane-option" + type="checkbox" + closemenu="none" + data-l10n-id="folder-pane-header-context-toggle-new-message" + oncommand="folderPane.toggleNewMsgBtn(event);"/> + <menuseparator id="separatorAfterToggleButtons"/> + <menuitem id="folderPaneHeaderToggleTotalCount" + class="folder-pane-option" + value="total" + type="checkbox" + closemenu="none" + data-l10n-id="folder-pane-show-total-toggle" + oncommand="folderPane.toggleTotal(event);"/> + <menuitem id="folderPaneHeaderToggleFolderSize" + class="folder-pane-option" + type="checkbox" + closemenu="none" + data-l10n-id="folder-pane-header-toggle-folder-size" + oncommand="folderPane.toggleFolderSize(event);"/> + <menuitem id="folderPaneHeaderToggleLocalFolders" + class="folder-pane-option" + type="checkbox" + closemenu="none" + data-l10n-id="folder-pane-header-hide-local-folders" + oncommand="folderPane.toggleLocalFolders(event);"/> + <menuseparator id="separatorBeforeHideFolderPaneHeaderOption"/> + <menuitem id="folderPaneHeaderHideMenuItem" + data-l10n-id="folder-pane-header-context-hide" + oncommand="folderPane.toggleHeader(false);"/> + </menupopup> + <menupopup id="folderPaneModeContext" + class="no-accel-menupopup" + position="bottomleft topleft"> + <menuitem id="folderPaneModeMoveUp" + class="folder-pane-mode" + value="moveup" + data-l10n-id="folder-pane-mode-move-up" + oncommand="folderPane.moveFolderModeUp(event);"/> + <menuitem id="folderPaneModeMoveDown" + class="folder-pane-mode" + value="movedown" + data-l10n-id="folder-pane-mode-move-down" + oncommand="folderPane.moveFolderModeDown(event);"/> + <menuseparator id="separatorBeforeCompactFolderOption"/> + <menuitem id="compactFolderButton" + class="compact-folder-button folder-pane-mode" + value="compact" + type="checkbox" + data-l10n-id="folder-pane-mode-context-toggle-compact-mode" + oncommand="folderPane.compactFolderToggle(event);"/> + </menupopup> + <menupopup id="threadPaneDisplayContext" + class="no-accel-menupopup" + position="bottomleft topleft" + onpopupshowing="threadPaneHeader.updateDisplayContextMenu(event);"> + <menuitem id="threadPaneTableView" + class="thread-view-option" + type="radio" + name="threadview" + value="table" + closemenu="none" + data-l10n-id="thread-pane-header-context-table-view" + oncommand="threadPaneHeader.changePaneView(event);"/> + <menuitem id="threadPaneCardsView" + class="thread-view-option" + type="radio" + name="threadview" + value="cards" + closemenu="none" + data-l10n-id="thread-pane-header-context-cards-view" + oncommand="threadPaneHeader.changePaneView(event);"/> + <menuseparator id="separatorBeforeHideThreadHeaderOption"/> + <menu id="threadPaneSortMenu" + accesskey="&sortMenu.accesskey;" + label="&sortMenu.label;"> + <menupopup id="menu_threadPaneSortPopup" + oncommand="goDoCommand('cmd_sort', event);" + onpopupshowing="threadPaneHeader.updateThreadPaneSortMenu(event);"> + <menuitem id="threadPaneSortByDateMenuitem" + type="radio" + name="sortby" + value="byDate" + label="&sortByDateCmd.label;" + accesskey="&sortByDateCmd.accesskey;"/> + <menuitem id="threadPaneSortByReceivedMenuitem" + type="radio" + name="sortby" + value="byReceived" + label="&sortByReceivedCmd.label;" + accesskey="&sortByReceivedCmd.accesskey;"/> + <menuitem id="threadPaneSortByFlagMenuitem" + type="radio" + name="sortby" + value="byFlagged" + label="&sortByStarCmd.label;" + accesskey="&sortByStarCmd.accesskey;"/> + <menuitem id="threadPaneSortByOrderReceivedMenuitem" + type="radio" + name="sortby" + value="byId" + label="&sortByOrderReceivedCmd.label;" + accesskey="&sortByOrderReceivedCmd.accesskey;"/> + <menuitem id="threadPaneSortByPriorityMenuitem" + type="radio" + name="sortby" + value="byPriority" + label="&sortByPriorityCmd.label;" + accesskey="&sortByPriorityCmd.accesskey;"/> + <menuitem id="threadPaneSortByFromMenuitem" + type="radio" + name="sortby" + value="byAuthor" + label="&sortByFromCmd.label;" + accesskey="&sortByFromCmd.accesskey;"/> + <menuitem id="threadPaneSortByRecipientMenuitem" + type="radio" + name="sortby" + value="byRecipient" + label="&sortByRecipientCmd.label;" + accesskey="&sortByRecipientCmd.accesskey;"/> + <menuitem id="threadPaneSortByCorrespondentMenuitem" + type="radio" + name="sortby" + value="byCorrespondent" + label="&sortByCorrespondentCmd.label;" + accesskey="&sortByCorrespondentCmd.accesskey;"/> + <menuitem id="threadPaneSortBySizeMenuitem" + type="radio" + name="sortby" + value="bySize" + label="&sortBySizeCmd.label;" + accesskey="&sortBySizeCmd.accesskey;"/> + <menuitem id="threadPaneSortByStatusMenuitem" + type="radio" + name="sortby" + value="byStatus" + label="&sortByStatusCmd.label;" + accesskey="&sortByStatusCmd.accesskey;"/> + <menuitem id="threadPaneSortBySubjectMenuitem" + type="radio" + name="sortby" + value="bySubject" + label="&sortBySubjectCmd.label;" + accesskey="&sortBySubjectCmd.accesskey;"/> + <menuitem id="threadPaneSortByUnreadMenuitem" + type="radio" + name="sortby" + value="byUnread" + label="&sortByUnreadCmd.label;" + accesskey="&sortByUnreadCmd.accesskey;"/> + <menuitem id="threadPaneSortByTagsMenuitem" + type="radio" + name="sortby" + value="byTags" + label="&sortByTagsCmd.label;" + accesskey="&sortByTagsCmd.accesskey;"/> + <menuitem id="threadPaneSortByJunkStatusMenuitem" + type="radio" + name="sortby" + value="byJunkStatus" + label="&sortByJunkStatusCmd.label;" + accesskey="&sortByJunkStatusCmd.accesskey;"/> + <menuitem id="threadPaneSortByAttachmentsMenuitem" + type="radio" + name="sortby" + value="byAttachments" + label="&sortByAttachmentsCmd.label;" + accesskey="&sortByAttachmentsCmd.accesskey;"/> + <menuseparator id="threadPaneSortAfterAttachmentSeparator"/> + <menuitem id="threadPaneSortAscending" + type="radio" + name="sortdirection" + value="ascending" + label="&sortAscending.label;" + accesskey="&sortAscending.accesskey;"/> + <menuitem id="threadPaneSortDescending" + type="radio" + name="sortdirection" + value="descending" + label="&sortDescending.label;" + accesskey="&sortDescending.accesskey;"/> + <menuseparator id="threadPaneSortAfterDescendingSeparator"/> + <menuitem id="threadPaneSortThreaded" + type="radio" + name="threaded" + value="threaded" + label="&sortThreaded.label;" + accesskey="&sortThreaded.accesskey;"/> + <menuitem id="threadPaneSortUnthreaded" + type="radio" + name="threaded" + value="unthreaded" + label="&sortUnthreaded.label;" + accesskey="&sortUnthreaded.accesskey;"/> + <menuitem id="threadPaneGroupBySort" + type="checkbox" + name="group" + value="group" + label="&groupBySort.label;" + accesskey="&groupBySort.accesskey;"/> + </menupopup> + </menu> + <menuseparator id="separatorAfterSortOptions"/> + <menuitem data-l10n-id="thread-pane-header-context-hide" + oncommand="threadPaneHeader.toggleThreadPaneHeader();"/> + </menupopup> + <menupopup id="folderPaneGetMessagesContext" + class="no-accel-menupopup" + position="bottomleft topleft" + onpopupshowing="folderPane.updateGetMessagesContextMenu();"> + <menuitem id="itemGetAllNewMessages" + class="menuitem-iconic" + data-l10n-id="folder-pane-get-all-messages-menuitem" + oncommand="top.MsgGetMessagesForAllAuthenticatedAccounts();"/> + <menuseparator id="separatorAfterItemGetAllNewMessages"/> + </menupopup> +#include mailContext.inc.xhtml + <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete" + type="autocomplete" + role="group" + noautofocus="true"/> +</popupset> +</html> diff --git a/comm/mail/base/content/aboutAddonsExtra.js b/comm/mail/base/content/aboutAddonsExtra.js new file mode 100644 index 0000000000..1499d3927b --- /dev/null +++ b/comm/mail/base/content/aboutAddonsExtra.js @@ -0,0 +1,211 @@ +/* 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 ../../../../toolkit/mozapps/extensions/content/aboutaddons.js */ + +const THUNDERBIRD_THEME_PREVIEWS = new Map([ + [ + "thunderbird-compact-light@mozilla.org", + "resource://builtin-themes/light/preview.svg", + ], + [ + "thunderbird-compact-dark@mozilla.org", + "resource://builtin-themes/dark/preview.svg", + ], +]); + +var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm"); +ChromeUtils.defineESModuleGetters(this, { + ExtensionData: "resource://gre/modules/Extension.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "alternativeAddonSearchUrl", + "extensions.alternativeAddonSearch.url" +); + +(async function () { + window.MozXULElement.insertFTLIfNeeded("messenger/aboutAddonsExtra.ftl"); + // Needed for webext-perms-description-experiment. + window.MozXULElement.insertFTLIfNeeded("messenger/extensionPermissions.ftl"); + UIFontSize.registerWindow(window); + + // Consume clicks on a-tags and let openTrustedLinkIn() decide how to open them. + window.addEventListener("click", event => { + if (event.target.matches("a[href]") && event.target.href) { + let uri = Services.io.newURI(event.target.href); + if (uri.scheme == "http" || uri.scheme == "https") { + event.preventDefault(); + event.stopPropagation(); + windowRoot.ownerGlobal.openTrustedLinkIn(event.target.href, "tab"); + } + } + }); + + // Fix the "Search on addons.mozilla.org" placeholder text in the searchbox. + let textbox = document.querySelector("search-addons > search-textbox"); + document.l10n.setAttributes(textbox, "atn-addons-heading-search-input"); + + // Add our stylesheet. + let contentStylesheet = document.createElement("link"); + contentStylesheet.rel = "stylesheet"; + contentStylesheet.href = "chrome://messenger/skin/aboutAddonsExtra.css"; + document.head.appendChild(contentStylesheet); + + // Override logic for detecting unsigned add-ons. + window.isCorrectlySigned = function () { + return true; + }; + + // Load our theme screenshots. + let _getScreenshotUrlForAddon = getScreenshotUrlForAddon; + getScreenshotUrlForAddon = function (addon) { + if (THUNDERBIRD_THEME_PREVIEWS.has(addon.id)) { + return THUNDERBIRD_THEME_PREVIEWS.get(addon.id); + } + return _getScreenshotUrlForAddon(addon); + }; + + // Add logic to detect add-ons using the unsupported legacy API. + let getMozillaAddonMessageInfo = window.getAddonMessageInfo; + window.getAddonMessageInfo = async function (addon) { + const { name } = addon; + const { STATE_SOFTBLOCKED } = Ci.nsIBlocklistService; + + let data = new ExtensionData(addon.getResourceURI()); + await data.loadManifest(); + if ( + addon.type == "extension" && + (data.manifest.legacy || + (!addon.isCompatible && + (AddonManager.checkCompatibility || + addon.blocklistState !== STATE_SOFTBLOCKED))) + ) { + return { + linkText: await document.l10n.formatValue( + "add-on-search-alternative-button-label" + ), + linkUrl: `${alternativeAddonSearchUrl}?id=${encodeURIComponent( + addon.id + )}&q=${encodeURIComponent(name)}`, + messageId: "details-notification-incompatible", + messageArgs: { name, version: Services.appinfo.version }, + type: "warning", + }; + } + return getMozillaAddonMessageInfo(addon); + }; + document.querySelectorAll("addon-card").forEach(card => card.updateMessage()); + + // Override parts of the addon-card customElement to be able + // to add a dedicated button for extension preferences. + await customElements.whenDefined("addon-card"); + AddonCard.prototype.addOptionsButton = async function () { + let { addon, optionsButton } = this; + if (addon.type != "extension") { + return; + } + + let addonOptionsButton = this.querySelector(".extension-options-button"); + if (!addonOptionsButton) { + addonOptionsButton = document.createElement("button"); + addonOptionsButton.classList.add("extension-options-button"); + addonOptionsButton.setAttribute("action", "preferences"); + document.l10n.setAttributes(addonOptionsButton, "add-on-options-button"); + addonOptionsButton.disabled = true; + optionsButton.parentNode.insertBefore(addonOptionsButton, optionsButton); + } + + // Upon fresh install the manifest has not been parsed and optionsType + // is not known, manually trigger parsing. + if (addon.isActive && !addon.optionsType) { + let data = new ExtensionData(addon.getResourceURI()); + await data.loadManifest(); + } + + addonOptionsButton.disabled = !(addon.isActive && addon.optionsType); + }; + AddonCard.prototype._update = AddonCard.prototype.update; + AddonCard.prototype.update = function () { + this._update(); + this.addOptionsButton(); + }; + + // Override parts of the addon-permission-list customElement to be able + // to show the usage of Experiments in the permission list. + await customElements.whenDefined("addon-permissions-list"); + AddonPermissionsList.prototype.renderExperimentOnly = function () { + this.textContent = ""; + let frag = importTemplate("addon-permissions-list"); + let section = frag.querySelector(".addon-permissions-required"); + section.hidden = false; + let list = section.querySelector(".addon-permissions-list"); + + let item = document.createElement("li"); + document.l10n.setAttributes(item, "webext-perms-description-experiment"); + item.classList.add("permission-info", "permission-checked"); + list.appendChild(item); + + this.appendChild(frag); + }; + // We change this function from sync to async, which does not matter. + // It calls this.render() which is async without awaiting it anyway. + AddonPermissionsList.prototype.setAddon = async function (addon) { + this.addon = addon; + let data = new ExtensionData(addon.getResourceURI()); + await data.loadManifest(); + if (data.manifest.experiment_apis) { + this.renderExperimentOnly(); + } else { + this.render(); + } + }; + + await customElements.whenDefined("recommended-addon-card"); + RecommendedAddonCard.prototype._setCardContent = + RecommendedAddonCard.prototype.setCardContent; + RecommendedAddonCard.prototype.setCardContent = function (card, addon) { + this._setCardContent(card, addon); + card.addEventListener("click", event => { + if (event.target.matches("a[href]") || event.target.matches("button")) { + return; + } + windowRoot.ownerGlobal.openTrustedLinkIn( + card.querySelector(".disco-addon-author a").href, + "tab" + ); + }); + }; + + await customElements.whenDefined("search-addons"); + SearchAddons.prototype.searchAddons = function (query) { + if (query.length === 0) { + return; + } + + let url = new URL( + formatUTMParams( + "addons-manager-search", + AddonRepository.getSearchURL(query) + ) + ); + + // Limit search to themes, if the themes section is currently active. + if ( + document.getElementById("page-header").getAttribute("type") == "theme" + ) { + url.searchParams.set("cat", "themes"); + } + + let browser = getBrowserElement(); + let chromewin = browser.ownerGlobal; + chromewin.openLinkIn(url.href, "tab", { + fromChrome: true, + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + }); + }; +})(); diff --git a/comm/mail/base/content/aboutDialog-appUpdater.js b/comm/mail/base/content/aboutDialog-appUpdater.js new file mode 100644 index 0000000000..36bbc6a3e6 --- /dev/null +++ b/comm/mail/base/content/aboutDialog-appUpdater.js @@ -0,0 +1,320 @@ +/* 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/. */ + +// Note: this file is included in aboutDialog.xhtml and preferences/advanced.xhtml +// if MOZ_UPDATER is defined. + +/* import-globals-from aboutDialog.js */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs", + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "AUS", + "@mozilla.org/updates/update-service;1", + "nsIApplicationUpdateService" +); + +var UPDATING_MIN_DISPLAY_TIME_MS = 1500; + +var gAppUpdater; + +function onUnload(aEvent) { + if (gAppUpdater) { + gAppUpdater.destroy(); + gAppUpdater = null; + } +} + +function appUpdater(options = {}) { + this._appUpdater = new AppUpdater(); + + this._appUpdateListener = (status, ...args) => { + this._onAppUpdateStatus(status, ...args); + }; + this._appUpdater.addListener(this._appUpdateListener); + + this.options = options; + this.updatingMinDisplayTimerId = null; + this.updateDeck = document.getElementById("updateDeck"); + + this.bundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + + try { + let manualURL = new URL( + Services.urlFormatter.formatURLPref("app.update.url.manual") + ); + + for (const manualLink of document.getElementsByClassName("manualLink")) { + // Strip hash and search parameters for display text. + manualLink.textContent = manualURL.origin + manualURL.pathname; + manualLink.href = manualURL.href; + } + + document.getElementById("failedLink").href = manualURL.href; + } catch (e) { + console.error("Invalid manual update url.", e); + } + + this._appUpdater.check(); +} + +appUpdater.prototype = { + destroy() { + this.stopCurrentCheck(); + if (this.updatingMinDisplayTimerId) { + clearTimeout(this.updatingMinDisplayTimerId); + } + }, + + stopCurrentCheck() { + this._appUpdater.removeListener(this._appUpdateListener); + this._appUpdater.stop(); + }, + + get update() { + return this._appUpdater.update; + }, + + get selectedPanel() { + return this.updateDeck.selectedPanel; + }, + + _onAppUpdateStatus(status, ...args) { + switch (status) { + case AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY: + this.selectPanel("policyDisabled"); + break; + case AppUpdater.STATUS.READY_FOR_RESTART: + this.selectPanel("apply"); + break; + case AppUpdater.STATUS.OTHER_INSTANCE_HANDLING_UPDATES: + this.selectPanel("otherInstanceHandlingUpdates"); + break; + case AppUpdater.STATUS.DOWNLOADING: { + let downloadStatus = document.getElementById("downloadStatus"); + if (!args.length) { + // Very early in the DOWNLOADING state, `selectedPatch` may not be + // available yet. But this function will be called again when it is + // available. A `maxSize < 0` indicates that the max size is not yet + // available. + let maxSize = -1; + if (this.update.selectedPatch) { + maxSize = this.update.selectedPatch.size; + } + downloadStatus.textContent = DownloadUtils.getTransferTotal( + 0, + maxSize + ); + this.selectPanel("downloading"); + } else { + let [progress, max] = args; + downloadStatus.textContent = DownloadUtils.getTransferTotal( + progress, + max + ); + } + break; + } + case AppUpdater.STATUS.STAGING: + this.selectPanel("applying"); + break; + case AppUpdater.STATUS.CHECKING: { + this.checkingForUpdatesDelayPromise = new Promise(resolve => { + this.updatingMinDisplayTimerId = setTimeout( + resolve, + UPDATING_MIN_DISPLAY_TIME_MS + ); + }); + if (Services.policies.isAllowed("appUpdate")) { + this.selectPanel("checkingForUpdates"); + } else { + this.selectPanel("policyDisabled"); + } + break; + } + case AppUpdater.STATUS.CHECKING_FAILED: + this.selectPanel("checkingFailed"); + break; + case AppUpdater.STATUS.NO_UPDATES_FOUND: + this.checkingForUpdatesDelayPromise.then(() => { + if (Services.policies.isAllowed("appUpdate")) { + this.selectPanel("noUpdatesFound"); + } else { + this.selectPanel("policyDisabled"); + } + }); + break; + case AppUpdater.STATUS.UNSUPPORTED_SYSTEM: + if (this.update.detailsURL) { + let unsupportedLink = document.getElementById("unsupportedLink"); + unsupportedLink.href = this.update.detailsURL; + } + this.selectPanel("unsupportedSystem"); + break; + case AppUpdater.STATUS.MANUAL_UPDATE: + this.selectPanel("manualUpdate"); + break; + case AppUpdater.STATUS.DOWNLOAD_AND_INSTALL: + this.selectPanel("downloadAndInstall"); + break; + case AppUpdater.STATUS.DOWNLOAD_FAILED: + this.selectPanel("downloadFailed"); + break; + case AppUpdater.STATUS.INTERNAL_ERROR: + this.selectPanel("internalError"); + break; + case AppUpdater.STATUS.NEVER_CHECKED: + this.selectPanel("checkForUpdates"); + break; + case AppUpdater.STATUS.NO_UPDATER: + default: + this.selectPanel("noUpdater"); + document.getElementById("updateBox").style.maxHeight = "0"; + break; + } + }, + + /** + * Sets the panel of the updateDeck and the visibility of icons + * in the #icons element. + * + * @param aChildID + * The id of the deck's child to select, e.g. "apply". + */ + selectPanel(aChildID) { + let panel = document.getElementById(aChildID); + let icons = document.getElementById("icons"); + if (icons) { + icons.className = aChildID; + } + + // Make sure to select the panel before potentially auto-focusing the button. + this.updateDeck.selectedPanel = panel; + + let button = panel.querySelector("button"); + if (button) { + if (aChildID == "downloadAndInstall") { + let updateVersion = gAppUpdater.update.displayVersion; + // Include the build ID if this is an "a#" (nightly or aurora) build + if (/a\d+$/.test(updateVersion)) { + let buildID = gAppUpdater.update.buildID; + let year = buildID.slice(0, 4); + let month = buildID.slice(4, 6); + let day = buildID.slice(6, 8); + updateVersion += ` (${year}-${month}-${day})`; + } else { + let updateNotesLink = document.getElementById("updateNotes"); + if (updateNotesLink) { + updateNotesLink.href = gAppUpdater.update.detailsURL; + updateNotesLink.hidden = false; + } + } + button.textContent = this.bundle.formatStringFromName( + "update.downloadAndInstallButton.label", + [updateVersion] + ); + button.accessKey = this.bundle.GetStringFromName( + "update.downloadAndInstallButton.accesskey" + ); + } + if (this.options.buttonAutoFocus) { + let promise = Promise.resolve(); + if (document.readyState != "complete") { + promise = new Promise(resolve => + window.addEventListener("load", resolve, { once: true }) + ); + } + promise.then(() => { + if ( + !document.commandDispatcher.focusedElement || // don't steal the focus + // except from the other buttons + document.commandDispatcher.focusedElement.localName == "button" + ) { + button.focus(); + } + }); + } + } + }, + + /** + * Check for updates + */ + checkForUpdates() { + this._appUpdater.check(); + }, + + /** + * Handles oncommand for the "Restart to Update" button + * which is presented after the download has been downloaded. + */ + buttonRestartAfterDownload() { + if (AUS.currentState != Ci.nsIApplicationUpdateService.STATE_PENDING) { + return; + } + + gAppUpdater.selectPanel("restarting"); + + // Notify all windows that an application quit has been requested. + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + + // Something aborted the quit process. + if (cancelQuit.data) { + gAppUpdater.selectPanel("apply"); + return; + } + + // If already in safe mode restart in safe mode (bug 327119) + if (Services.appinfo.inSafeMode) { + Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + return; + } + + if ( + !Services.startup.quit( + Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart + ) + ) { + // Either the user or the hidden window aborted the quit process. + gAppUpdater.selectPanel("apply"); + } + }, + + /** + * Starts the download of an update mar. + */ + startDownload() { + this._appUpdater.allowUpdateDownload(); + }, +}; + +window.addEventListener("load", () => { + let protocolSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + for (let link of document.querySelectorAll(".download-link")) { + link.addEventListener("click", event => { + event.preventDefault(); + protocolSvc.loadURI(Services.io.newURI(event.target.href)); + }); + } +}); diff --git a/comm/mail/base/content/aboutDialog.css b/comm/mail/base/content/aboutDialog.css new file mode 100644 index 0000000000..36a6332030 --- /dev/null +++ b/comm/mail/base/content/aboutDialog.css @@ -0,0 +1,187 @@ +/* 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/. */ + +:root { + --dialog-background: #fefefe; + --dialog-box-color: #222; + --client-box-background: #f7f7f7; + --link-color: -moz-nativehyperlinktext; + --link-decoration: inherit; +} + +@media (prefers-color-scheme: dark) { + :root { + --dialog-background: #222; + --dialog-box-color: #f7f7f7; + --client-box-background: #444; + --link-color: #f7f7f7; + --link-decoration: underline; + } +} + +@media (prefers-contrast) { + :root { + --dialog-background: -moz-Dialog; + --dialog-box-color: -moz-DialogText; + --client-box-background: -moz-Dialog; + --link-color: -moz-nativehyperlinktext; + --link-decoration: inherit; + } +} + +body { + margin: 0; + overflow: hidden; +} + +#aboutDialog { + /* Set an explicit line-height to avoid discrepancies in 'auto' spacing + across screens with different device DPI, which may cause font metrics + to round differently. */ + line-height: 1.5; +} + +#aboutDialogContainer { + width: 670px; + color: var(--dialog-box-color); + background-color: var(--dialog-background); +} + +#rightBox { + background-image: url("chrome://branding/content/about-wordmark.svg"); + background-repeat: no-repeat; + /* padding-top creates room for the wordmark */ + padding-top: 38px; + margin-top: 20px; + margin-inline: 30px; + -moz-context-properties: fill; + fill: currentColor; +} + +#detailsBox { + padding-top: 10px; +} + +#updateDeck { + align-items: center; + min-height: 33px; +} + +.update-throbber { + width: 16px; + min-height: 16px; + margin-inline-end: 3px; + content: image-set(url("chrome://global/skin/icons/loading.png"), + url("chrome://global/skin/icons/loading@2x.png") 2x); + vertical-align: middle; +} + +#rightBox:-moz-locale-dir(rtl) { + background-position: 100% 0; +} + +#bottomBox { + padding: 15px 10px 0; +} + +#version { + font-weight: bold; + margin-top: 10px; + margin-inline-start: 0; + user-select: text; + -moz-user-focus: normal; + cursor: text; +} + +#releasenotes { + margin-inline-start: 0.5em; +} + +#distribution, +#distributionId { + display: none; + margin-block: 0; +} + +.text-blurb { + margin-bottom: 10px; + margin-inline-start: 0; + padding-inline-start: 0; +} + +.update-deck-container { + display: flex; + align-items: center; + position: fixed; +} + +.update-deck-container > * { + flex: 0 0 fit-content; +} + +.update-deck-container.deck-selected { + visibility: visible; +} + +.update-deck-container span { + font-style: italic; +} + +.update-deck-container span > a { + font-style: normal; +} + +.update-throbber { + width: 16px; + height: 16px; + margin-inline-end: 3px; +} + +.trademark-label, +.text-link, +.text-link:focus { + margin: 0; + padding: 0; +} + +.bottom-link, +.bottom-link:focus { + text-align: center; + margin: 0 40px; +} + +.text-link { + color: var(--link-color); +} + +.text-link:not(:hover) { + text-decoration: var(--link-decoration); +} + +#updateNotes { + margin-inline-start: 5px; +} + +#currentChannel { + margin: 0; + padding: 0; + font-weight: bold; +} + +#icons > .icon { + -moz-context-properties: fill; + margin: 10px 5px; + width: 16px; + height: 16px; +} + +#icons:not(.checkingForUpdates, .downloading, .applying, .restarting) > .update-throbber, +#icons:not(.noUpdatesFound) > .noUpdatesFound, +#icons:not(.apply) > .apply { + display: none; +} + +#icons > .noUpdatesFound { + fill: #16a34a; +} diff --git a/comm/mail/base/content/aboutDialog.js b/comm/mail/base/content/aboutDialog.js new file mode 100644 index 0000000000..74af77866f --- /dev/null +++ b/comm/mail/base/content/aboutDialog.js @@ -0,0 +1,155 @@ +/* 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 aboutDialog-appUpdater.js */ + +"use strict"; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +if (AppConstants.MOZ_UPDATER) { + Services.scriptloader.loadSubScript( + "chrome://messenger/content/aboutDialog-appUpdater.js", + this + ); +} + +window.addEventListener("DOMContentLoaded", onLoad); +if (AppConstants.MOZ_UPDATER) { + // This method is in the aboutDialog-appUpdater.js file. + window.addEventListener("unload", onUnload); +} + +function onLoad(event) { + if (event.target !== document) { + return; + } + + let defaults = Services.prefs.getDefaultBranch(null); + let distroId = defaults.getCharPref("distribution.id", ""); + if (distroId) { + let distroAbout = defaults.getStringPref("distribution.about", ""); + // If there is about text, we always show it. + if (distroAbout) { + let distroField = document.getElementById("distribution"); + distroField.innerText = distroAbout; + distroField.style.display = "block"; + } + // If it's not a mozilla distribution, show the rest, + // unless about text exists, then we always show. + if (!distroId.startsWith("mozilla-") || distroAbout) { + let distroVersion = defaults.getCharPref("distribution.version", ""); + if (distroVersion) { + distroId += " - " + distroVersion; + } + + let distroIdField = document.getElementById("distributionId"); + distroIdField.innerText = distroId; + distroIdField.style.display = "block"; + } + } + + // Include the build ID and display warning if this is an "a#" (nightly or aurora) build + let versionId = "aboutDialog-version"; + let versionAttributes = { + version: AppConstants.MOZ_APP_VERSION_DISPLAY, + bits: Services.appinfo.is64Bit ? 64 : 32, + }; + + let version = Services.appinfo.version; + if (/a\d+$/.test(version)) { + versionId = "aboutDialog-version-nightly"; + let buildID = Services.appinfo.appBuildID; + let year = buildID.slice(0, 4); + let month = buildID.slice(4, 6); + let day = buildID.slice(6, 8); + versionAttributes.isodate = `${year}-${month}-${day}`; + + document.getElementById("experimental").hidden = false; + document.getElementById("communityDesc").hidden = true; + } + + // Use Fluent arguments for append version and the architecture of the build + let versionField = document.getElementById("version"); + + document.l10n.setAttributes(versionField, versionId, versionAttributes); + + if (!AppConstants.NIGHTLY_BUILD) { + // Show a release notes link if we have a URL. + let relNotesLink = document.getElementById("releasenotes"); + let relNotesPrefType = Services.prefs.getPrefType("app.releaseNotesURL"); + if (relNotesPrefType != Services.prefs.PREF_INVALID) { + let relNotesURL = Services.urlFormatter.formatURLPref( + "app.releaseNotesURL" + ); + if (relNotesURL != "about:blank") { + relNotesLink.href = relNotesURL; + relNotesLink.hidden = false; + } + } + } + + if (AppConstants.MOZ_UPDATER) { + gAppUpdater = new appUpdater({ buttonAutoFocus: true }); + + let channelLabel = document.getElementById("currentChannelText"); + let channelAttrs = document.l10n.getAttributes(channelLabel); + let channel = UpdateUtils.UpdateChannel; + document.l10n.setAttributes(channelLabel, channelAttrs.id, { channel }); + if ( + /^release($|\-)/.test(channel) || + Services.sysinfo.getProperty("isPackagedApp") + ) { + channelLabel.hidden = true; + } + } + + // Open external links in browser + for (const link of document.getElementsByClassName("browser-link")) { + link.onclick = event => { + event.preventDefault(); + openLink(event.target.href); + }; + } + // Open internal (about:) links open in Thunderbird tab + for (const link of document.getElementsByClassName("tab-link")) { + link.onclick = event => { + event.preventDefault(); + openAboutTab(event.target.href); + }; + } +} + +// This function is used to open about: tabs. The caller should ensure the url +// is only an about: url. +function openAboutTab(url) { + // Check existing windows + let mailWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (mailWindow) { + mailWindow.focus(); + mailWindow.document + .getElementById("tabmail") + .openTab("contentTab", { url }); + return; + } + + // No existing windows. + window.openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,dialog=no,all", + null, + { + tabType: "contentTab", + tabParams: { url }, + } + ); +} + +function openLink(url) { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(url)); +} diff --git a/comm/mail/base/content/aboutDialog.xhtml b/comm/mail/base/content/aboutDialog.xhtml new file mode 100644 index 0000000000..55c7330f03 --- /dev/null +++ b/comm/mail/base/content/aboutDialog.xhtml @@ -0,0 +1,184 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +#filter substitution + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/content/aboutDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://branding/content/aboutDialog.css" type="text/css"?> + +<!DOCTYPE html> +<html id="aboutDialog" xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + role="dialog" + windowtype="mail:about"> + +<head> + <title data-l10n-id="about-dialog-title"></title> + <link rel="localization" href="branding/brand.ftl"/> + <link rel="localization" href="messenger/aboutDialog.ftl"/> + <script defer="true" src="chrome://messenger/content/aboutDialog.js"></script> +</head> +<body aria-describedby="version distribution distributionId currentChannelText communityDesc contributeDesc trademark"> + <xul:keyset id="mainKeyset"> + <xul:key keycode="VK_ESCAPE" oncommand="window.close();"/> +#ifdef XP_MACOSX + <xul:key id="key_close" modifiers="accel" data-l10n-id="cmd-close-mac-command-key" + oncommand="window.close();"/> +#endif + </xul:keyset> + <div id="aboutDialogContainer"> + <xul:hbox id="clientBox"> + <xul:vbox id="leftBox" flex="1"/> + <xul:vbox id="rightBox"> + <xul:hbox align="baseline"> + <span id="version"></span> + <a id="releasenotes" class="text-link browser-link" hidden="hidden" + data-l10n-id="release-notes-link"></a> + </xul:hbox> + + <img src="chrome://messenger/skin/icons/new/supernova-logo.webp" alt="" id="supernova-logo"/> + + <span id="distribution" class="text-blurb"></span> + <span id="distributionId" class="text-blurb"></span> + + <xul:vbox id="detailsBox"> + <xul:hbox id="updateBox"> +#ifdef MOZ_UPDATER + <div id="icons"> + <img class="icon update-throbber" role="presentation"/> + <img class="icon noUpdatesFound" src="chrome://global/skin/icons/check.svg" role="presentation"/> + <img class="icon apply" src="chrome://global/skin/icons/reload.svg" role="presentation"/> + </div> + <xul:vbox> + <xul:deck id="updateDeck" orient="vertical"> + <div id="checkForUpdates" class="update-deck-container"> + <button id="checkForUpdatesButton" + data-l10n-id="update-check-for-updates-button" + onclick="gAppUpdater.checkForUpdates();"> + </button> + </div> + <div id="downloadAndInstall" class="update-deck-container"> + <button id="downloadAndInstallButton" + onclick="gAppUpdater.startDownload();"> + </button> + <a id="updateNotes" class="text-link browser-link" hidden="hidden" + data-l10n-id="about-update-whats-new"></a> + </div> + <div id="apply" class="update-deck-container"> + <button id="updateButton" + data-l10n-id="update-update-button" + onclick="gAppUpdater.buttonRestartAfterDownload();"> + </button> + </div> + <div id="checkingForUpdates" class="update-deck-container"> + <span data-l10n-id="update-checking-for-updates"></span> + </div> + <div id="downloading" class="update-deck-container" data-l10n-id="update-downloading-message"> + <span id="downloadStatus" data-l10n-name="download-status"></span> + </div> + <div id="applying" class="update-deck-container"> + <span data-l10n-id="update-applying"></span> + </div> + <div id="downloadFailed" class="update-deck-container"> + <!-- Outer span ensures whitespace between the plain text and + - the link. Otherwise, this would be suppressed by the + - update-deck-container's display: flex. --> + <span data-l10n-id="update-failed"> + <a id="failedLink" data-l10n-name="failed-link" + class="text-link browser-link"></a> + </span> + </div> + <div id="policyDisabled" class="update-deck-container"> + <span data-l10n-id="update-admin-disabled"></span> + </div> + <div id="noUpdatesFound" class="update-deck-container"> + <span data-l10n-id="update-no-updates-found"></span> + </div> + <div id="checkingFailed" class="update-deck-container"> + <span data-l10n-id="aboutdialog-update-checking-failed"></span> + </div> + <div id="otherInstanceHandlingUpdates" class="update-deck-container"> + <span data-l10n-id="update-other-instance-handling-updates"></span> + </div> + <div id="manualUpdate" class="update-deck-container"> + <span data-l10n-id="update-manual"> + <a id="manualLink" data-l10n-name="manual-link" + class="manualLink text-link browser-link"></a> + </span> + </div> + <div id="unsupportedSystem" class="update-deck-container"> + <span data-l10n-id="update-unsupported"> + <a id="unsupportedLink" data-l10n-name="unsupported-link" + class="manualLink text-link browser-link"></a> + </span> + </div> + <div id="restarting" class="update-deck-container"> + <span data-l10n-id="update-restarting"></span> + </div> + <div id="internalError" class="update-deck-container"> + <span data-l10n-id="update-internal-error"> + <a id="internalErrorLink" data-l10n-name="manual-link" + class="manualLink text-link browser-link"></a> + </span> + </div> + <div id="noUpdater" class="update-deck-container"></div> + </xul:deck> + <!-- This HBOX is duplicated above without class="update" --> + <xul:hbox align="baseline"> + <span id="version" class="update"></span> + <a id="releasenotes" class="text-link browser-link" hidden="hidden" + data-l10n-id="release-notes-link"></a> + </xul:hbox> + </xul:vbox> +#endif + </xul:hbox> + +#ifdef MOZ_UPDATER + <div class="text-blurb" id="currentChannelText" data-l10n-id="channel-description" + data-l10n-args='{"channel": ""}' + data-l10n-attrs="{"channel": ""}"> + <span id="currentChannel" data-l10n-name="current-channel"></span> + </div> +#endif + <xul:vbox id="experimental" hidden="true"> + <div class="text-blurb" id="warningDesc"> + <span data-l10n-id="warning-desc-version"></span> +#ifdef MOZ_TELEMETRY_ON_BY_DEFAULT + <span data-l10n-id="warning-desc-telemetry"></span> +#endif + </div> + <div class="text-blurb" id="communityExperimentalDesc" data-l10n-id="community-experimental"> + <a class="text-link browser-link" href="https://www.mozilla.org/" + data-l10n-name="community-exp-mozilla-link"></a> + <a class="text-link tab-link" href="about:credits" + data-l10n-name="community-exp-credits-link"></a> + </div> + </xul:vbox> + <div class="text-blurb" id="communityDesc" data-l10n-id="community-desc"> + <a class="text-link browser-link" href="https://www.mozilla.org/" data-l10n-name="community-mozilla-link"></a> + <a class="text-link tab-link" href="about:credits" data-l10n-name="community-credits-link"></a> + </div> + <div class="text-blurb" id="contributeDesc" data-l10n-id="about-donation"> + <a class="text-link browser-link" href="https://give.thunderbird.net/?utm_source=thunderbird-client&utm_medium=referral&utm_content=about-dialog" + data-l10n-name="helpus-donate-link"></a> + <a class="text-link browser-link" href="https://www.thunderbird.net/get-involved/" + data-l10n-name="helpus-get-involved-link"></a> + </div> + </xul:vbox> + </xul:vbox> + </xul:hbox> + <xul:vbox id="bottomBox"> + <xul:hbox pack="center"> + <a class="text-link bottom-link tab-link" href="about:license" data-l10n-id="bottom-links-license"></a> + <a class="text-link bottom-link tab-link" href="about:rights" data-l10n-id="bottom-links-rights"></a> + <a class="text-link bottom-link browser-link" href="https://www.mozilla.org/privacy/thunderbird/" + data-l10n-id="bottom-links-privacy"></a> + </xul:hbox> + <span id="trademark" data-l10n-id="trademarkInfo"></span> + </xul:vbox> + </div> +</body> +</html> diff --git a/comm/mail/base/content/aboutMessage.js b/comm/mail/base/content/aboutMessage.js new file mode 100644 index 0000000000..29ce47ba4d --- /dev/null +++ b/comm/mail/base/content/aboutMessage.js @@ -0,0 +1,617 @@ +/* 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/. */ + +/* globals Enigmail, MailE10SUtils */ + +// mailCommon.js +/* globals commandController, DBViewWrapper, dbViewWrapperListener, + nsMsgViewIndex_None, TreeSelection */ +/* globals gDBView: true, gFolder: true, gViewWrapper: true */ + +// mailContext.js +/* globals mailContextMenu */ + +// msgHdrView.js +/* globals AdjustHeaderView ClearCurrentHeaders ClearPendingReadTimer + HideMessageHeaderPane OnLoadMsgHeaderPane OnTagsChange + OnUnloadMsgHeaderPane HandleAllAttachments AttachmentMenuController */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + UIDensity: "resource:///modules/UIDensity.jsm", + UIFontSize: "resource:///modules/UIFontSize.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +const messengerBundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" +); + +var gMessage, gMessageURI; +var autodetectCharset; + +function getMessagePaneBrowser() { + return document.getElementById("messagepane"); +} + +function messagePaneOnResize() { + const doc = getMessagePaneBrowser().contentDocument; + // Bail out if it's http content or we don't have images. + if (doc?.URL.startsWith("http") || !doc?.images) { + return; + } + + for (let img of doc.images) { + img.toggleAttribute( + "overflowing", + img.clientWidth - doc.body.offsetWidth >= 0 && + (img.clientWidth <= img.naturalWidth || !img.naturalWidth) + ); + } +} + +function ReloadMessage() { + if (!gMessageURI) { + return; + } + displayMessage(gMessageURI, gViewWrapper); +} + +function MailSetCharacterSet() { + let messageService = MailServices.messageServiceFromURI(gMessageURI); + gMessage = messageService.messageURIToMsgHdr(gMessageURI); + messageService.loadMessage( + gMessageURI, + getMessagePaneBrowser().docShell, + top.msgWindow, + null, + true + ); + autodetectCharset = true; +} + +window.addEventListener("DOMContentLoaded", event => { + if (event.target != document) { + return; + } + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); + + OnLoadMsgHeaderPane(); + + Enigmail.msg.messengerStartup(); + Enigmail.hdrView.hdrViewLoad(); + + MailServices.mailSession.AddFolderListener( + folderListener, + Ci.nsIFolderListener.removed + ); + + preferenceObserver.init(); + Services.obs.addObserver(msgObserver, "message-content-updated"); + + const browser = getMessagePaneBrowser(); + + if (parent == top) { + // Standalone message display? Focus the message pane. + browser.focus(); + } + + if (window.parent == window.top) { + mailContextMenu.init(); + } + + // There might not be a msgWindow variable on the top window + // if we're e.g. showing a message in a dedicated window. + if (top.msgWindow) { + // Necessary plumbing to communicate status updates back to + // the user. + browser.docShell + ?.QueryInterface(Ci.nsIWebProgress) + .addProgressListener( + top.msgWindow.statusFeedback, + Ci.nsIWebProgress.NOTIFY_ALL + ); + } + + window.dispatchEvent( + new CustomEvent("aboutMessageLoaded", { bubbles: true }) + ); +}); + +window.addEventListener("unload", () => { + ClearPendingReadTimer(); + OnUnloadMsgHeaderPane(); + MailServices.mailSession.RemoveFolderListener(folderListener); + preferenceObserver.cleanUp(); + Services.obs.removeObserver(msgObserver, "message-content-updated"); + gViewWrapper?.close(); +}); + +function displayMessage(uri, viewWrapper) { + // Clear the state flags, if this window is re-used. + window.msgLoaded = false; + window.msgLoading = false; + + // Clean up existing objects before starting again. + ClearPendingReadTimer(); + gMessage = null; + if (gViewWrapper && viewWrapper != gViewWrapper) { + // Don't clean up gViewWrapper if we're going to reuse it. If we're inside + // about:3pane, close the view wrapper, but don't call `onLeavingFolder`, + // because about:3pane will do that if we're actually leaving the folder. + gViewWrapper?.close(parent != top); + gViewWrapper = null; + } + gDBView = null; + + gMessageURI = uri; + ClearCurrentHeaders(); + + if (!uri) { + HideMessageHeaderPane(); + MailE10SUtils.loadAboutBlank(getMessagePaneBrowser()); + window.msgLoaded = true; + window.dispatchEvent( + new CustomEvent("messageURIChanged", { bubbles: true, detail: uri }) + ); + return; + } + + let messageService = MailServices.messageServiceFromURI(uri); + gMessage = messageService.messageURIToMsgHdr(uri); + gFolder = gMessage.folder; + + messageHistory.push(uri); + + if (gFolder) { + if (viewWrapper) { + if (viewWrapper != gViewWrapper) { + gViewWrapper = viewWrapper.clone(dbViewWrapperListener); + } + } else { + gViewWrapper = new DBViewWrapper(dbViewWrapperListener); + gViewWrapper._viewFlags = Ci.nsMsgViewFlagsType.kThreadedDisplay; + gViewWrapper.open(gFolder); + } + } else { + gViewWrapper = new DBViewWrapper(dbViewWrapperListener); + gViewWrapper.openSearchView(); + } + gDBView = gViewWrapper.dbView; + let selection = (gDBView.selection = new TreeSelection()); + selection.view = gDBView; + let index = gDBView.findIndexOfMsgHdr(gMessage, true); + selection.select(index == nsMsgViewIndex_None ? -1 : index); + gDBView?.setJSTree({ + QueryInterface: ChromeUtils.generateQI(["nsIMsgJSTree"]), + _inBatch: false, + beginUpdateBatch() { + this._inBatch = true; + }, + endUpdateBatch() { + this._inBatch = false; + }, + ensureRowIsVisible(index) {}, + invalidate() {}, + invalidateRange(startIndex, endIndex) {}, + rowCountChanged(index, count) { + let wasSuppressed = gDBView.selection.selectEventsSuppressed; + gDBView.selection.selectEventsSuppressed = true; + gDBView.selection.adjustSelection(index, count); + gDBView.selection.selectEventsSuppressed = wasSuppressed; + }, + currentIndex: null, + }); + + if (gMessage.flags & Ci.nsMsgMessageFlags.HasRe) { + document.title = `Re: ${gMessage.mime2DecodedSubject || ""}`; + } else { + document.title = gMessage.mime2DecodedSubject; + } + + let browser = getMessagePaneBrowser(); + const browserChanged = MailE10SUtils.changeRemoteness(browser, null); + // The message pane browser should inherit `docShellIsActive` from the + // about:message browser, but changing remoteness causes that to not happen. + browser.docShellIsActive = !document.hidden; + browser.docShell.allowAuth = false; + browser.docShell.allowDNSPrefetch = false; + + if (browserChanged) { + browser.docShell + ?.QueryInterface(Ci.nsIWebProgress) + .addProgressListener( + top.msgWindow.statusFeedback, + Ci.nsIWebProgress.NOTIFY_ALL + ); + } + + if (gMessage.flags & Ci.nsMsgMessageFlags.Partial) { + document.body.classList.add("partial-message"); + } else if (document.body.classList.contains("partial-message")) { + document.body.classList.remove("partial-message"); + document.body.classList.add("completed-message"); + } + + // @implements {nsIUrlListener} + let urlListener = { + OnStartRunningUrl(url) {}, + OnStopRunningUrl(url, status) { + window.msgLoading = true; + window.dispatchEvent( + new CustomEvent("messageURIChanged", { bubbles: true, detail: uri }) + ); + if (url instanceof Ci.nsIMsgMailNewsUrl && url.seeOtherURI) { + // Show error page if needed. + HideMessageHeaderPane(); + MailE10SUtils.loadURI(getMessagePaneBrowser(), url.seeOtherURI); + } + }, + }; + try { + messageService.loadMessage( + uri, + browser.docShell, + top.msgWindow, + urlListener, + false + ); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_OFFLINE) { + throw ex; + } + + // TODO: This should be replaced with a real page, and made not ugly. + let title = messengerBundle.GetStringFromName("nocachedbodytitle"); + // This string includes some HTML! Get rid of it. + title = title.replace(/<\/?title>/gi, ""); + let body = messengerBundle.GetStringFromName("nocachedbodybody2"); + HideMessageHeaderPane(); + MailE10SUtils.loadURI( + getMessagePaneBrowser(), + "data:text/html;base64," + + btoa( + `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8" /> + <title>${title}</title> + </head> + <body> + <h1>${title}</h1> + <p>${body}</p> + </body> + </html>` + ) + ); + } + autodetectCharset = false; +} + +var folderListener = { + QueryInterface: ChromeUtils.generateQI(["nsIFolderListener"]), + + onFolderRemoved(parentFolder, childFolder) {}, + onMessageRemoved(parentFolder, msg) { + messageHistory.onMessageRemoved(parentFolder, msg); + }, +}; + +var msgObserver = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + observe(subject, topic, data) { + if ( + topic == "message-content-updated" && + gMessageURI == subject.QueryInterface(Ci.nsISupportsString).data + ) { + // This notification is triggered after a partial pop3 message was + // fully downloaded. The old message URI is now gone. To reload the + // message, we display it with its new URI. + displayMessage(data, gViewWrapper); + } + }, +}; + +var preferenceObserver = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + _topics: [ + "mail.inline_attachments", + "mail.show_headers", + "mail.showCondensedAddresses", + "mailnews.display.disallow_mime_handlers", + "mailnews.display.html_as", + "mailnews.display.prefer_plaintext", + "mailnews.headers.showReferences", + "rss.show.summary", + ], + + _reloadTimeout: null, + + init() { + for (let topic of this._topics) { + Services.prefs.addObserver(topic, this); + } + }, + + cleanUp() { + for (let topic of this._topics) { + Services.prefs.removeObserver(topic, this); + } + }, + + observe(subject, topic, data) { + if (data == "mail.show_headers") { + AdjustHeaderView(Services.prefs.getIntPref(data)); + } + if (!this._reloadTimeout) { + // Clear the event queue before reloading the message. Several prefs may + // be changed at once. + this._reloadTimeout = setTimeout(() => { + this._reloadTimeout = null; + ReloadMessage(); + }); + } + }, +}; + +var messageHistory = { + MAX_HISTORY_SIZE: 20, + /** + * @typedef {object} MessageHistoryEntry + * @property {string} messageURI - URI of the message for this entry. + * @property {string} folderURI - URI of the folder for this entry. + */ + /** + * @type {MessageHistoryEntry[]} + */ + _history: [], + _currentIndex: -1, + /** + * Remove the message from the history, cleaning up the state as needed in + * the process. + * + * @param {nsIMsgFolder} parentFolder + * @param {nsIMsgDBHdr} message + */ + onMessageRemoved(parentFolder, message) { + if (!this._history.length) { + return; + } + const messageURI = parentFolder.generateMessageURI(message.messageKey); + const folderURI = parentFolder.URI; + const oldLength = this._history.length; + let removedEntriesBeforeFuture = 0; + this._history = this._history.filter((entry, index) => { + const keepEntry = + entry.messageURI !== messageURI || entry.folderURI !== folderURI; + if (!keepEntry && index <= this._currentIndex) { + ++removedEntriesBeforeFuture; + } + return keepEntry; + }); + this._currentIndex -= removedEntriesBeforeFuture; + // Correct for first entry getting removed while it's the current entry. + if (this._history.length && this._currentIndex == -1) { + this._currentIndex = 0; + } + if (oldLength === this._history.length) { + return; + } + window.top.goUpdateCommand("cmd_goBack"); + window.top.goUpdateCommand("cmd_goForward"); + }, + /** + * Get the actual index in the history based on a delta from the current + * index. + * + * @param {number} delta - Relative delta from the current index. Forward is + * positive, backward is negative. + * @returns {number} Absolute index in the history, bounded to the history + * size. + */ + _getAbsoluteIndex(delta) { + return Math.min( + Math.max(this._currentIndex + delta, 0), + this._history.length - 1 + ); + }, + /** + * Add a message to the end of the history. Does nothing if the message is + * already the current item. Moves the history forward by one step if the next + * item already matches the given message. Else removes any "future" history + * if the current position isn't the newest entry in the history. + * + * If the history is growing larger than what we want to keep, it is trimmed. + * + * Assumes the view is currently in the folder that should be comitted to + * history. + * + * @param {string} messageURI - Message to add to the history. + */ + push(messageURI) { + if (!messageURI) { + return; + } + let currentItem = this._history[this._currentIndex]; + let currentFolder = gFolder?.URI; + if ( + currentItem && + messageURI === currentItem.messageURI && + currentFolder === currentItem.folderURI + ) { + return; + } + let nextMessageIndex = this._currentIndex + 1; + let erasedFuture = false; + if (nextMessageIndex < this._history.length) { + let nextMessage = this._history[nextMessageIndex]; + if ( + nextMessage && + messageURI === nextMessage.messageURI && + currentFolder === nextMessage.folderURI + ) { + this._currentIndex = nextMessageIndex; + if (this._currentIndex === 1) { + window.top.goUpdateCommand("cmd_goBack"); + } + if (this._currentIndex + 1 === this._history.length) { + window.top.goUpdateCommand("cmd_goForward"); + } + return; + } + this._history.splice(nextMessageIndex, Infinity); + erasedFuture = true; + } + this._history.push({ messageURI, folderURI: currentFolder }); + this._currentIndex = nextMessageIndex; + if (this._history.length > this.MAX_HISTORY_SIZE) { + let amountOfItemsToRemove = this._history.length - this.MAX_HISTORY_SIZE; + this._history.splice(0, amountOfItemsToRemove); + this._currentIndex -= amountOfItemsToRemove; + } + if (!currentItem || this._currentIndex === 0) { + window.top.goUpdateCommand("cmd_goBack"); + } + if (erasedFuture) { + window.top.goUpdateCommand("cmd_goForward"); + } + }, + /** + * Go forward or back in history relative to the current position. + * + * @param {number} delta + * @returns {?MessageHistoryEntry} The message and folder URI that are now at + * the active position in the history. If null is returned, no action was + * taken. + */ + pop(delta) { + let targetIndex = this._getAbsoluteIndex(delta); + if (this._currentIndex == targetIndex && gMessage) { + return null; + } + this._currentIndex = targetIndex; + window.top.goUpdateCommand("cmd_goBack"); + window.top.goUpdateCommand("cmd_goForward"); + return this._history[targetIndex]; + }, + /** + * Get the current state of the message history. + * + * @returns {{entries: MessageHistoryEntry[], currentIndex: number}} + * A list of message and folder URIs as strings and the current index in the + * entries. + */ + getHistory() { + return { entries: this._history.slice(), currentIndex: this._currentIndex }; + }, + /** + * Get a specific history entry relative to the current positon. + * + * @param {number} delta - Relative index to get the value of. + * @returns {?MessageHistoryEntry} If found, the message and + * folder URI at the given position. + */ + getMessageAt(delta) { + if (!this._history.length) { + return null; + } + return this._history[this._getAbsoluteIndex(delta)]; + }, + /** + * Check if going forward or back in the history by the given steps is + * possible. A special case is when no message is currently selected, going + * back to relative position 0 (so the current index) is possible. + * + * @param {number} delta - Relative position to go to from the current index. + * @returns {boolean} If there is a target available at that position in the + * current history. + */ + canPop(delta) { + let resultIndex = this._currentIndex + delta; + return ( + resultIndex >= 0 && + resultIndex < this._history.length && + (resultIndex !== this._currentIndex || !gMessage) + ); + }, + /** + * Clear the message history, resetting it to its initial empty state. + */ + clear() { + this._history.length = 0; + this._currentIndex = -1; + window.top.goUpdateCommand("cmd_goBack"); + window.top.goUpdateCommand("cmd_goForward"); + }, +}; + +commandController.registerCallback( + "cmd_delete", + () => commandController.doCommand("cmd_deleteMessage"), + () => commandController.isCommandEnabled("cmd_deleteMessage") +); +commandController.registerCallback( + "cmd_shiftDelete", + () => commandController.doCommand("cmd_shiftDeleteMessage"), + () => commandController.isCommandEnabled("cmd_shiftDeleteMessage") +); +commandController.registerCallback("cmd_find", () => + document.getElementById("FindToolbar").onFindCommand() +); +commandController.registerCallback("cmd_findAgain", () => + document.getElementById("FindToolbar").onFindAgainCommand(false) +); +commandController.registerCallback("cmd_findPrevious", () => + document.getElementById("FindToolbar").onFindAgainCommand(true) +); +commandController.registerCallback("cmd_print", () => { + top.PrintUtils.startPrintWindow(getMessagePaneBrowser().browsingContext, {}); +}); +commandController.registerCallback("cmd_fullZoomReduce", () => { + top.ZoomManager.reduce(); +}); +commandController.registerCallback("cmd_fullZoomEnlarge", () => { + top.ZoomManager.enlarge(); +}); +commandController.registerCallback("cmd_fullZoomReset", () => { + top.ZoomManager.reset(); +}); +commandController.registerCallback("cmd_fullZoomToggle", () => { + top.ZoomManager.toggleZoom(); +}); + +// Attachments commands. +commandController.registerCallback( + "cmd_openAllAttachments", + () => HandleAllAttachments("open"), + () => AttachmentMenuController.someFilesAvailable() +); + +commandController.registerCallback( + "cmd_saveAllAttachments", + () => HandleAllAttachments("save"), + () => AttachmentMenuController.someFilesAvailable() +); + +commandController.registerCallback( + "cmd_detachAllAttachments", + () => HandleAllAttachments("detach"), + () => AttachmentMenuController.canDetachFiles() +); + +commandController.registerCallback( + "cmd_deleteAllAttachments", + () => HandleAllAttachments("delete"), + () => AttachmentMenuController.canDetachFiles() +); diff --git a/comm/mail/base/content/aboutMessage.xhtml b/comm/mail/base/content/aboutMessage.xhtml new file mode 100644 index 0000000000..36571563db --- /dev/null +++ b/comm/mail/base/content/aboutMessage.xhtml @@ -0,0 +1,158 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +#filter substitution + +<!DOCTYPE html [ +<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd"> +%msgHdrViewOverlayDTD; +<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" > +%messengerDTD; +<!ENTITY % editContactOverlayDTD SYSTEM "chrome://messenger/locale/editContactOverlay.dtd"> +%editContactOverlayDTD; +<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd"> +%lightningDTD; +<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" > +%calendarDTD; +<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd"> +%smimeDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8" /> + <title></title> + + <link rel="icon" href="chrome://messenger/skin/icons/new/compact/draft.svg" /> + + <link rel="stylesheet" href="chrome://calendar/skin/calendar.css" /> + <link rel="stylesheet" href="chrome://calendar/skin/shared/calendar-invitation-display.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/messageWindow.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/popupPanel.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/messageHeader.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/icons.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/colors.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/folderMenus.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/attachmentList.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/searchBox.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/openpgp/inlineNotification.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/contextMenu.css" /> + <link rel="stylesheet" href="chrome://messenger/skin/autocomplete.css" /> + + <link rel="localization" href="messenger/messenger.ftl" /> + <link rel="localization" href="toolkit/main-window/findbar.ftl" /> + <link rel="localization" href="toolkit/global/textActions.ftl" /> + <link rel="localization" href="calendar/calendar-invitation-panel.ftl" /> + <link rel="localization" href="messenger/openpgp/openpgp.ftl" /> + <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" /> + <link rel="localization" href="messenger/openpgp/msgReadStatus.ftl" /> + <link rel="localization" href="messenger/messageheader/headerFields.ftl" /> + + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script> + <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script> + <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script> + <script defer="defer" src="chrome://messenger/content/msgViewNavigation.js"></script> + <script defer="defer" src="chrome://messenger/content/editContactPanel.js"></script> + <script defer="defer" src="chrome://messenger/content/header-fields.js"></script> + <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script> + <script defer="defer" src="chrome://messenger-smime/content/msgHdrViewSMIMEOverlay.js"></script> + <script defer="defer" src="chrome://messenger-smime/content/msgReadSMIMEOverlay.js"></script> + <script defer="defer" src="chrome://openpgp/content/ui/enigmailMessengerOverlay.js"></script> + <script defer="defer" src="chrome://openpgp/content/ui/enigmailMsgHdrViewOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/msgSecurityPane.js"></script> + <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script> + <script defer="defer" src="chrome://calendar/content/imip-bar.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-invitation-display.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-invitation-panel.js"></script> + <script defer="defer" src="chrome://messenger/content/mailCore.js"></script> + <script defer="defer" src="chrome://messenger/content/mailContext.js"></script> + <script defer="defer" src="chrome://messenger/content/mailCommon.js"></script> + <script defer="defer" src="chrome://messenger/content/msgHdrView.js"></script> + <script defer="defer" src="chrome://messenger/content/aboutMessage.js"></script> +</head> +<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/> + <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/> + + <commandset id="attachmentCommands"> + <command id="cmd_openAllAttachments" + oncommand="goDoCommand('cmd_openAllAttachments');"/> + <command id="cmd_saveAllAttachments" + oncommand="goDoCommand('cmd_saveAllAttachments');"/> + <command id="cmd_detachAllAttachments" + oncommand="goDoCommand('cmd_detachAllAttachments');"/> + <command id="cmd_deleteAllAttachments" + oncommand="goDoCommand('cmd_deleteAllAttachments');"/> + </commandset> + + <popupset id="mainPopupSet"> +#include mailContext.inc.xhtml +#include msgHdrPopup.inc.xhtml +#include editContactPanel.inc.xhtml + <tooltip id="aHTMLTooltip" page="true"/> + </popupset> + + <!-- msg header view --> + <!-- a convenience box for ease of extension overlaying --> + <hbox id="messagepaneboxwrapper" flex="1"> + <vbox id="messagepanebox"> + <vbox id="singleMessage"> + <hbox id="msgHeaderView" collapsed="true" class="main-header-area"> +#include msgHdrView.inc.xhtml + </hbox> +#include ../../../calendar/base/content/imip-bar-overlay.inc.xhtml + </vbox> + <!-- The msgNotificationBar appears on top of the message and displays + information like: junk, mdn, remote content and phishing warnings --> + <vbox id="mail-notification-top"> + <!-- notificationbox will be added here lazily. --> + </vbox> + +#include ../../../calendar/base/content/widgets/calendar-invitation-panel.xhtml +#include ../../../calendar/base/content/widgets/calendar-minidate.xhtml + + <vbox id="calendarInvitationDisplayContainer" + flex="1" + hidden="true"> + <html:div id="calendarInvitationDisplay"> + <!-- The calendar invitation panel is displayed here. --> + </html:div> + </vbox> + + <!-- message view --> + <browser id="messagepane" + context="mailContext" + tooltip="aHTMLTooltip" + style="height: 0px; min-height: 1px; background-color: field;" + flex="1" + name="messagepane" + disablesecurity="true" + disablehistory="true" + type="content" + primary="true" + autofind="false" + nodefaultsrc="true" + forcemessagemanager="true" + maychangeremoteness="true" + messagemanagergroup="single-page" + onclick="return contentAreaClick(event);" + onresize="messagePaneOnResize();"/> + <splitter id="attachment-splitter" orient="vertical" + resizebefore="closest" resizeafter="closest" + collapse="after" collapsed="true"/> + <vbox id="attachmentView" collapsed="true"> +#include msgAttachmentView.inc.xhtml + </vbox> + <findbar id="FindToolbar" browserid="messagepane"/> + </vbox> +#include msgSecurityPane.inc.xhtml + </hbox> +</html:body> +</html> diff --git a/comm/mail/base/content/aboutRights.xhtml b/comm/mail/base/content/aboutRights.xhtml new file mode 100644 index 0000000000..51997dc87d --- /dev/null +++ b/comm/mail/base/content/aboutRights.xhtml @@ -0,0 +1,110 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE html [ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd"> +%htmlDTD; ]> + +<!-- 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/. --> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <title data-l10n-id="rights-title"></title> + <link + rel="stylesheet" + href="chrome://global/skin/in-content/info-pages.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://global/skin/aboutRights.css" + type="text/css" + /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="messenger/aboutRights.ftl" /> + </head> + + <body id="your-rights"> + <div class="container"> + <div class="rights-header"> + <div> + <h1 data-l10n-id="rights-title"></h1> + + <p data-l10n-id="rights-intro"></p> + </div> + </div> + + <ul> + <li data-l10n-id="rights-intro-point-1"> + <a + href="http://www.mozilla.org/MPL/" + data-l10n-name="mozilla-public-license-link" + ></a> + </li> + <!-- Point 2 discusses Mozilla trademarks, and isn't needed when the build is unbranded. + - Point 4 discusses privacy policy, unbranded builds get a placeholder (for the vendor to replace) + - Point 5 discusses web service terms, unbranded builds gets a placeholder (for the vendor to replace) --> + <li data-l10n-id="rights-intro-point-2"> + <a + href="http://www.mozilla.org/foundation/trademarks/policy.html" + data-l10n-name="mozilla-trademarks-link" + ></a> + </li> + <li data-l10n-id="rights-intro-point-3"></li> + <li data-l10n-id="rights-intro-point-4"> + <a + href="https://www.mozilla.org/legal/privacy/firefox.html" + data-l10n-name="mozilla-privacy-policy-link" + ></a> + </li> + <li data-l10n-id="rights-intro-point-5"> + <a + href="about:rights#webservices" + id="showWebServices" + data-l10n-name="mozilla-service-terms-link" + ></a> + </li> + <li data-l10n-id="rights-intro-point-6"></li> + </ul> + + <div id="webservices-container"> + <a name="webservices" /> + <h3 data-l10n-id="rights-webservices-header"></h3> + + <p data-l10n-id="rights-webservices2"> + <a + href="about:rights#disabling-webservices" + id="showDisablingWebServices" + data-l10n-name="mozilla-disable-service-link" + ></a> + </p> + + <div id="disabling-webservices-container" style="margin-left: 40px"> + <a name="disabling-webservices" /> + <p data-l10n-id="rights-locationawarebrowsing"></p> + <ul> + <li data-l10n-id="rights-locationawarebrowsing-term-1"></li> + <li data-l10n-id="rights-locationawarebrowsing-term-2"></li> + <li data-l10n-id="rights-locationawarebrowsing-term-3"></li> + <li data-l10n-id="rights-locationawarebrowsing-term-4"></li> + </ul> + </div> + + <ol> + <!-- Terms only apply to official builds, unbranded builds get a placeholder. --> + <li data-l10n-id="rights-webservices-term-1"></li> + <li data-l10n-id="rights-webservices-term-2"></li> + <li data-l10n-id="rights-webservices-term-3"></li> + <li data-l10n-id="rights-webservices-term-4"></li> + <li data-l10n-id="rights-webservices-term-5"></li> + <li data-l10n-id="rights-webservices-term-6"></li> + <li data-l10n-id="rights-webservices-term-7"></li> + </ol> + </div> + </div> + </body> + <script src="chrome://global/content/aboutRights.js" /> +</html> diff --git a/comm/mail/base/content/browserRequest.js b/comm/mail/base/content/browserRequest.js new file mode 100644 index 0000000000..3cf695f94a --- /dev/null +++ b/comm/mail/base/content/browserRequest.js @@ -0,0 +1,145 @@ +/* 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 { MailE10SUtils } = ChromeUtils.import( + "resource:///modules/MailE10SUtils.jsm" +); + +/* Magic global things the <browser> and its entourage of logic expect. */ +var PopupNotifications = { + show(browser, id, message) { + console.warn( + "Not showing popup notification", + id, + "with the message", + message + ); + }, +}; + +var gBrowser = { + get selectedBrowser() { + return document.getElementById("requestFrame"); + }, + _getAndMaybeCreateDateTimePickerPanel() { + return this.selectedBrowser.dateTimePicker; + }, + get webNavigation() { + return this.selectedBrowser.webNavigation; + }, +}; + +function getBrowser() { + return gBrowser.selectedBrowser; +} + +/* Logic to actually run the login process and window contents */ +var reporterListener = { + _isBusy: false, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + onStateChange( + /* in nsIWebProgress*/ aWebProgress, + /* in nsIRequest*/ aRequest, + /* in unsigned long*/ aStateFlags, + /* in nsresult*/ aStatus + ) {}, + + onProgressChange( + /* in nsIWebProgress*/ aWebProgress, + /* in nsIRequest*/ aRequest, + /* in long*/ aCurSelfProgress, + /* in long */ aMaxSelfProgress, + /* in long */ aCurTotalProgress, + /* in long */ aMaxTotalProgress + ) {}, + + onLocationChange( + /* in nsIWebProgress*/ aWebProgress, + /* in nsIRequest*/ aRequest, + /* in nsIURI*/ aLocation + ) { + document.getElementById("headerMessage").value = aLocation.spec; + }, + + onStatusChange( + /* in nsIWebProgress*/ aWebProgress, + /* in nsIRequest*/ aRequest, + /* in nsresult*/ aStatus, + /* in wstring*/ aMessage + ) {}, + + onSecurityChange( + /* in nsIWebProgress*/ aWebProgress, + /* in nsIRequest*/ aRequest, + /* in unsigned long*/ aState + ) { + const wpl_security_bits = + Ci.nsIWebProgressListener.STATE_IS_SECURE | + Ci.nsIWebProgressListener.STATE_IS_BROKEN | + Ci.nsIWebProgressListener.STATE_IS_INSECURE; + + let icon = document.getElementById("security-icon"); + switch (aState & wpl_security_bits) { + case Ci.nsIWebProgressListener.STATE_IS_SECURE: + icon.setAttribute( + "src", + "chrome://messenger/skin/icons/connection-secure.svg" + ); + // Set alt. + document.l10n.setAttributes(icon, "content-tab-security-high-icon"); + icon.classList.add("secure-connection-icon"); + break; + case Ci.nsIWebProgressListener.STATE_IS_BROKEN: + icon.setAttribute( + "src", + "chrome://messenger/skin/icons/connection-insecure.svg" + ); + document.l10n.setAttributes(icon, "content-tab-security-broken-icon"); + icon.classList.remove("secure-connection-icon"); + break; + default: + icon.removeAttribute("src"); + icon.removeAttribute("data-l10n-id"); + icon.removeAttribute("alt"); + icon.classList.remove("secure-connection-icon"); + break; + } + }, + + onContentBlockingEvent( + /* in nsIWebProgress*/ aWebProgress, + /* in nsIRequest*/ aRequest, + /* in unsigned long*/ aEvent + ) {}, +}; + +function cancelRequest() { + reportUserClosed(); + window.close(); +} + +function reportUserClosed() { + let request = window.arguments[0].wrappedJSObject; + request.cancelled(); +} + +function loadRequestedUrl() { + let request = window.arguments[0].wrappedJSObject; + + var browser = document.getElementById("requestFrame"); + browser.addProgressListener(reporterListener, Ci.nsIWebProgress.NOTIFY_ALL); + var url = request.url; + if (url == "") { + document.getElementById("headerMessage").value = request.promptText; + } else { + MailE10SUtils.loadURI(browser, url); + document.getElementById("headerMessage").value = url; + } + request.loaded(window, browser.webProgress); +} diff --git a/comm/mail/base/content/browserRequest.xhtml b/comm/mail/base/content/browserRequest.xhtml new file mode 100644 index 0000000000..b9a77766a9 --- /dev/null +++ b/comm/mail/base/content/browserRequest.xhtml @@ -0,0 +1,58 @@ +<?xml version="1.0"?> +<!--# 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/. + --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/tabmail.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/browserRequest.css" type="text/css"?> + +<!DOCTYPE window [ +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%brandDTD; +<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd"> +%messengerDTD; +]> +<window id="browserRequest" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml" + buttons="," + onload="loadRequestedUrl()" + onclose="reportUserClosed()" + title="" + width="800" + height="500" + orient="vertical"> + <popupset id="mainPopupSet"> +#define NO_BROWSERCONTEXT +#include widgets/browserPopups.inc.xhtml + </popupset> + + <script src="chrome://messenger/content/globalOverlay.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + <script src="chrome://messenger/content/viewZoomOverlay.js"/> + <script src="chrome://messenger/content/browserRequest.js"/> + + <html:link rel="localization" href="messenger/messenger.ftl"/> + + <keyset id="mainKeyset"> + <key id="key_close" key="w" modifiers="accel" oncommand="cancelRequest()"/> + <key id="key_close2" keycode="VK_ESCAPE" oncommand="cancelRequest()"/> + </keyset> + + <!-- Use the same styling and semantics as content tabs. --> + <html:div id="header" class="contentTabAddress"> + <html:img id="security-icon" class="contentTabSecurity" /> + <html:input id="headerMessage" class="contentTabUrlInput themeableSearchBox" + readonly="readonly"> + </html:input> + </html:div> + <browser id="requestFrame" + type="content" + nodefaultsrc="true" + maychangeremoteness="true" + flex="1" + autocompletepopup="PopupAutoComplete"/> +</window> diff --git a/comm/mail/base/content/buildconfig.html b/comm/mail/base/content/buildconfig.html new file mode 100644 index 0000000000..1a699cbfed --- /dev/null +++ b/comm/mail/base/content/buildconfig.html @@ -0,0 +1,106 @@ +<!DOCTYPE html> +# 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/. +# +#filter substitution +#include @TOPOBJDIR@/source-repo.h +#include @TOPOBJDIR@/buildid.h +<html> + <head> + <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src chrome:; object-src 'none'" /> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width; user-scalable=false;"> + <title>Build Configuration</title> + <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" type="text/css"> + <link rel="stylesheet" href="chrome://global/content/buildconfig.css" type="text/css"> + </head> + <body> + <div> + <h1>Build Configuration</h1> + <h2>@MOZ_APP_DISPLAYNAME@ @MOZ_APP_VERSION_DISPLAY@ - @MOZ_BUILDID@</h2> + <table> + <tbody> + #ifdef MOZ_COMM_SOURCE_URL + <tr> + <th> + @MOZ_APP_DISPLAYNAME@ source + </th> + </tr> + <tr> + <td> + <a href="@MOZ_COMM_SOURCE_URL@">@MOZ_COMM_SOURCE_URL@</a> + </td> + </tr> + #endif + #ifdef MOZ_GECKO_SOURCE_URL + <tr> + <th> + Platform source + </th> + </tr> + <tr> + <td> + <a href="@MOZ_GECKO_SOURCE_URL@">@MOZ_GECKO_SOURCE_URL@</a> + </td> + </tr> + #endif + </tbody> + </table> + + <p> + The latest information on building @MOZ_APP_DISPLAYNAME@ can be found at + <a href="@THUNDERBIRD_DEVELOPER_WWW@">@THUNDERBIRD_DEVELOPER_WWW@</a>. + </p> + + <h2>Build platform</h2> + <table> + <tbody> + <tr> + <th>Target</th> + </tr> + <tr> + <td>@target@</td> + </tr> + </tbody> + </table> + #if defined(CC) && defined(CXX) && defined(RUSTC) + <h2>Build tools</h2> + <table> + <tbody> + <tr> + <th>Compiler</th> + <th>Version</th> + <th>Compiler flags</th> + </tr> + <tr> + <td><code>@CC@</code></td> + <td><code>@CC_VERSION@</code></td> + <td><code>@CFLAGS@</code></td> + </tr> + <tr> + <td><code>@CXX@</code></td> + <td><code>@CC_VERSION@</code></td> + <td><code>@CXXFLAGS@</code></td> + </tr> + <tr> + <td><code>@RUSTC@</code></td> + <td><code>@RUSTC_VERSION@</code></td> + <td><code>@RUSTFLAGS@</code></td> + </tr> + </tbody> + </table> + #endif + <h2>Configure options</h2> + <table> + <tbody> + <tr> + <td> + <code>@MOZ_CONFIGURE_OPTIONS@</code> + </td> + </tr> + </tbody> + </table> + </div> + </body> +</html> diff --git a/comm/mail/base/content/commonDialog.xhtml b/comm/mail/base/content/commonDialog.xhtml new file mode 100644 index 0000000000..4072ff52f6 --- /dev/null +++ b/comm/mail/base/content/commonDialog.xhtml @@ -0,0 +1,110 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://global/content/commonDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://global/skin/commonDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window> + +<window + id="commonDialogWindow" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + aria-describedby="infoBody" + headerparent="dialogGrid" + onunload="commonDialogOnUnload();" +> + <dialog id="commonDialog" buttonpack="end"> + <linkset> + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="toolkit/global/commonDialog.ftl" /> + </linkset> + <script src="chrome://global/content/adjustableTitle.js" /> + <script src="chrome://global/content/commonDialog.js" /> + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://global/content/customElements.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + <script> + /* eslint-disable no-undef */ + document.addEventListener("DOMContentLoaded", function () { + commonDialogOnLoad(); + }); + </script> + + <commandset id="selectEditMenuItems"> + <command + id="cmd_copy" + oncommand="goDoCommand('cmd_copy')" + disabled="true" + /> + <command id="cmd_selectAll" oncommand="goDoCommand('cmd_selectAll')" /> + </commandset> + + <popupset id="contentAreaContextSet"> + <menupopup + id="contentAreaContextMenu" + onpopupshowing="goUpdateCommand('cmd_copy')" + > + <menuitem + id="context-copy" + data-l10n-id="common-dialog-copy-cmd" + command="cmd_copy" + disabled="true" + /> + <menuitem + id="context-selectall" + data-l10n-id="common-dialog-select-all-cmd" + command="cmd_selectAll" + /> + </menupopup> + </popupset> + + <!-- The <div> was added in bug 1606617 to workaround bug 1614447 --> + <div xmlns="http://www.w3.org/1999/xhtml"> + <div id="dialogGrid"> + <div class="dialogRow" id="infoRow" hidden="hidden"> + <div id="iconContainer"> + <img id="infoIcon" src="" alt="" role="presentation" /> + </div> + <div id="infoContainer"> + <xul:description id="infoTitle" /> + <xul:description + id="infoBody" + context="contentAreaContextMenu" + noinitialfocus="true" + /> + </div> + </div> + <div id="loginContainer" class="dialogRow" hidden="hidden"> + <xul:label + id="loginLabel" + data-l10n-id="common-dialog-username" + control="loginTextbox" + /> + <input type="text" id="loginTextbox" dir="ltr" /> + </div> + <div id="password1Container" class="dialogRow" hidden="hidden"> + <xul:label + id="password1Label" + data-l10n-id="common-dialog-password" + control="password1Textbox" + /> + <input type="password" id="password1Textbox" dir="ltr" /> + </div> + <div id="checkboxContainer" class="dialogRow" hidden="hidden"> + <div /> + <!-- spacer --> + <xul:checkbox id="checkbox" oncommand="Dialog.onCheckbox()" /> + </div> + </div> + </div> + </dialog> +</window> diff --git a/comm/mail/base/content/compactFoldersDialog.js b/comm/mail/base/content/compactFoldersDialog.js new file mode 100644 index 0000000000..b0ac027266 --- /dev/null +++ b/comm/mail/base/content/compactFoldersDialog.js @@ -0,0 +1,54 @@ +/* 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 propBag, args; + +document.addEventListener("DOMContentLoaded", compactDialogOnDOMContentLoaded); +// Bug 1720540: Call sizeToContent only after the entire window has been loaded, +// including the shadow DOM and the updated fluent strings. +window.addEventListener("load", window.sizeToContent); + +function compactDialogOnDOMContentLoaded() { + propBag = window.arguments[0] + .QueryInterface(Ci.nsIWritablePropertyBag2) + .QueryInterface(Ci.nsIWritablePropertyBag); + + // Convert to a JS object. + args = {}; + for (let prop of propBag.enumerator) { + args[prop.name] = prop.value; + } + + // We're deliberately adding the data-l10n-args attribute synchronously to + // avoid race issues for window.sizeToContent later on. + document + .getElementById("compactFoldersText") + .setAttribute("data-l10n-args", JSON.stringify({ data: args.compactSize })); + + document.addEventListener("dialogaccept", function () { + args.buttonNumClicked = 0; + args.checked = document.getElementById("neverAskCheckbox").checked; + }); + + document.addEventListener("dialogcancel", function () { + args.buttonNumClicked = 1; + }); + + document.addEventListener("dialogextra1", function () { + // Open the support article URL and leave the dialog open. + let uri = Services.io.newURI( + "https://support.mozilla.org/kb/compacting-folders" + ); + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(uri); + }); +} + +function compactDialogOnUnload() { + // Convert args back into property bag. + for (let propName in args) { + propBag.setProperty(propName, args[propName]); + } +} diff --git a/comm/mail/base/content/compactFoldersDialog.xhtml b/comm/mail/base/content/compactFoldersDialog.xhtml new file mode 100644 index 0000000000..427c70848c --- /dev/null +++ b/comm/mail/base/content/compactFoldersDialog.xhtml @@ -0,0 +1,54 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="compact-dialog-window-title" + role="alert" + lightweightthemes="true" + onunload="compactDialogOnUnload();" +> + <dialog + id="folderCompactDialog" + buttons="accept,cancel,extra1" + data-l10n-id="compact-dialog" + data-l10n-attrs="buttonlabelaccept, buttonlabelcancel, buttonlabelextra1, buttonaccesskeyaccept, buttonaccesskeycancel, buttonaccesskeyextra1" + > + <linkset> + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="messenger/compactFoldersDialog.ftl" /> + </linkset> + + <script src="chrome://messenger/content/globalOverlay.js" /> + <script src="chrome://global/content/editMenuOverlay.js" /> + <script src="chrome://messenger/content/compactFoldersDialog.js" /> + <script src="chrome://messenger/content/dialogShadowDom.js" /> + + <hbox> + <vbox class="image-container"> + <html:img src="chrome://global/skin/icons/help.svg" alt="" /> + </vbox> + + <vbox flex="1" class="text-container"> + <description + id="compactFoldersText" + data-l10n-id="compact-dialog-message" + data-l10n-args='{"data" : ""}' + /> + <checkbox + id="neverAskCheckbox" + data-l10n-id="compact-dialog-never-ask-checkbox" + /> + </vbox> + </hbox> + </dialog> +</window> diff --git a/comm/mail/base/content/contentAreaClick.js b/comm/mail/base/content/contentAreaClick.js new file mode 100644 index 0000000000..647c4e7551 --- /dev/null +++ b/comm/mail/base/content/contentAreaClick.js @@ -0,0 +1,206 @@ +/** + * 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 ../../../../toolkit/content/contentAreaUtils.js */ +/* import-globals-from utilityOverlay.js */ + +/* globals getMessagePaneBrowser */ // From aboutMessage.js + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +XPCOMUtils.defineLazyModuleGetters(this, { + PhishingDetector: "resource:///modules/PhishingDetector.jsm", +}); +XPCOMUtils.defineLazyPreferenceGetter( + this, + "alternativeAddonSearchUrl", + "extensions.alternativeAddonSearch.url" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "canonicalAddonServerUrl", + "extensions.canonicalAddonServer.url" +); +/** + * Extract the href from the link click event. + * We look for HTMLAnchorElement, HTMLAreaElement, HTMLLinkElement, + * HTMLInputElement.form.action, and nested anchor tags. + * If the clicked element was a HTMLInputElement or HTMLButtonElement + * we return the form action. + * + * @returns [href, linkText] the url and the text for the link being clicked. + */ +function hRefForClickEvent(aEvent, aDontCheckInputElement) { + let target = + aEvent.type == "command" + ? document.commandDispatcher.focusedElement + : aEvent.target; + + if ( + HTMLImageElement.isInstance(target) && + target.hasAttribute("overflowing") + ) { + // Click on zoomed image. + return [null, null]; + } + + let href = null; + let linkText = null; + if ( + HTMLAnchorElement.isInstance(target) || + HTMLAreaElement.isInstance(target) || + HTMLLinkElement.isInstance(target) + ) { + if (target.hasAttribute("href")) { + href = target.href; + linkText = gatherTextUnder(target); + } + } else if ( + !aDontCheckInputElement && + (HTMLInputElement.isInstance(target) || + HTMLButtonElement.isInstance(target)) + ) { + if (target.form && target.form.action) { + href = target.form.action; + } + } else { + // We may be nested inside of a link node. + let linkNode = aEvent.target; + while (linkNode && !HTMLAnchorElement.isInstance(linkNode)) { + linkNode = linkNode.parentNode; + } + + if (linkNode) { + href = linkNode.href; + linkText = gatherTextUnder(linkNode); + } + } + return [href, linkText]; +} + +/** + * Check whether the click target's or its ancestor's href + * points to an anchor on the page. + * + * @param HTMLElement aTargetNode - the element node. + * @returns - true if link pointing to anchor. + */ +function isLinkToAnchorOnPage(aTargetNode) { + let url = aTargetNode.ownerDocument.URL; + if (!url.startsWith("http")) { + return false; + } + + let linkNode = aTargetNode; + while (linkNode && !HTMLAnchorElement.isInstance(linkNode)) { + linkNode = linkNode.parentNode; + } + + // It's not a link with an anchor. + if (!linkNode || !linkNode.href || !linkNode.hash) { + return false; + } + + // The link's href must match the document URL. + if (makeURI(linkNode.href).specIgnoringRef != makeURI(url).specIgnoringRef) { + return false; + } + + return true; +} + +// Called whenever the user clicks in the content area, +// should always return true for click to go through. +function contentAreaClick(aEvent) { + let target = aEvent.target; + if (target.localName == "browser") { + // This is a remote browser. Nothing useful can happen in this process. + return true; + } + + // If we've loaded a web page url, and the element's or its ancestor's href + // points to an anchor on the page, let the click go through. + // Otherwise fall through and open externally. + if (isLinkToAnchorOnPage(target)) { + return true; + } + + let [href, linkText] = hRefForClickEvent(aEvent); + + if (!href && !aEvent.button) { + // Is this an image that we might want to scale? + + if (HTMLImageElement.isInstance(target)) { + // Make sure it loaded successfully. No action if not or a broken link. + var req = target.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST); + if (!req || req.imageStatus & Ci.imgIRequest.STATUS_ERROR) { + return false; + } + + // Is it an image? + if (target.localName == "img" && target.hasAttribute("overflowing")) { + if (target.hasAttribute("shrinktofit")) { + // Currently shrunk to fit, so unshrink it. + target.removeAttribute("shrinktofit"); + } else { + // User wants to shrink now. + target.setAttribute("shrinktofit", true); + } + + return false; + } + } + return true; + } + + if (!href || aEvent.button == 2) { + return true; + } + + // We want all about, http and https links in the message pane to be loaded + // externally in a browser, therefore we need to detect that here and redirect + // as necessary. + let uri = makeURI(href); + if ( + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .isExposedProtocol(uri.scheme) && + !uri.schemeIs("http") && + !uri.schemeIs("https") + ) { + return true; + } + + // Add-on names in the Add-On Manager are links, but we don't want to do + // anything with them. + if (uri.schemeIs("addons")) { + return true; + } + + // Now we're here, we know this should be loaded in an external browser, so + // prevent the default action so we don't try and load it here. + aEvent.preventDefault(); + + // Let the phishing detector check the link. + let urlPhishCheckResult = PhishingDetector.warnOnSuspiciousLinkClick( + window, + href, + linkText + ); + if (urlPhishCheckResult === 1) { + return false; // Block request + } + + if (urlPhishCheckResult === 0) { + // Use linkText instead. + openLinkExternally(linkText); + return true; + } + + openLinkExternally(href); + return true; +} diff --git a/comm/mail/base/content/customElements.js b/comm/mail/base/content/customElements.js new file mode 100644 index 0000000000..f5bec376a9 --- /dev/null +++ b/comm/mail/base/content/customElements.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +(() => { + // If toolkit customElements weren't already loaded, do it now. + if (!window.MozXULElement) { + Services.scriptloader.loadSubScript( + "chrome://global/content/customElements.js", + window + ); + } + + const isDummyDocument = + document.documentURI == "chrome://extensions/content/dummy.xhtml"; + if (!isDummyDocument) { + for (let script of [ + "chrome://chat/content/conversation-browser.js", + "chrome://messenger/content/gloda-autocomplete-input.js", + "chrome://chat/content/chat-tooltip.js", + "chrome://messenger/content/mailWidgets.js", + "chrome://messenger/content/statuspanel.js", + "chrome://messenger/content/foldersummary.js", + "chrome://messenger/content/addressbook/menulist-addrbooks.js", + "chrome://messenger/content/folder-menupopup.js", + "chrome://messenger/content/toolbarbutton-menu-button.js", + ]) { + Services.scriptloader.loadSubScript(script, window); + } + } +})(); diff --git a/comm/mail/base/content/customizeToolbar.js b/comm/mail/base/content/customizeToolbar.js new file mode 100644 index 0000000000..dd2b28bdab --- /dev/null +++ b/comm/mail/base/content/customizeToolbar.js @@ -0,0 +1,836 @@ +/* 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 gToolboxDocument = null; +var gToolbox = null; +var gCurrentDragOverItem = null; +var gToolboxChanged = false; +var gToolboxSheet = false; +var gPaletteBox = null; + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +function onLoad() { + if ("arguments" in window && window.arguments[0]) { + InitWithToolbox(window.arguments[0]); + repositionDialog(window); + } else if (window.frameElement && "toolbox" in window.frameElement) { + gToolboxSheet = true; + InitWithToolbox(window.frameElement.toolbox); + repositionDialog(window.frameElement.panel); + } +} + +function InitWithToolbox(aToolbox) { + gToolbox = aToolbox; + dispatchCustomizationEvent("beforecustomization"); + gToolboxDocument = gToolbox.ownerDocument; + gToolbox.customizing = true; + forEachCustomizableToolbar(function (toolbar) { + toolbar.setAttribute("customizing", "true"); + }); + gPaletteBox = document.getElementById("palette-box"); + + var elts = getRootElements(); + for (let i = 0; i < elts.length; i++) { + elts[i].addEventListener("dragstart", onToolbarDragStart, true); + elts[i].addEventListener("dragover", onToolbarDragOver, true); + elts[i].addEventListener("dragleave", onToolbarDragLeave, true); + elts[i].addEventListener("drop", onToolbarDrop, true); + } + + initDialog(); +} + +function onClose() { + if (!gToolboxSheet) { + window.close(); + } else { + finishToolbarCustomization(); + } +} + +function onUnload() { + if (!gToolboxSheet) { + finishToolbarCustomization(); + } +} + +function finishToolbarCustomization() { + removeToolboxListeners(); + unwrapToolbarItems(); + persistCurrentSets(); + gToolbox.customizing = false; + forEachCustomizableToolbar(function (toolbar) { + toolbar.removeAttribute("customizing"); + }); + + notifyParentComplete(); +} + +function initDialog() { + var mode = gToolbox.getAttribute("mode"); + document.getElementById("modelist").value = mode; + var smallIconsCheckbox = document.getElementById("smallicons"); + smallIconsCheckbox.checked = gToolbox.getAttribute("iconsize") == "small"; + if (mode == "text") { + smallIconsCheckbox.disabled = true; + } + + if (AppConstants.MOZ_APP_NAME == "thunderbird") { + document.getElementById("showTitlebar").checked = + !Services.prefs.getBoolPref("mail.tabs.drawInTitlebar"); + if ( + window.opener && + window.opener.document.documentElement.getAttribute("windowtype") == + "mail:3pane" + ) { + document.getElementById("titlebarSettings").hidden = false; + } + } + + // Build up the palette of other items. + buildPalette(); + + // Wrap all the items on the toolbar in toolbarpaletteitems. + wrapToolbarItems(); +} + +function repositionDialog(aWindow) { + // Position the dialog touching the bottom of the toolbox and centered with + // it. + if (!aWindow) { + return; + } + + var width; + if (aWindow != window) { + width = aWindow.getBoundingClientRect().width; + } else if (document.documentElement.hasAttribute("width")) { + width = document.documentElement.getAttribute("width"); + } else { + width = parseInt(document.documentElement.style.width); + } + var boundingRect = gToolbox.getBoundingClientRect(); + var screenX = gToolbox.screenX + (boundingRect.width - width) / 2; + var screenY = gToolbox.screenY + boundingRect.height; + + aWindow.moveTo(screenX, screenY); +} + +function removeToolboxListeners() { + var elts = getRootElements(); + for (let i = 0; i < elts.length; i++) { + elts[i].removeEventListener("dragstart", onToolbarDragStart, true); + elts[i].removeEventListener("dragover", onToolbarDragOver, true); + elts[i].removeEventListener("dragleave", onToolbarDragLeave, true); + elts[i].removeEventListener("drop", onToolbarDrop, true); + } +} + +/** + * Invoke a callback on the toolbox to notify it that the dialog is done + * and going away. + */ +function notifyParentComplete() { + if ("customizeDone" in gToolbox) { + gToolbox.customizeDone(gToolboxChanged); + } + dispatchCustomizationEvent("aftercustomization"); +} + +function toolboxChanged(aType) { + gToolboxChanged = true; + if ("customizeChange" in gToolbox) { + gToolbox.customizeChange(aType); + } + dispatchCustomizationEvent("customizationchange"); +} + +function dispatchCustomizationEvent(aEventName) { + var evt = document.createEvent("Events"); + evt.initEvent(aEventName, true, true); + gToolbox.dispatchEvent(evt); +} + +/** + * Persist the current set of buttons in all customizable toolbars to + * localstore. + */ +function persistCurrentSets() { + if (!gToolboxChanged || gToolboxDocument.defaultView.closed) { + return; + } + + forEachCustomizableToolbar(function (toolbar) { + // Calculate currentset and store it in the attribute. + var currentSet = toolbar.currentSet; + toolbar.setAttribute("currentset", currentSet); + Services.xulStore.persist(toolbar, "currentset"); + }); +} + +/** + * Wraps all items in all customizable toolbars in a toolbox. + */ +function wrapToolbarItems() { + forEachCustomizableToolbar(function (toolbar) { + for (let item of toolbar.children) { + if (AppConstants.platform == "macosx") { + if ( + item.firstElementChild && + item.firstElementChild.localName == "menubar" + ) { + return; + } + } + if (isToolbarItem(item)) { + let wrapper = wrapToolbarItem(item); + cleanupItemForToolbar(item, wrapper); + } + } + }); +} + +function getRootElements() { + if (window.frameElement && "externalToolbars" in window.frameElement) { + return [gToolbox].concat(window.frameElement.externalToolbars); + } + if ("arguments" in window && window.arguments[1].length > 0) { + return [gToolbox].concat(window.arguments[1]); + } + return [gToolbox]; +} + +/** + * Unwraps all items in all customizable toolbars in a toolbox. + */ +function unwrapToolbarItems() { + let elts = getRootElements(); + for (let i = 0; i < elts.length; i++) { + let paletteItems = elts[i].getElementsByTagName("toolbarpaletteitem"); + let paletteItem; + while ((paletteItem = paletteItems.item(0)) != null) { + let toolbarItem = paletteItem.firstElementChild; + restoreItemForToolbar(toolbarItem, paletteItem); + paletteItem.parentNode.replaceChild(toolbarItem, paletteItem); + } + } +} + +/** + * Creates a wrapper that can be used to contain a toolbaritem and prevent + * it from receiving UI events. + */ +function createWrapper(aId, aDocument) { + let wrapper = aDocument.createXULElement("toolbarpaletteitem"); + + wrapper.id = "wrapper-" + aId; + return wrapper; +} + +/** + * Wraps an item that has been cloned from a template and adds + * it to the end of the palette. + */ +function wrapPaletteItem(aPaletteItem) { + var wrapper = createWrapper(aPaletteItem.id, document); + + wrapper.appendChild(aPaletteItem); + + // XXX We need to call this AFTER the palette item has been appended + // to the wrapper or else we crash dropping certain buttons on the + // palette due to removal of the command and disabled attributes - JRH + cleanUpItemForPalette(aPaletteItem, wrapper); + + gPaletteBox.appendChild(wrapper); +} + +/** + * Wraps an item that is currently on a toolbar and replaces the item + * with the wrapper. This is not used when dropping items from the palette, + * only when first starting the dialog and wrapping everything on the toolbars. + */ +function wrapToolbarItem(aToolbarItem) { + var wrapper = createWrapper(aToolbarItem.id, gToolboxDocument); + + wrapper.flex = aToolbarItem.flex; + + aToolbarItem.parentNode.replaceChild(wrapper, aToolbarItem); + + wrapper.appendChild(aToolbarItem); + + return wrapper; +} + +/** + * Get the list of ids for the current set of items on each toolbar. + */ +function getCurrentItemIds() { + var currentItems = {}; + forEachCustomizableToolbar(function (toolbar) { + var child = toolbar.firstElementChild; + while (child) { + if (isToolbarItem(child)) { + currentItems[child.id] = 1; + } + child = child.nextElementSibling; + } + }); + return currentItems; +} + +/** + * Builds the palette of draggable items that are not yet in a toolbar. + */ +function buildPalette() { + // Empty the palette first. + while (gPaletteBox.lastElementChild) { + gPaletteBox.lastChild.remove(); + } + + // Add the toolbar separator item. + var templateNode = document.createXULElement("toolbarseparator"); + templateNode.id = "separator"; + wrapPaletteItem(templateNode); + + // Add the toolbar spring item. + templateNode = document.createXULElement("toolbarspring"); + templateNode.id = "spring"; + templateNode.flex = 1; + wrapPaletteItem(templateNode); + + // Add the toolbar spacer item. + templateNode = document.createXULElement("toolbarspacer"); + templateNode.id = "spacer"; + templateNode.flex = 1; + wrapPaletteItem(templateNode); + + var currentItems = getCurrentItemIds(); + templateNode = gToolbox.palette.firstElementChild; + while (templateNode) { + // Check if the item is already in a toolbar before adding it to the + // palette, but do not add back separators, springs and spacers - we do + // not want them duplicated. + if (!isSpecialItem(templateNode) && !(templateNode.id in currentItems)) { + var paletteItem = document.importNode(templateNode, true); + wrapPaletteItem(paletteItem); + } + + templateNode = templateNode.nextElementSibling; + } +} + +/** + * Makes sure that an item that has been cloned from a template + * is stripped of any attributes that may adversely affect its + * appearance in the palette. + */ +function cleanUpItemForPalette(aItem, aWrapper) { + aWrapper.setAttribute("place", "palette"); + setWrapperType(aItem, aWrapper); + + if (aItem.hasAttribute("title")) { + aWrapper.setAttribute("title", aItem.getAttribute("title")); + } else if (aItem.hasAttribute("label")) { + aWrapper.setAttribute("title", aItem.getAttribute("label")); + } else if (isSpecialItem(aItem)) { + var stringBundle = document.getElementById("stringBundle"); + // Remove the common "toolbar" prefix to generate the string name. + var title = stringBundle.getString(aItem.localName.slice(7) + "Title"); + aWrapper.setAttribute("title", title); + } + aWrapper.setAttribute("tooltiptext", aWrapper.getAttribute("title")); + + // Remove attributes that screw up our appearance. + aItem.removeAttribute("command"); + aItem.removeAttribute("observes"); + aItem.removeAttribute("type"); + aItem.removeAttribute("width"); + aItem.removeAttribute("checked"); + aItem.removeAttribute("collapsed"); + + aWrapper.querySelectorAll("[disabled]").forEach(function (aNode) { + aNode.removeAttribute("disabled"); + }); +} + +/** + * Makes sure that an item that has been cloned from a template + * is stripped of all properties that may adversely affect its + * appearance in the toolbar. Store critical properties on the + * wrapper so they can be put back on the item when we're done. + */ +function cleanupItemForToolbar(aItem, aWrapper) { + setWrapperType(aItem, aWrapper); + aWrapper.setAttribute("place", "toolbar"); + + if (aItem.hasAttribute("command")) { + aWrapper.setAttribute("itemcommand", aItem.getAttribute("command")); + aItem.removeAttribute("command"); + } + + if (aItem.hasAttribute("collapsed")) { + aWrapper.setAttribute("itemcollapsed", aItem.getAttribute("collapsed")); + aItem.removeAttribute("collapsed"); + } + + if (aItem.checked) { + aWrapper.setAttribute("itemchecked", "true"); + aItem.checked = false; + } + + if (aItem.disabled) { + aWrapper.setAttribute("itemdisabled", "true"); + aItem.disabled = false; + } +} + +/** + * Restore all the properties that we stripped off above. + */ +function restoreItemForToolbar(aItem, aWrapper) { + if (aWrapper.hasAttribute("itemdisabled")) { + aItem.disabled = true; + } + + if (aWrapper.hasAttribute("itemchecked")) { + aItem.checked = true; + } + + if (aWrapper.hasAttribute("itemcollapsed")) { + let collapsed = aWrapper.getAttribute("itemcollapsed"); + aItem.setAttribute("collapsed", collapsed); + } + + if (aWrapper.hasAttribute("itemcommand")) { + let commandID = aWrapper.getAttribute("itemcommand"); + aItem.setAttribute("command", commandID); + + // XXX Bug 309953 - toolbarbuttons aren't in sync with their commands after customizing + let command = gToolboxDocument.getElementById(commandID); + if (command && command.hasAttribute("disabled")) { + aItem.setAttribute("disabled", command.getAttribute("disabled")); + } + } +} + +function setWrapperType(aItem, aWrapper) { + if (aItem.localName == "toolbarseparator") { + aWrapper.setAttribute("type", "separator"); + } else if (aItem.localName == "toolbarspring") { + aWrapper.setAttribute("type", "spring"); + } else if (aItem.localName == "toolbarspacer") { + aWrapper.setAttribute("type", "spacer"); + } else if (aItem.localName == "toolbaritem" && aItem.firstElementChild) { + aWrapper.setAttribute("type", aItem.firstElementChild.localName); + } +} + +function setDragActive(aItem, aValue) { + var node = aItem; + var direction = window.getComputedStyle(aItem).direction; + var value = direction == "ltr" ? "left" : "right"; + if (aItem.localName == "toolbar") { + node = aItem.lastElementChild; + value = direction == "ltr" ? "right" : "left"; + } + + if (!node) { + return; + } + + if (aValue) { + if (!node.hasAttribute("dragover")) { + node.setAttribute("dragover", value); + } + } else { + node.removeAttribute("dragover"); + } +} + +/** + * Restore the default set of buttons to fixed toolbars, + * remove all custom toolbars, and rebuild the palette. + */ +function restoreDefaultSet() { + // Unwrap the items on the toolbar. + unwrapToolbarItems(); + + // Remove all of the customized toolbars. + var child = gToolbox.lastElementChild; + while (child) { + if (child.hasAttribute("customindex")) { + var thisChild = child; + child = child.previousElementSibling; + thisChild.currentSet = "__empty"; + gToolbox.removeChild(thisChild); + } else { + child = child.previousElementSibling; + } + } + + // Restore the defaultset for fixed toolbars. + forEachCustomizableToolbar(function (toolbar) { + var defaultSet = toolbar.getAttribute("defaultset"); + if (defaultSet) { + toolbar.currentSet = defaultSet; + } + }); + + // Restore the default icon size and mode. + document.getElementById("smallicons").checked = updateIconSize() == "small"; + document.getElementById("modelist").value = updateToolbarMode(); + + // Now rebuild the palette. + buildPalette(); + + // Now re-wrap the items on the toolbar. + wrapToolbarItems(); + + toolboxChanged("reset"); +} + +function updateIconSize(aSize) { + return updateToolboxProperty("iconsize", aSize, "large"); +} + +function updateTitlebar() { + let titlebarCheckbox = document.getElementById("showTitlebar"); + Services.prefs.setBoolPref( + "mail.tabs.drawInTitlebar", + !titlebarCheckbox.checked + ); + + // Bring the customizeToolbar window to front (on linux it's behind the main + // window). Otherwise the customization window gets left in the background. + setTimeout(() => window.focus(), 100); +} + +function updateToolbarMode(aModeValue) { + var mode = updateToolboxProperty("mode", aModeValue, "icons"); + + var iconSizeCheckbox = document.getElementById("smallicons"); + iconSizeCheckbox.disabled = mode == "text"; + + return mode; +} + +function updateToolboxProperty(aProp, aValue, aToolkitDefault) { + var toolboxDefault = + gToolbox.getAttribute("default" + aProp) || aToolkitDefault; + + gToolbox.setAttribute(aProp, aValue || toolboxDefault); + Services.xulStore.persist(gToolbox, aProp); + + forEachCustomizableToolbar(function (toolbar) { + var toolbarDefault = + toolbar.getAttribute("default" + aProp) || toolboxDefault; + if ( + toolbar.getAttribute("lock" + aProp) == "true" && + toolbar.getAttribute(aProp) == toolbarDefault + ) { + return; + } + + toolbar.setAttribute(aProp, aValue || toolbarDefault); + Services.xulStore.persist(toolbar, aProp); + }); + + toolboxChanged(aProp); + + return aValue || toolboxDefault; +} + +function forEachCustomizableToolbar(callback) { + if (window.frameElement && "externalToolbars" in window.frameElement) { + Array.from(window.frameElement.externalToolbars) + .filter(isCustomizableToolbar) + .forEach(callback); + } else if ("arguments" in window && window.arguments[1].length > 0) { + Array.from(window.arguments[1]) + .filter(isCustomizableToolbar) + .forEach(callback); + } + Array.from(gToolbox.children).filter(isCustomizableToolbar).forEach(callback); +} + +function isCustomizableToolbar(aElt) { + return ( + aElt.localName == "toolbar" && aElt.getAttribute("customizable") == "true" + ); +} + +function isSpecialItem(aElt) { + return ( + aElt.localName == "toolbarseparator" || + aElt.localName == "toolbarspring" || + aElt.localName == "toolbarspacer" + ); +} + +function isToolbarItem(aElt) { + return ( + aElt.localName == "toolbarbutton" || + aElt.localName == "toolbaritem" || + aElt.localName == "toolbarseparator" || + aElt.localName == "toolbarspring" || + aElt.localName == "toolbarspacer" + ); +} + +// Drag and Drop observers + +function onToolbarDragLeave(aEvent) { + if (isUnwantedDragEvent(aEvent)) { + return; + } + + if (gCurrentDragOverItem) { + setDragActive(gCurrentDragOverItem, false); + } +} + +function onToolbarDragStart(aEvent) { + var item = aEvent.target; + while (item && item.localName != "toolbarpaletteitem") { + if (item.localName == "toolbar") { + return; + } + item = item.parentNode; + } + + item.setAttribute("dragactive", "true"); + + var dt = aEvent.dataTransfer; + var documentId = gToolboxDocument.documentElement.id; + dt.setData("text/toolbarwrapper-id/" + documentId, item.firstElementChild.id); + dt.effectAllowed = "move"; +} + +function onToolbarDragOver(aEvent) { + if (isUnwantedDragEvent(aEvent)) { + return; + } + + var documentId = gToolboxDocument.documentElement.id; + if ( + !aEvent.dataTransfer.types.includes( + "text/toolbarwrapper-id/" + documentId.toLowerCase() + ) + ) { + return; + } + + var toolbar = aEvent.target; + var dropTarget = aEvent.target; + while (toolbar && toolbar.localName != "toolbar") { + dropTarget = toolbar; + toolbar = toolbar.parentNode; + } + + // Make sure we are dragging over a customizable toolbar. + if (!toolbar || !isCustomizableToolbar(toolbar)) { + gCurrentDragOverItem = null; + return; + } + + var previousDragItem = gCurrentDragOverItem; + + if (dropTarget.localName == "toolbar") { + gCurrentDragOverItem = dropTarget; + } else { + gCurrentDragOverItem = null; + + var direction = window.getComputedStyle(dropTarget.parentNode).direction; + var boundingRect = dropTarget.getBoundingClientRect(); + var dropTargetCenter = boundingRect.x + boundingRect.width / 2; + var dragAfter; + if (direction == "ltr") { + dragAfter = aEvent.clientX > dropTargetCenter; + } else { + dragAfter = aEvent.clientX < dropTargetCenter; + } + + if (dragAfter) { + gCurrentDragOverItem = dropTarget.nextElementSibling; + if (!gCurrentDragOverItem) { + gCurrentDragOverItem = toolbar; + } + } else { + gCurrentDragOverItem = dropTarget; + } + } + + if (previousDragItem && gCurrentDragOverItem != previousDragItem) { + setDragActive(previousDragItem, false); + } + + setDragActive(gCurrentDragOverItem, true); + + aEvent.preventDefault(); + aEvent.stopPropagation(); +} + +function onToolbarDrop(aEvent) { + if (isUnwantedDragEvent(aEvent)) { + return; + } + + if (!gCurrentDragOverItem) { + return; + } + + setDragActive(gCurrentDragOverItem, false); + + var documentId = gToolboxDocument.documentElement.id; + var draggedItemId = aEvent.dataTransfer.getData( + "text/toolbarwrapper-id/" + documentId + ); + if (gCurrentDragOverItem.id == draggedItemId) { + return; + } + + var toolbar = aEvent.target; + while (toolbar.localName != "toolbar") { + toolbar = toolbar.parentNode; + } + + var draggedPaletteWrapper = document.getElementById( + "wrapper-" + draggedItemId + ); + if (!draggedPaletteWrapper) { + // The wrapper has been dragged from the toolbar. + // Get the wrapper from the toolbar document and make sure that + // it isn't being dropped on itself. + let wrapper = gToolboxDocument.getElementById("wrapper-" + draggedItemId); + if (wrapper == gCurrentDragOverItem) { + return; + } + + // Don't allow non-removable kids (e.g., the menubar) to move. + if (wrapper.firstElementChild.getAttribute("removable") != "true") { + return; + } + + // Remove the item from its place in the toolbar. + wrapper.remove(); + + // Determine which toolbar we are dropping on. + var dropToolbar = null; + if (gCurrentDragOverItem.localName == "toolbar") { + dropToolbar = gCurrentDragOverItem; + } else { + dropToolbar = gCurrentDragOverItem.parentNode; + } + + // Insert the item into the toolbar. + if (gCurrentDragOverItem != dropToolbar) { + dropToolbar.insertBefore(wrapper, gCurrentDragOverItem); + } else { + dropToolbar.appendChild(wrapper); + } + } else { + // The item has been dragged from the palette + + // Create a new wrapper for the item. We don't know the id yet. + let wrapper = createWrapper("", gToolboxDocument); + + // Ask the toolbar to clone the item's template, place it inside the wrapper, and insert it in the toolbar. + var newItem = toolbar.insertItem( + draggedItemId, + gCurrentDragOverItem == toolbar ? null : gCurrentDragOverItem, + wrapper + ); + + // Prepare the item and wrapper to look good on the toolbar. + cleanupItemForToolbar(newItem, wrapper); + wrapper.id = "wrapper-" + newItem.id; + wrapper.flex = newItem.flex; + + // Remove the wrapper from the palette. + if ( + draggedItemId != "separator" && + draggedItemId != "spring" && + draggedItemId != "spacer" + ) { + gPaletteBox.removeChild(draggedPaletteWrapper); + } + } + + gCurrentDragOverItem = null; + + toolboxChanged(); +} + +function onPaletteDragOver(aEvent) { + if (isUnwantedDragEvent(aEvent)) { + return; + } + var documentId = gToolboxDocument.documentElement.id; + if ( + aEvent.dataTransfer.types.includes( + "text/toolbarwrapper-id/" + documentId.toLowerCase() + ) + ) { + aEvent.preventDefault(); + } +} + +function onPaletteDrop(aEvent) { + if (isUnwantedDragEvent(aEvent)) { + return; + } + var documentId = gToolboxDocument.documentElement.id; + var itemId = aEvent.dataTransfer.getData( + "text/toolbarwrapper-id/" + documentId + ); + + var wrapper = gToolboxDocument.getElementById("wrapper-" + itemId); + if (wrapper) { + // Don't allow non-removable kids (e.g., the menubar) to move. + if (wrapper.firstElementChild.getAttribute("removable") != "true") { + return; + } + + var wrapperType = wrapper.getAttribute("type"); + if ( + wrapperType != "separator" && + wrapperType != "spacer" && + wrapperType != "spring" + ) { + restoreItemForToolbar(wrapper.firstElementChild, wrapper); + wrapPaletteItem(document.importNode(wrapper.firstElementChild, true)); + gToolbox.palette.appendChild(wrapper.firstElementChild); + } + + // The item was dragged out of the toolbar. + wrapper.remove(); + } + + toolboxChanged(); +} + +function isUnwantedDragEvent(aEvent) { + try { + if ( + Services.prefs.getBoolPref("toolkit.customization.unsafe_drag_events") + ) { + return false; + } + } catch (ex) {} + + // Discard drag events that originated from a separate window to + // prevent content->chrome privilege escalations. + let mozSourceNode = aEvent.dataTransfer.mozSourceNode; + // mozSourceNode is null in the dragStart event handler or if + // the drag event originated in an external application. + if (!mozSourceNode) { + return true; + } + let sourceWindow = mozSourceNode.ownerGlobal; + return sourceWindow != window && sourceWindow != gToolboxDocument.defaultView; +} diff --git a/comm/mail/base/content/customizeToolbar.xhtml b/comm/mail/base/content/customizeToolbar.xhtml new file mode 100644 index 0000000000..8810a44ee9 --- /dev/null +++ b/comm/mail/base/content/customizeToolbar.xhtml @@ -0,0 +1,105 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<!DOCTYPE dialog [ <!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd"> +%customizeToolbarDTD; ]> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/customizeToolbar.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/primaryToolbar.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/messengercompose/messengercompose.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/calendar-task-view.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-toolbar.css" type="text/css"?> + +<window + id="CustomizeToolbarWindow" + title="&dialog.title;" + lightweightthemes="true" + windowtype="mailnews:customizeToolbar" + onload="overlayOnLoad();" + onunload="onUnload();" + style="max-width: 92ch; min-height: 36em" + persist="width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <script src="chrome://messenger/content/customizeToolbar.js" /> + <script src="chrome://messenger/content/mailCore.js" /> + <stringbundle + id="stringBundle" + src="chrome://messenger/locale/customizeToolbar.properties" + /> + + <keyset id="CustomizeToolbarKeyset"> + <key id="cmd_close1" keycode="VK_ESCAPE" oncommand="onClose();" /> + <key id="cmd_close2" keycode="VK_RETURN" oncommand="onClose();" /> + </keyset> + + <vbox id="main-box" flex="1"> + <description id="instructions"> &instructions.description; </description> + + <vbox + flex="1" + id="palette-box" + ondragstart="onToolbarDragStart(event)" + ondragover="onPaletteDragOver(event)" + ondrop="onPaletteDrop(event)" + /> + + <hbox id="buttonBox" align="center"> + <hbox id="titlebarSettings" hidden="true"> + <checkbox + id="showTitlebar" + oncommand="updateTitlebar();" + label="&showTitlebar2.label;" + /> + </hbox> + <label id="modelistLabel" value="&show.label;" control="modelist" /> + <menulist + id="modelist" + value="icons" + oncommand="overlayUpdateToolbarMode(this.value, 'mail-toolbox');" + > + <menupopup id="modelistpopup"> + <menuitem id="modefull" value="full" label="&iconsAndText.label;" /> + <menuitem id="modeicons" value="icons" label="&icons.label;" /> + <menuitem id="modetext" value="text" label="&text.label;" /> + <menuitem + id="textbesideiconItem" + value="textbesideicon" + label="&iconsBesideText.label;" + /> + </menupopup> + </menulist> + <checkbox + id="smallicons" + oncommand="updateIconSize(this.checked ? 'small' : 'large');" + label="&useSmallIcons.label;" + /> + </hbox> + <hbox align="center"> + <button + id="restoreDefault" + label="&restoreDefaultSet.label;" + oncommand="restoreDefaultSet();" + /> + <spacer flex="1" /> + <button + id="donebutton" + label="&saveChanges.label;" + oncommand="onClose();" + default="true" + /> + </hbox> + </vbox> +</window> diff --git a/comm/mail/base/content/dialogShadowDom.js b/comm/mail/base/content/dialogShadowDom.js new file mode 100644 index 0000000000..447aa87603 --- /dev/null +++ b/comm/mail/base/content/dialogShadowDom.js @@ -0,0 +1,14 @@ +/* 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/. */ + +/** + * When the dialog window loads, add a stylesheet to the shadow DOM of the + * dialog to style the accept and cancel buttons, etc. + */ +window.addEventListener("load", () => { + let link = document.createElement("link"); + link.setAttribute("rel", "stylesheet"); + link.setAttribute("href", "chrome://messenger/skin/themeableDialog.css"); + document.querySelector("dialog").shadowRoot.appendChild(link); +}); diff --git a/comm/mail/base/content/editContactPanel.inc.xhtml b/comm/mail/base/content/editContactPanel.inc.xhtml new file mode 100644 index 0000000000..4580648eca --- /dev/null +++ b/comm/mail/base/content/editContactPanel.inc.xhtml @@ -0,0 +1,75 @@ +# 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/. + +<html:template id="editContactPanelTemplate"> +<panel id="editContactPanel" + type="arrow" + orient="vertical" + class="cui-widget-panel popup-panel panel-no-padding" + ignorekeys="true" + aria-labelledby="editContactPanelTitle" + onpopuphidden="editContactInlineUI.onPopupHidden(event);" + onpopupshown="editContactInlineUI.onPopupShown(event);" + onkeypress="editContactInlineUI.onKeyPress(event, true);"> + <html:div class="popup-panel-body"> + <html:div id="editContactHeader"> + <html:img id="editContactPanelIcon" + src="chrome://messenger/skin/icons/new/normal/address-book.svg" + alt="" /> + <html:h3 id="editContactPanelTitle" flex="1"></html:h3> + </html:div> + + <box id="editContactContent"> + <hbox pack="end"> + <label value="&editContactName.label;" + class="editContactPanel_rowLabel" + accesskey="&editContactName.accesskey;" + control="editContactName"/> + </hbox> + <html:input id="editContactName" class="editContactTextbox" type="text" + onkeypress="editContactInlineUI.onKeyPress(event, true);"/> + <hbox pack="end"> + <label value="&editContactEmail.label;" + class="editContactPanel_rowLabel" + accesskey="&editContactEmail.accesskey;" + control="editContactEmail"/> + </hbox> + <html:input id="editContactEmail" readonly="readonly" + class="editContactTextbox" type="email" + onkeypress="editContactInlineUI.onKeyPress(event, true);"/> + <hbox pack="end"> + <label id="editContactAddressBook" + class="editContactPanel_rowLabel" + value="&editContactAddressBook.label;" + accesskey="&editContactAddressBook.accesskey;" + control="editContactAddressBookList"/> + </hbox> + <menulist is="menulist-addrbooks" + id="editContactAddressBookList" + flex="1"/> + <label value="" collapsed="true"/> + <description id="contactMoveDisabledText" hidden="true"> + &contactMoveDisabledWarning.description; + </description> + </box> + + <html:div class="popup-panel-buttons-container"> + <button id="editContactPanelEditDetailsButton" + oncommand="editContactInlineUI.editDetails();" + onkeypress="editContactInlineUI.onKeyPress(event, false);"/> + <button id="editContactPanelDeleteContactButton" + label="&editContactPanelDeleteContact.label;" + accesskey="&editContactPanelDeleteContact.accesskey;" + oncommand="editContactInlineUI.deleteContact();" + onkeypress="editContactInlineUI.onKeyPress(event, false);"/> + <button id="editContactPanelDoneButton" + class="primary" + label="&editContactPanelDone.label;" + accesskey="&editContactPanelDone.accesskey;" + oncommand="editContactInlineUI.saveChanges();" + onkeypress="editContactInlineUI.onKeyPress(event, false);"/> + </html:div> + </html:div> +</panel> +</html:template> diff --git a/comm/mail/base/content/editContactPanel.js b/comm/mail/base/content/editContactPanel.js new file mode 100644 index 0000000000..40e1061167 --- /dev/null +++ b/comm/mail/base/content/editContactPanel.js @@ -0,0 +1,248 @@ +/* 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 { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +var editContactInlineUI = { + _overlayLoaded: false, + _overlayLoading: false, + _cardDetails: null, + _writeable: true, + _blockedCommands: ["cmd_close"], + + _blockCommands() { + for (var i = 0; i < this._blockedCommands; ++i) { + var elt = document.getElementById(this._blockedCommands[i]); + // make sure not to permanetly disable this item + if (elt.hasAttribute("wasDisabled")) { + continue; + } + + if (elt.getAttribute("disabled") == "true") { + elt.setAttribute("wasDisabled", "true"); + } else { + elt.setAttribute("wasDisabled", "false"); + elt.setAttribute("disabled", "true"); + } + } + }, + + _restoreCommandsState() { + for (var i = 0; i < this._blockedCommands; ++i) { + var elt = document.getElementById(this._blockedCommands[i]); + if (elt.getAttribute("wasDisabled") != "true") { + elt.removeAttribute("disabled"); + } + elt.removeAttribute("wasDisabled"); + } + document.getElementById("editContactAddressBookList").disabled = false; + document.getElementById("contactMoveDisabledText").hidden = true; + }, + + onPopupHidden(aEvent) { + if (aEvent.target == this.panel) { + this._restoreCommandsState(); + } + }, + + onPopupShown(aEvent) { + if (aEvent.target == this.panel) { + document.getElementById("editContactName").focus(); + } + }, + + onKeyPress(aEvent, aHandleOnlyReadOnly) { + // Escape should just close this panel + if (aEvent.keyCode == KeyEvent.DOM_VK_ESCAPE) { + this.panel.hidePopup(); + return; + } + + // Return does the default button (done) + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) { + if (!aEvent.target.hasAttribute("oncommand")) { + this.saveChanges(); + } + return; + } + + // Only handle the read-only cases here. + if (aHandleOnlyReadOnly && this._writeable && !aEvent.target.readOnly) { + return; + } + + // Any other character and we prevent the default, this stops us doing + // things in the main message window. + if (aEvent.charCode) { + aEvent.preventDefault(); + } + }, + + get panel() { + // The panel is initially stored in a template for performance reasons. + // Load it into the DOM now. + delete this.panel; + let template = document.getElementById("editContactPanelTemplate"); + template.replaceWith(template.content); + let element = document.getElementById("editContactPanel"); + return (this.panel = element); + }, + + showEditContactPanel(aCardDetails, aAnchorElement) { + this._cardDetails = aCardDetails; + let position = "after_start"; + this._doShowEditContactPanel(aAnchorElement, position); + }, + + _doShowEditContactPanel(aAnchorElement, aPosition) { + this._blockCommands(); // un-done in the popuphiding handler. + var bundle = Services.strings.createBundle( + "chrome://messenger/locale/editContactOverlay.properties" + ); + + // Is this address book writeable? + this._writeable = !this._cardDetails.book.readOnly; + var type = this._writeable ? "edit" : "view"; + + // Force the panel to be created from the template, if necessary. + this.panel; + + // Update the labels accordingly. + document.getElementById("editContactPanelTitle").textContent = + bundle.GetStringFromName(type + "Title"); + document.getElementById("editContactPanelEditDetailsButton").label = + bundle.GetStringFromName(type + "DetailsLabel"); + document.getElementById("editContactPanelEditDetailsButton").accessKey = + bundle.GetStringFromName(type + "DetailsAccessKey"); + + // We don't need a delete button for a read only card. + document.getElementById("editContactPanelDeleteContactButton").hidden = + !this._writeable; + + var nameElement = document.getElementById("editContactName"); + + // Set these to read only if we can't write to the directory. + if (this._writeable) { + nameElement.removeAttribute("readonly"); + nameElement.class = "editContactTextbox"; + } else { + nameElement.setAttribute("readonly", "readonly"); + nameElement.class = "plain"; + } + + // Fill in the card details + nameElement.value = this._cardDetails.card.displayName; + document.getElementById("editContactEmail").value = + aAnchorElement.getAttribute("emailAddress") || + aAnchorElement.emailAddress; + + document.getElementById("editContactAddressBookList").value = + this._cardDetails.book.URI; + + // Is this card contained within mailing lists? + let inMailList = false; + if (this._cardDetails.book.supportsMailingLists) { + // We only have to look in one book here, because cards currently have + // to be in the address book they belong to. + for (let list of this._cardDetails.book.childNodes) { + if (!list.isMailList) { + continue; + } + + for (let card of list.childCards) { + if (card.primaryEmail == this._cardDetails.card.primaryEmail) { + inMailList = true; + break; + } + } + if (inMailList) { + break; + } + } + } + + if (!this._writeable || inMailList) { + document.getElementById("editContactAddressBookList").disabled = true; + } + + if (inMailList) { + document.getElementById("contactMoveDisabledText").hidden = false; + } + + this.panel.openPopup(aAnchorElement, aPosition, -1, -1); + }, + + editDetails() { + this.saveChanges(); + top.toAddressBook({ action: "edit", card: this._cardDetails.card }); + }, + + deleteContact() { + if (this._cardDetails.book.readOnly) { + // Double check we can delete this. + return; + } + + // Hide before the dialog or the panel takes the first click. + this.panel.hidePopup(); + + var bundle = Services.strings.createBundle( + "chrome://messenger/locale/editContactOverlay.properties" + ); + if ( + !Services.prompt.confirm( + window, + bundle.GetStringFromName("deleteContactTitle"), + bundle.GetStringFromName("deleteContactMessage") + ) + ) { + // XXX Would be nice to bring the popup back up here. + return; + } + + MailServices.ab + .getDirectory(this._cardDetails.book.URI) + .deleteCards([this._cardDetails.card]); + }, + + saveChanges() { + // If we're a popup dialog, just hide the popup and return + if (!this._writeable) { + this.panel.hidePopup(); + return; + } + + let originalBook = this._cardDetails.book; + + let abURI = document.getElementById("editContactAddressBookList").value; + if (abURI != originalBook.URI) { + this._cardDetails.book = MailServices.ab.getDirectory(abURI); + } + + // We can assume the email address stays the same, so just update the name + var newName = document.getElementById("editContactName").value; + if (newName != this._cardDetails.card.displayName) { + this._cardDetails.card.displayName = newName; + this._cardDetails.card.setProperty("PreferDisplayName", true); + } + + // Save the card + if (this._cardDetails.book.hasCard(this._cardDetails.card)) { + // Address book wasn't changed. + this._cardDetails.book.modifyCard(this._cardDetails.card); + } else { + // We changed address books for the card. + + // Add it to the chosen address book... + this._cardDetails.book.addCard(this._cardDetails.card); + + // ...and delete it from the old place. + originalBook.deleteCards([this._cardDetails.card]); + } + + this.panel.hidePopup(); + }, +}; diff --git a/comm/mail/base/content/folderDisplay.js b/comm/mail/base/content/folderDisplay.js new file mode 100644 index 0000000000..dfd5824c6b --- /dev/null +++ b/comm/mail/base/content/folderDisplay.js @@ -0,0 +1,2649 @@ +/* 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 SearchDialog.js */ + +/* globals ViewPickerBinding */ // From msgViewPickerOverlay.js + +/* TODO: Now used exclusively in SearchDialog.xhtml. Needs dead code removal. */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + TreeSelection: "chrome://messenger/content/tree-selection.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + DBViewWrapper: "resource:///modules/DBViewWrapper.jsm", +}); + +var gDBView; +var nsMsgKey_None = 0xffffffff; +var nsMsgViewIndex_None = 0xffffffff; + +/** + * Maintains a list of listeners for all FolderDisplayWidget instances in this + * window. The assumption is that because of our multiplexed tab + * implementation all consumers are effectively going to care about all such + * tabs. + * + * We are not just a global list so that we can add brains about efficiently + * building lists, provide try-wrapper convenience, etc. + */ +var FolderDisplayListenerManager = { + _listeners: [], + + /** + * Register a listener that implements one or more of the methods defined on + * |IDBViewWrapperListener|. Note that a change from those interface + * signatures is that the first argument is always a reference to the + * FolderDisplayWidget generating the notification. + * + * We additionally support the following notifications: + * - onMakeActive. Invoked when makeActive is called on the + * FolderDisplayWidget. The second argument (after the folder display) is + * aWasInactive. + * + * - onActiveCreatedView. onCreatedView deferred to when the tab is actually + * made active. + * + * - onActiveMessagesLoaded. onMessagesLoaded deferred to when the + * tab is actually made active. Use this if the actions you need to take + * are based on the folder display actually being visible, such as updating + * some UI widget, etc. Not all messages may have been loaded, but some. + * + */ + registerListener(aListener) { + this._listeners.push(aListener); + }, + + /** + * Unregister a previously registered event listener. + */ + unregisterListener(aListener) { + let idx = this._listeners.indexOf(aListener); + if (idx >= 0) { + this._listeners.splice(idx, 1); + } + }, + + /** + * For use by FolderDisplayWidget to trigger listener invocation. + */ + _fireListeners(aEventName, aArgs) { + for (let listener of this._listeners) { + if (aEventName in listener) { + try { + listener[aEventName].apply(listener, aArgs); + } catch (e) { + console.error( + aEventName + " event listener FAILED; " + e + " at: " + e.stack + ); + } + } + } + }, +}; + +/** + * Abstraction for a widget that (roughly speaking) displays the contents of + * folders. The widget belongs to a tab and has a lifetime as long as the tab + * that contains it. This class is strictly concerned with the UI aspects of + * this; the DBViewWrapper class handles the view details (and is exposed on + * the 'view' attribute.) + * + * The search window subclasses this into the SearchFolderDisplayWidget rather + * than us attempting to generalize everything excessively. This is because + * we hate the search window and don't want to clutter up this code for it. + * The standalone message display window also subclasses us; we do not hate it, + * but it's not invited to our birthday party either. + * For reasons of simplicity and the original order of implementation, this + * class does alter its behavior slightly for the benefit of the standalone + * message window. If no tab info is provided, we avoid touching tabmail + * (which is good, because it won't exist!) And now we guard against treeBox + * manipulations... + */ +function FolderDisplayWidget() { + // If the folder does not get handled by the DBViewWrapper, stash it here. + // ex: when isServer is true. + this._nonViewFolder = null; + + this.view = new DBViewWrapper(this); + + /** + * The XUL tree node, as retrieved by getDocumentElementById. The caller is + * responsible for setting this. + */ + this.tree = null; + + /** + * The nsIMsgWindow corresponding to the window that holds us. There is only + * one of these per tab. The caller is responsible for setting this. + */ + this.msgWindow = null; + /** + * The nsIMessenger instance that corresponds to our tab/window. We do not + * use this ourselves, but are responsible for using it to update the + * global |messenger| object so that our tab maintains its own undo and + * navigation history. At some point we might touch it for those reasons. + */ + this.messenger = null; + this.threadPaneCommandUpdater = this; + + /** + * Flag to expose whether all messages are loaded or not. Set by + * onMessagesLoaded() when aAll is true. + */ + this._allMessagesLoaded = false; + + /** + * Save the top row displayed when we go inactive, restore when we go active, + * nuke it when we destroy the view. + */ + this._savedFirstVisibleRow = null; + /** the next view index to select once the delete completes */ + this._nextViewIndexAfterDelete = null; + /** + * Track when a mass move is in effect (we get told by hintMassMoveStarting, + * and hintMassMoveCompleted) so that we can avoid deletion-triggered + * moving to _nextViewIndexAfterDelete until the mass move completes. + */ + this._massMoveActive = false; + /** + * Track when a message is being deleted so we can respond appropriately. + */ + this._deleteInProgress = false; + + /** + * Used by pushNavigation to queue a navigation request for when we enter the + * next folder; onMessagesLoaded(true) is the one that processes it. + */ + this._pendingNavigation = null; + + this._active = false; + /** + * A list of methods to call on 'this' object when we are next made active. + * This list is populated by calls to |_notifyWhenActive| when we are + * not active at the moment. + */ + this._notificationsPendingActivation = []; + + this._mostRecentSelectionCounts = []; + this._mostRecentCurrentIndices = []; +} +FolderDisplayWidget.prototype = { + /** + * @returns the currently displayed folder. This is just proxied from the + * view wrapper. + * @groupName Displayed + */ + get displayedFolder() { + return this._nonViewFolder || this.view.displayedFolder; + }, + + /** + * @returns true if the selection should be summarized for this folder. This + * is based on the mail.operate_on_msgs_in_collapsed_threads pref and + * if we are in a newsgroup folder. XXX When bug 478167 is fixed, this + * should be limited to being disabled for newsgroups that are not stored + * offline. + */ + get summarizeSelectionInFolder() { + return ( + Services.prefs.getBoolPref("mail.operate_on_msgs_in_collapsed_threads") && + !(this.displayedFolder instanceof Ci.nsIMsgNewsFolder) + ); + }, + + /** + * @returns the nsITreeSelection object for our tree view. This exists for + * the benefit of message tabs that haven't been switched to yet. + * We provide a fake tree selection in those cases. + * @protected + */ + get treeSelection() { + // If we haven't switched to this tab yet, dbView will exist but + // dbView.selection won't, so use the fake tree selection instead. + if (this.view.dbView) { + return this.view.dbView.selection; + } + return null; + }, + + /** + * Determine which pane currently has focus (one of the folder pane, thread + * pane, or message pane). The message pane node is the common ancestor of + * the single- and multi-message content windows. When changing focus to the + * message pane, be sure to focus the appropriate content window in addition + * to the messagepanebox (doing both is required in order to blur the + * previously-focused chrome element). + * + * @returns the focused pane + */ + get focusedPane() { + let panes = ["threadTree", "folderTree", "messagepanebox"].map(id => + document.getElementById(id) + ); + + let currentNode = top.document.activeElement; + + while (currentNode) { + if (panes.includes(currentNode)) { + return currentNode; + } + + currentNode = currentNode.parentNode; + } + return null; + }, + + /** + * Number of headers to tell the message database to cache when we enter a + * folder. This value is being propagated from legacy code which provided + * no explanation for its choice. + * + * We definitely want the header cache size to be larger than the number of + * rows that can be displayed on screen simultaneously. + * + * @private + */ + PERF_HEADER_CACHE_SIZE: 100, + + /** + * @name Selection Persistence + * @private + */ + // @{ + + /** + * An optional object, with the following properties: + * - messages: This is a list where each item is an object with the following + * attributes sufficient to re-establish the selected items even in the + * face of folder renaming. + * - messageId: The value of the message's message-id header. + * + * That's right, we only save the message-id header value. This is arguably + * overkill and ambiguous in the face of duplicate messages, but it's the + * most persistent/reliable thing we have without gloda. + * Using the view index was ruled out because it is hardly stable. Using the + * message key alone is insufficient for cross-folder searches. Using a + * folder identifier and message key is insufficient for local folders in the + * face of compaction, let alone complexities where the folder name may + * change due to renaming/moving. Which means we eventually need to fall + * back to message-id anyways. Feel free to add in lots of complexity if + * you actually write unit tests for all the many possible cases. + * Additional justification is that selection saving/restoration should not + * happen all that frequently. A nice freebie is that message-id is + * definitely persistable. + * + * - forceSelect: Whether we are allowed to drop all filters in our quest to + * select messages. + */ + _savedSelection: null, + + /** + * Save the current view selection for when we the view is getting destroyed + * or otherwise re-ordered in such a way that the nsITreeSelection will lose + * track of things (because it just has a naive view-index 'view' of the + * world.) We just save each message's message-id header. This is overkill + * and ambiguous in the face of duplicate messages (and expensive to + * restore), but is also the most reliable option for this use case. + */ + _saveSelection() { + this._savedSelection = { + messages: this.selectedMessages.map(msgHdr => ({ + messageId: msgHdr.messageId, + })), + forceSelect: false, + }; + }, + + /** + * Clear the saved selection. + */ + _clearSavedSelection() { + this._savedSelection = null; + }, + + /** + * Restore the view selection if we have a saved selection. We must be + * active! + * + * @returns true if we were able to restore the selection and there was + * a selection, false if there was no selection (anymore). + */ + _restoreSelection() { + if (!this._savedSelection || !this._active) { + return false; + } + + // translate message IDs back to messages. this is O(s(m+n)) where: + // - s is the number of messages saved in the selection + // - m is the number of messages in the view (from findIndexOfMsgHdr) + // - n is the number of messages in the underlying folders (from + // DBViewWrapper.getMsgHdrForMessageID). + // which ends up being O(sn) + let messages = this._savedSelection.messages + .map(savedInfo => this.view.getMsgHdrForMessageID(savedInfo.messageId)) + .filter(msgHdr => !!msgHdr); + + this.selectMessages(messages, this._savedSelection.forceSelect, true); + this._savedSelection = null; + + return this.selectedCount != 0; + }, + + /** + * Restore the last expandAll/collapseAll state, for both grouped and threaded + * views. Not all views respect viewFlags, ie single folder non-virtual. + */ + restoreThreadState() { + if (!this._active || !this.tree || !this.view.dbView.viewFolder) { + return; + } + + if ( + this.view._threadExpandAll && + !(this.view.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll) + ) { + this.view.dbView.doCommand(Ci.nsMsgViewCommandType.expandAll); + } + if ( + !this.view._threadExpandAll && + this.view.dbView.viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ) { + this.view.dbView.doCommand(Ci.nsMsgViewCommandType.collapseAll); + } + }, + // @} + + /** + * @name Columns + * @protected + */ + // @{ + + /** + * The map of all stock sortable columns and their sortType. The key must + * match the column's xul <treecol> id. + */ + COLUMNS_MAP: new Map([ + ["accountCol", "byAccount"], + ["attachmentCol", "byAttachments"], + ["senderCol", "byAuthor"], + ["correspondentCol", "byCorrespondent"], + ["dateCol", "byDate"], + ["flaggedCol", "byFlagged"], + ["idCol", "byId"], + ["junkStatusCol", "byJunkStatus"], + ["locationCol", "byLocation"], + ["priorityCol", "byPriority"], + ["receivedCol", "byReceived"], + ["recipientCol", "byRecipient"], + ["sizeCol", "bySize"], + ["statusCol", "byStatus"], + ["subjectCol", "bySubject"], + ["tagsCol", "byTags"], + ["threadCol", "byThread"], + ["unreadButtonColHeader", "byUnread"], + ]), + + /** + * The map of stock non-sortable columns. The key must match the column's + * xul <treecol> id. + */ + COLUMNS_MAP_NOSORT: new Set([ + "selectCol", + "totalCol", + "unreadCol", + "deleteCol", + ]), + + /** + * The set of potential default columns in their default display order. Each + * column in this list is checked against |COLUMN_DEFAULT_TESTERS| to see if + * it is actually an appropriate default for the folder type. + */ + DEFAULT_COLUMNS: [ + "threadCol", + "attachmentCol", + "flaggedCol", + "subjectCol", + "unreadButtonColHeader", + "senderCol", // news folders or incoming folders when correspondents not in use + "recipientCol", // outgoing folders when correspondents not in use + "correspondentCol", // mail folders + "junkStatusCol", + "dateCol", + "locationCol", // multiple-folder backed folders + ], + + /** + * Maps column ids to functions that test whether the column is a good default + * for display for the folder. Each function should expect a DBViewWrapper + * instance as its argument. The intent is that the various helper + * properties like isMailFolder/isIncomingFolder/isOutgoingFolder allow the + * constraint to be expressed concisely. If a helper does not exist, add + * one! (If doing so is out of reach, than access viewWrapper.displayedFolder + * to get at the nsIMsgFolder.) + * If a column does not have a function, it is assumed that it should be + * displayed by default. + */ + COLUMN_DEFAULT_TESTERS: { + correspondentCol(viewWrapper) { + if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) { + // Don't show the correspondent for news or RSS where it doesn't make sense. + return viewWrapper.isMailFolder && !viewWrapper.isFeedFolder; + } + return false; + }, + senderCol(viewWrapper) { + if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) { + // Show the sender even if correspondent is enabled for news and feeds. + return viewWrapper.isNewsFolder || viewWrapper.isFeedFolder; + } + // senderCol = From. You only care in incoming folders. + return viewWrapper.isIncomingFolder; + }, + recipientCol(viewWrapper) { + if (Services.prefs.getBoolPref("mail.threadpane.use_correspondents")) { + // No recipient column if we use correspondent. + return false; + } + // recipientCol = To. You only care in outgoing folders. + return viewWrapper.isOutgoingFolder; + }, + // Only show the location column for non-single-folder results + locationCol(viewWrapper) { + return !viewWrapper.isSingleFolder; + }, + // core UI does not provide an ability to mark newsgroup messages as spam + junkStatusCol(viewWrapper) { + return !viewWrapper.isNewsFolder; + }, + }, + + /** + * The property name we use to store the column states on the + * dbFolderInfo. + */ + PERSISTED_COLUMN_PROPERTY_NAME: "columnStates", + + /** + * Given a dbFolderInfo, extract the persisted state from it if there is any. + * + * @returns null if there was no persisted state, the persisted state in object + * form otherwise. (Ideally the state conforms to the documentation on + * |_savedColumnStates| but we can't stop people from doing bad things.) + */ + _depersistColumnStatesFromDbFolderInfo(aDbFolderInfo) { + let columnJsonString = aDbFolderInfo.getCharProperty( + this.PERSISTED_COLUMN_PROPERTY_NAME + ); + if (!columnJsonString) { + return null; + } + + return JSON.parse(columnJsonString); + }, + + /** + * Persist the column state for the currently displayed folder. We are + * assuming that the message database is already open when we are called and + * therefore that we do not need to worry about cleaning up after the message + * database. + * The caller should only call this when they have reason to suspect that the + * column state has been changed. This could be because there was no + * persisted state so we figured out a default one and want to save it. + * Otherwise this should be because the user explicitly changed up the column + * configurations. You should not call this willy-nilly. + * + * @param aState State to persist. + */ + _persistColumnStates(aState) { + if (this.view.isSynthetic) { + let syntheticView = this.view._syntheticView; + if ("setPersistedSetting" in syntheticView) { + syntheticView.setPersistedSetting("columns", aState); + } + return; + } + + if (!this.view.displayedFolder || !this.view.displayedFolder.msgDatabase) { + return; + } + + let msgDatabase = this.view.displayedFolder.msgDatabase; + let dbFolderInfo = msgDatabase.dBFolderInfo; + dbFolderInfo.setCharProperty( + this.PERSISTED_COLUMN_PROPERTY_NAME, + JSON.stringify(aState) + ); + msgDatabase.commit(Ci.nsMsgDBCommitType.kLargeCommit); + }, + + /** + * Let us know that the state of the columns has changed. This is either due + * to a re-ordering or hidden-ness being toggled. + * + * This method should only be called on (the active) gFolderDisplay. + */ + hintColumnsChanged() { + // ignore this if we are the ones doing things + if (this._touchingColumns) { + return; + } + this._persistColumnStates(this.getColumnStates()); + }, + + /** + * Either inherit the column state of another folder or use heuristics to + * figure out the best column state for the current folder. + */ + _getDefaultColumnsForCurrentFolder(aDoNotInherit) { + // If the view is synthetic, try asking it for its default columns. If it + // fails, just return nothing, since most synthetic views don't care about + // columns anyway. + if (this.view.isSynthetic) { + if ("getDefaultSetting" in this.view._syntheticView) { + return this.view._syntheticView.getDefaultSetting("columns"); + } + return {}; + } + + // do not inherit from the inbox if: + // - It's an outgoing folder; these have a different use-case and there + // should be a small number of these, so it's okay to have no defaults. + // - It's a virtual folder (single or multi-folder backed). Who knows what + // the intent of the user is in this case. This should also be bounded + // in number and our default heuristics should be pretty good. + // - It's a multiple folder; this is either a search view (which has no + // displayed folder) or a virtual folder (which we eliminated above). + // - News folders. There is no inbox so there's nothing to inherit from. + // (Although we could try and see if they have opened any other news + // folders in the same account. But it's not all that important to us.) + // - It's an inbox! + let doNotInherit = + aDoNotInherit || + this.view.isOutgoingFolder || + this.view.isVirtual || + this.view.isMultiFolder || + this.view.isNewsFolder || + this.displayedFolder.getFlag(Ci.nsMsgFolderFlags.Inbox); + + // Try and grab the inbox for this account's settings. we may not be able + // to, in which case we just won't inherit. (It ends up the same since the + // inbox is obviously not customized in this case.) + if (!doNotInherit) { + let inboxFolder = this.displayedFolder.rootFolder.getFolderWithFlags( + Ci.nsMsgFolderFlags.Inbox + ); + if (inboxFolder) { + let state = this._depersistColumnStatesFromDbFolderInfo( + inboxFolder.msgDatabase.dBFolderInfo + ); + // inbox message databases don't get closed as a matter of policy. + + if (state) { + return state; + } + } + } + + // if we are still here, use the defaults and helper functions + let state = {}; + for (let colId of this.DEFAULT_COLUMNS) { + let shouldShowColumn = true; + if (colId in this.COLUMN_DEFAULT_TESTERS) { + // This is potentially going to be used by extensions; avoid them + // killing us. + try { + shouldShowColumn = this.COLUMN_DEFAULT_TESTERS[colId](this.view); + } catch (ex) { + shouldShowColumn = false; + console.error(ex); + } + } + state[colId] = { visible: shouldShowColumn }; + } + return state; + }, + + /** + * Is setColumnStates messing with the columns' DOM? This is used by + * hintColumnsChanged to avoid wasteful state persistence. + */ + _touchingColumns: false, + + /** + * Set the column states of this FolderDisplay to the provided state. + * + * @param aColumnStates an object of the form described on + * |_savedColumnStates|. If ordinal attributes are omitted then no + * re-ordering will be performed. This is intentional, but potentially a + * bad idea. (Right now only gloda search underspecifies ordinals.) + * @param [aPersistChanges=false] Should we persist the changes to the view? + * This only has an effect if we are active. + * + * @public + */ + setColumnStates(aColumnStates, aPersistChanges) { + // If we are not active, just overwrite our current state with the provided + // state and bail. + if (!this._active) { + this._savedColumnStates = aColumnStates; + return; + } + + this._touchingColumns = true; + + try { + let cols = document.getElementById("threadCols"); + let colChildren = cols.children; + + for (let iKid = 0; iKid < colChildren.length; iKid++) { + let colChild = colChildren[iKid]; + if (colChild == null) { + continue; + } + + // We only care about treecols. The splitters do not need to be marked + // hidden or un-hidden. + if (colChild.tagName == "treecol") { + // if it doesn't have preserved state it should be hidden + let shouldBeHidden = true; + // restore state + if (colChild.id in aColumnStates) { + let colState = aColumnStates[colChild.id]; + if ("visible" in colState) { + shouldBeHidden = !colState.visible; + } + if ("ordinal" in colState && colChild.ordinal != colState.ordinal) { + colChild.ordinal = colState.ordinal; + } + } + let isHidden = colChild.hidden; + if (isHidden != shouldBeHidden) { + if (shouldBeHidden) { + colChild.setAttribute("hidden", "true"); + } else { + colChild.removeAttribute("hidden"); + } + } + } + } + } finally { + this._touchingColumns = false; + } + + if (aPersistChanges) { + this.hintColumnsChanged(); + } + }, + + /** + * A dictionary that maps column ids to dictionaries where each dictionary + * has the following fields: + * - visible: Is the column visible. + * - ordinal: The 1-based XUL 'ordinal' value assigned to the column. This + * corresponds to the position but is not something you want to manipulate. + * See the documentation in _saveColumnStates for more information. + */ + _savedColumnStates: null, + + /** + * Return a dictionary in the form of |_savedColumnStates| representing the + * current column states. + * + * @public + */ + getColumnStates() { + if (!this._active) { + return this._savedColumnStates; + } + + let columnStates = {}; + + let cols = document.getElementById("threadCols"); + let colChildren = cols.children; + for (let iKid = 0; iKid < colChildren.length; iKid++) { + let colChild = colChildren[iKid]; + if (colChild.tagName != "treecol") { + continue; + } + columnStates[colChild.id] = { + visible: !colChild.hidden, + ordinal: colChild.ordinal, + }; + } + + return columnStates; + }, + + /** + * For now, just save the visible columns into a dictionary for use in a + * subsequent call to |setColumnStates|. + */ + _saveColumnStates() { + // In the actual TreeColumn, the index property indicates the column + // number. This column number is a 0-based index with no gaps; it only + // increments the number each time it sees a column. + // However, this is subservient to the 'ordinal' property which + // defines the _apparent content sequence_ provided by GetNextSibling. + // The underlying content ordering is still the same, which is how + // _ensureColumnOrder() can reset things to their XUL definition sequence. + // The 'ordinal' stuff works because nsBoxFrame::RelayoutChildAtOrdinal + // messes with the sibling relationship. + // Ordinals are 1-based. _ensureColumnOrder() apparently is dumb and does + // not know this, although the ordering is relative so it doesn't actually + // matter. The annoying splitters do have ordinals, and live between + // tree columns. The splitters adjacent to a tree column do not need to + // have any 'ordinal' relationship, although it would appear user activity + // tends to move them around in a predictable fashion with oddness involved + // at the edges. + // Changes to the ordinal attribute should take immediate effect in terms of + // sibling relationship, but will merely invalidate the columns rather than + // cause a re-computation of column relationships every time. + // _ensureColumnOrder() invalidates the tree when it is done re-ordering; + // I'm not sure that's entirely necessary... + this._savedColumnStates = this.getColumnStates(); + }, + + /** + * Restores the visible columns saved by |_saveColumnStates|. + */ + _restoreColumnStates() { + if (this._savedColumnStates) { + this.setColumnStates(this._savedColumnStates); + this._savedColumnStates = null; + } + }, + // @} + + /** + * @name What To Display + * @protected + */ + // @{ + showFolderUri(aFolderURI) { + return this.show(MailUtils.getExistingFolder(aFolderURI)); + }, + + /** + * Invoked by showFolder when it turns out the folder is in fact a server. + * + * @private + */ + _showServer() { + // currently nothing to do. makeActive handles everything for us (because + // what is displayed needs to be re-asserted each time we are activated + // too.) + }, + + /** + * Select a folder for display. + * + * @param aFolder The nsIMsgDBFolder to display. + */ + show(aFolder) { + if (aFolder == null) { + this._nonViewFolder = null; + this.view.close(); + } else if (aFolder instanceof Ci.nsIMsgFolder) { + if (aFolder.isServer) { + this._nonViewFolder = aFolder; + this._showServer(); + this.view.close(); + // A server is fully loaded immediately, for now. (When we have the + // account summary, we might want to change this to wait for the page + // load to complete.) + this._allMessagesLoaded = true; + } else { + this._nonViewFolder = null; + this.view.open(aFolder); + } + } else { + // it must be a synthetic view + this.view.openSynthetic(aFolder); + } + if (this._active) { + this.makeActive(); + } + }, + + /** + * Clone an existing view wrapper as the basis for our display. + */ + cloneView(aViewWrapper) { + this.view = aViewWrapper.clone(this); + // generate a view created notification; this will cause us to do the right + // thing in terms of associating the view with the tree and such. + this.onCreatedView(); + if (this._active) { + this.makeActive(); + } + }, + + /** + * Close resources associated with the currently displayed folder because you + * no longer care about this FolderDisplayWidget. + */ + close() { + // Mark ourselves as inactive without doing any of the hard work of becoming + // inactive. This saves us from trying to update things as they go away. + this._active = false; + + this.view.close(); + this.messenger.setWindow(null, null); + this.messenger = null; + }, + // @} + + /* =============================== */ + /* ===== IDBViewWrapper Listener ===== */ + /* =============================== */ + + /** + * @name IDBViewWrapperListener Interface + * @private + */ + // @{ + + /** + * @returns true if the mail view picker is visible. This affects whether the + * DBViewWrapper will actually use the persisted mail view or not. + */ + get shouldUseMailViews() { + return ViewPickerBinding.isVisible; + }, + + /** + * Let the viewWrapper know if we should defer message display because we + * want the user to connect to the server first so password authentication + * can occur. + * + * @returns true if the folder should be shown immediately, false if we should + * wait for updateFolder to complete. + */ + get shouldDeferMessageDisplayUntilAfterServerConnect() { + let passwordPromptRequired = false; + + if (Services.prefs.getBoolPref("mail.password_protect_local_cache")) { + passwordPromptRequired = + this.view.displayedFolder.server.passwordPromptRequired; + } + + return passwordPromptRequired; + }, + + /** + * Let the viewWrapper know if it should mark the messages read when leaving + * the provided folder. + * + * @returns true if the preference is set for the folder's server type. + */ + shouldMarkMessagesReadOnLeavingFolder(aMsgFolder) { + return Services.prefs.getBoolPref( + "mailnews.mark_message_read." + aMsgFolder.server.type + ); + }, + + /** + * The view wrapper tells us when it starts loading a folder, and we set the + * cursor busy. Setting the cursor busy on a per-tab basis is us being + * nice to the future. Loading a folder is a blocking operation that is going + * to make us unresponsive and accordingly make it very hard for the user to + * change tabs. + */ + onFolderLoading(aFolderLoading) { + FolderDisplayListenerManager._fireListeners("onFolderLoading", [ + this, + aFolderLoading, + ]); + }, + + /** + * The view wrapper tells us when a search is active, and we mark the tab as + * thinking so the user knows something is happening. 'Searching' in this + * case is more than just a user-initiated search. Virtual folders / saved + * searches, mail views, plus the more obvious quick search are all based off + * of searches and we will receive a notification for them. + */ + onSearching(aIsSearching) { + FolderDisplayListenerManager._fireListeners("onSearching", [ + this, + aIsSearching, + ]); + }, + + /** + * Things we do on creating a view: + * - notify the observer service so that custom column handler providers can + * add their custom columns to our view. + */ + onCreatedView() { + // All of our messages are not displayed if the view was just created. We + // will get an onMessagesLoaded(true) nearly immediately if this is a local + // folder where view creation is synonymous with having all messages. + this._allMessagesLoaded = false; + + FolderDisplayListenerManager._fireListeners("onCreatedView", [this]); + + this._notifyWhenActive(this._activeCreatedView); + }, + _activeCreatedView() { + gDBView = this.view.dbView; // eslint-disable-line no-global-assign + + // A change in view may result in changes to sorts, the view menu, etc. + // Do this before we 'reroot' the dbview. + this._updateThreadDisplay(); + + // this creates a new selection object for the view. + if (this.tree) { + this.tree.view = this.view.dbView; + } + + FolderDisplayListenerManager._fireListeners("onActiveCreatedView", [this]); + + // The data payload used to be viewType + ":" + viewFlags. We no longer + // do this because we already have the implied contract that gDBView is + // valid at the time we generate the notification. In such a case, you + // can easily get that information from the gDBView. (The documentation + // on creating a custom column assumes gDBView.) + Services.obs.notifyObservers(this.displayedFolder, "MsgCreateDBView"); + }, + + /** + * If our view is being destroyed and it is coming back, we want to save the + * current selection so we can restore it when the view comes back. + */ + onDestroyingView(aFolderIsComingBack) { + // try and persist the selection's content if we can + if (this._active) { + // If saving the selection throws an exception, we still want continue + // destroying the view. Saving the selection can fail if an underlying + // local folder has been compacted, invalidating the message keys. + // See bug 536676 for more info. + try { + // If a new selection is coming up, there's no point in trying to + // persist any selections. + if (aFolderIsComingBack && !this._aboutToSelectMessage) { + this._saveSelection(); + } else { + this._clearSavedSelection(); + } + } catch (ex) { + console.error(ex); + } + gDBView = null; // eslint-disable-line no-global-assign + } + + FolderDisplayListenerManager._fireListeners("onDestroyingView", [ + this, + aFolderIsComingBack, + ]); + + // if we have no view, no messages could be loaded. + this._allMessagesLoaded = false; + + // but the actual tree view selection (based on view indices) is a goner no + // matter what, make everyone forget. + this.view.dbView.selection = null; + this._savedFirstVisibleRow = null; + this._nextViewIndexAfterDelete = null; + // although the move may still be active, its relation to the view is moot. + this._massMoveActive = false; + + // Anything pending needs to get cleared out; the new view and its related + // events will re-schedule anything required or simply run it when it + // has its initial call to makeActive compelled. + this._notificationsPendingActivation = []; + }, + + /** + * Restore persisted information about what columns to display for the folder. + * If we have no persisted information, we leave/set _savedColumnStates null. + * The column states will be set to default values in onDisplayingFolder in + * that case. + */ + onLoadingFolder(aDbFolderInfo) { + this._savedColumnStates = + this._depersistColumnStatesFromDbFolderInfo(aDbFolderInfo); + + FolderDisplayListenerManager._fireListeners("onLoadingFolder", [ + this, + aDbFolderInfo, + ]); + }, + + /** + * We are entering the folder for display: + * - set the header cache size. + * - Setup the columns if we did not already depersist in |onLoadingFolder|. + */ + onDisplayingFolder() { + let displayedFolder = this.view.displayedFolder; + let msgDatabase = displayedFolder && displayedFolder.msgDatabase; + if (msgDatabase) { + msgDatabase.resetHdrCacheSize(this.PERF_HEADER_CACHE_SIZE); + } + + // makeActive will restore the folder state + if (!this._savedColumnStates) { + if ( + this.view.isSynthetic && + "getPersistedSetting" in this.view._syntheticView + ) { + let columns = this.view._syntheticView.getPersistedSetting("columns"); + this._savedColumnStates = columns; + } else { + // get the default for this folder + this._savedColumnStates = this._getDefaultColumnsForCurrentFolder(); + // and save it so it doesn't wiggle if the inbox/prototype changes + this._persistColumnStates(this._savedColumnStates); + } + } + + FolderDisplayListenerManager._fireListeners("onDisplayingFolder", [this]); + + if (this.active) { + this.makeActive(); + } + }, + + /** + * Notification from DBViewWrapper that it is closing the folder. This can + * happen for reasons other than our own 'close' method closing the view. + * For example, user deletion of the folder or underlying folder closes it. + */ + onLeavingFolder() { + FolderDisplayListenerManager._fireListeners("onLeavingFolder", [this]); + + // Keep the msgWindow's openFolder up-to-date; it powers nsMessenger's + // concept of history so that it can bring you back to the actual folder + // you were looking at, rather than just the underlying folder. + if (this._active) { + msgWindow.openFolder = null; + } + }, + + /** + * Indicates whether we are done loading the messages that should be in this + * folder. This is being surfaced for testing purposes, but could be useful + * to other code as well. But don't poll this property; ask for an event + * that you can hook. + */ + get allMessagesLoaded() { + return this._allMessagesLoaded; + }, + + /** + * Things to do once some or all the messages that should show up in a folder + * have shown up. For a real folder, this happens when the folder is + * entered. For a virtual folder, this happens when the search completes. + * + * What we do: + * - Any scrolling required! + */ + onMessagesLoaded(aAll) { + this._allMessagesLoaded = aAll; + + FolderDisplayListenerManager._fireListeners("onMessagesLoaded", [ + this, + aAll, + ]); + + this._notifyWhenActive(this._activeMessagesLoaded); + }, + _activeMessagesLoaded() { + FolderDisplayListenerManager._fireListeners("onActiveMessagesLoaded", [ + this, + ]); + + // - if a selectMessage's coming up, get out of here + if (this._aboutToSelectMessage) { + return; + } + + // - restore user's last expand/collapse choice. + this.restoreThreadState(); + + // - restore selection + // Attempt to restore the selection (if we saved it because the view was + // being destroyed or otherwise manipulated in a fashion that the normal + // nsTreeSelection would be unable to handle.) + if (this._restoreSelection()) { + this.ensureRowIsVisible(this.view.dbView.viewIndexForFirstSelectedMsg); + return; + } + + // - pending navigation from pushNavigation (probably spacebar triggered) + // Need to have all messages loaded first. + if (this._pendingNavigation) { + // Move it to a local and clear the state in case something bad happens. + // (We don't want to swallow the exception.) + let pendingNavigation = this._pendingNavigation; + this._pendingNavigation = null; + this.navigate.apply(this, pendingNavigation); + return; + } + + // - if something's already selected (e.g. in a message tab), scroll to the + // first selected message and get out + if (this.view.dbView.numSelected > 0) { + this.ensureRowIsVisible(this.view.dbView.viewIndexForFirstSelectedMsg); + return; + } + + // - new messages + // if configured to scroll to new messages, try that + if ( + Services.prefs.getBoolPref("mailnews.scroll_to_new_message") && + this.navigate(Ci.nsMsgNavigationType.firstNew, /* select */ false) + ) { + return; + } + + // - last selected message + // if configured to load the last selected message (this is currently more + // persistent than our saveSelection/restoreSelection stuff), and the view + // is backed by a single underlying folder (the only way having just a + // message key works out), try that + if ( + Services.prefs.getBoolPref("mailnews.remember_selected_message") && + this.view.isSingleFolder && + this.view.displayedFolder + ) { + // use the displayed folder; nsMsgDBView goes to the effort to save the + // state to the viewFolder, so this is the correct course of action. + let lastLoadedMessageKey = this.view.displayedFolder.lastMessageLoaded; + if (lastLoadedMessageKey != nsMsgKey_None) { + this.view.dbView.selectMsgByKey(lastLoadedMessageKey); + // The message key may not be present in the view for a variety of + // reasons. Beyond message deletion, it simply may not match the + // active mail view or quick search, for example. + if (this.view.dbView.numSelected > 0) { + this.ensureRowIsVisible( + this.view.dbView.viewIndexForFirstSelectedMsg + ); + return; + } + } + } + + // - towards the newest messages, but don't select + if ( + this.view.isSortedAscending && + this.view.sortImpliesTemporalOrdering && + this.navigate(Ci.nsMsgNavigationType.lastMessage, /* select */ false) + ) { + return; + } + + // - to the top, the coliseum + this.ensureRowIsVisible(0); + }, + + /** + * The DBViewWrapper tells us when someone (possibly the wrapper itself) + * changes the active mail view so that we can kick the UI to update. + */ + onMailViewChanged() { + // only do this if we're currently active. no need to queue it because we + // always update the mail view whenever we are made active. + if (this.active) { + // you cannot cancel a view change! + window.dispatchEvent( + new Event("MailViewChanged", { bubbles: false, cancelable: false }) + ); + } + }, + + /** + * Just the sort or threading was changed, without changing other things. We + * will not get this notification if the view was re-created, for example. + */ + onSortChanged() { + if (this.active) { + UpdateSortIndicators( + this.view.primarySortType, + this.view.primarySortOrder + ); + } + + FolderDisplayListenerManager._fireListeners("onSortChanged", [this]); + }, + + /** + * Messages (that may have been displayed) have been removed; this may impact + * our message selection. We might know it's coming; if we do then + * this._nextViewIndexAfterDelete should know what view index to select next. + * For the imap mark-as-deleted we won't know beforehand. + */ + onMessagesRemoved() { + FolderDisplayListenerManager._fireListeners("onMessagesRemoved", [this]); + + this._deleteInProgress = false; + + // - we saw this coming + let rowCount = this.view.dbView.rowCount; + if (!this._massMoveActive && this._nextViewIndexAfterDelete != null) { + // adjust the index if it is after the last row... + // (this can happen if the "mail.delete_matches_sort_order" pref is not + // set and the message is the last message in the view.) + if (this._nextViewIndexAfterDelete >= rowCount) { + this._nextViewIndexAfterDelete = rowCount - 1; + } + // just select the index and get on with our lives + this.selectViewIndex(this._nextViewIndexAfterDelete); + this._nextViewIndexAfterDelete = null; + return; + } + + // - we didn't see it coming + + // A deletion happened to our folder. + let treeSelection = this.treeSelection; + // we can't fix the selection if we have no selection + if (!treeSelection) { + return; + } + + // For reasons unknown (but theoretically knowable), sometimes the selection + // object will be invalid. At least, I've reliably seen a selection of + // [0, 0] with 0 rows. If that happens, we need to fix up the selection + // here. + if (rowCount == 0 && treeSelection.count) { + // nsTreeSelection doesn't generate an event if we use clearRange, so use + // that to avoid spurious events, given that we are going to definitely + // trigger a change notification below. + treeSelection.clearRange(0, 0); + } + + // Check if we now no longer have a selection, but we had exactly one + // message selected previously. If we did, then try and do some + // 'persistence of having a thing selected'. + if ( + treeSelection.count == 0 && + this._mostRecentSelectionCounts.length > 1 && + this._mostRecentSelectionCounts[1] == 1 && + this._mostRecentCurrentIndices[1] != -1 + ) { + let targetIndex = this._mostRecentCurrentIndices[1]; + if (targetIndex >= rowCount) { + targetIndex = rowCount - 1; + } + this.selectViewIndex(targetIndex); + return; + } + + // Otherwise, just tell the view that things have changed so it can update + // itself to the new state of things. + // tell the view that things have changed so it can update itself suitably. + if (this.view.dbView) { + this.view.dbView.selectionChanged(); + } + }, + + /** + * Messages were not actually removed, but we were expecting that they would + * be. Clean-up what onMessagesRemoved would have cleaned up, namely the + * next view index to select. + */ + onMessageRemovalFailed() { + this._nextViewIndexAfterDelete = null; + FolderDisplayListenerManager._fireListeners("onMessagesRemovalFailed", [ + this, + ]); + }, + + /** + * Update the status bar to reflect our exciting message counts. + */ + onMessageCountsChanged() {}, + // @} + /* ===== End IDBViewWrapperListener ===== */ + + /* ================================== */ + /* ===== nsIMsgDBViewCommandUpdater ===== */ + /* ================================== */ + + /** + * @name nsIMsgDBViewCommandUpdater Interface + * @private + */ + // @{ + + /** + * This gets called when the selection changes AND !suppressCommandUpdating + * AND (we're not removing a row OR we are now out of rows). + * In response, we update the toolbar. + */ + updateCommandStatus() {}, + + /** + * This gets called by nsMsgDBView::UpdateDisplayMessage following a call + * to nsIMessenger.OpenURL to kick off message display OR (UDM gets called) + * by nsMsgDBView::SelectionChanged in lieu of loading the message because + * mSupressMsgDisplay. + * In other words, we get notified immediately after the process of displaying + * a message triggered by the nsMsgDBView happens. We get some arguments + * that are display optimizations for historical reasons (as usual). + * + * Things this makes us want to do: + * - Set the tab title, perhaps. (If we are a message display.) + * - Update message counts, because things might have changed, why not. + * - Update some toolbar buttons, why not. + * + * @param aFolder The display/view folder, as opposed to the backing folder. + * @param aSubject The subject with "Re: " if it's got one, which makes it + * notably different from just directly accessing the message header's + * subject. + * @param aKeywords The keywords, which roughly translates to message tags. + */ + displayMessageChanged(aFolder, aSubject, aKeywords) {}, + + /** + * This gets called as a hint that the currently selected message is junk and + * said junked message is going to be moved out of the current folder, or + * right before a header is removed from the db view. The legacy behaviour + * is to retrieve the msgToSelectAfterDelete attribute off the db view, + * stashing it for benefit of the code that gets called when a message + * move/deletion is completed so that we can trigger its display. + */ + updateNextMessageAfterDelete() { + this.hintAboutToDeleteMessages(); + }, + + /** + * The most recent currentIndexes on the selection (from the last time + * summarizeSelection got called). We use this in onMessagesRemoved if + * we get an unexpected notification. + * We keep a maximum of 2 entries in this list. + */ + _mostRecentCurrentIndices: undefined, // initialized in constructor + /** + * The most recent counts on the selection (from the last time + * summarizeSelection got called). We use this in onMessagesRemoved if + * we get an unexpected notification. + * We keep a maximum of 2 entries in this list. + */ + _mostRecentSelectionCounts: undefined, // initialized in constructor + + /** + * Always called by the db view when the selection changes in + * SelectionChanged. This event will come after the notification to + * displayMessageChanged (if one happens), and before the notification to + * updateCommandStatus (if one happens). + */ + summarizeSelection() { + // save the current index off in case the selection gets deleted out from + // under us and we want to have persistence of actually-having-something + // selected. + let treeSelection = this.treeSelection; + if (treeSelection) { + this._mostRecentCurrentIndices.unshift(treeSelection.currentIndex); + this._mostRecentCurrentIndices.splice(2); + this._mostRecentSelectionCounts.unshift(treeSelection.count); + this._mostRecentSelectionCounts.splice(2); + } + }, + // @} + /* ===== End nsIMsgDBViewCommandUpdater ===== */ + + /* ===== Hints from the command infrastructure ===== */ + /** + * @name Command Infrastructure Hints + * @protected + */ + // @{ + + /** + * doCommand helps us out by telling us when it is telling the view to delete + * some messages. Ideally it should go through us / the DB View Wrapper to + * kick off the delete in the first place, but that's a thread I don't want + * to pull on right now. + * We use this hint to figure out the next message to display once the + * deletion completes. We do this before the deletion happens because the + * selection is probably going away (except in the IMAP delete model), and it + * might be too late to figure this out after the deletion happens. + * Our automated complement (that calls us) is updateNextMessageAfterDelete. + */ + hintAboutToDeleteMessages() { + this._deleteInProgress = true; + // save the value, even if it is nsMsgViewIndex_None. + this._nextViewIndexAfterDelete = this.view.dbView.msgToSelectAfterDelete; + }, + + /** + * The archive code tells us when it is starting to archive messages. This + * is different from hinting about deletion because it will also tell us + * when it has completed its mass move. + * The UI goal is that we do not immediately jump beyond the selected messages + * to the next message until all of the selected messages have been + * processed (moved). Ideally we would also do this when deleting messages + * from a multiple-folder backed message view, but we don't know when the + * last job completes in that case (whereas in this case we do because of the + * call to hintMassMoveCompleted.) + */ + hintMassMoveStarting() { + this.hintAboutToDeleteMessages(); + this._massMoveActive = true; + }, + + /** + * The archival has completed, we can finally let onMessagseRemoved run to + * completion. + */ + hintMassMoveCompleted() { + this._massMoveActive = false; + this.onMessagesRemoved(); + }, + + /** + * When a right-click on the thread pane is going to alter our selection, we + * get this notification (currently from |ChangeSelectionWithoutContentLoad| + * in threadPane.js), which lets us save our state. + * This ends one of two ways: we get made inactive because a new tab popped up + * or we get a call to |hintRightClickSelectionPerturbationDone|. + * + * Ideally, we could just save off our current nsITreeSelection and restore it + * when this is all over. This assumption would rely on the underlying view + * not having any changes to its rows before we restore the selection. I am + * not confident we can rule out background processes making changes, plus + * the right-click itself may mutate the view (although we could try and get + * it to restore the selection before it gets to the mutation part). Our + * only way to resolve this would be to create a 'tee' like fake selection + * that would proxy view change notifications to both sets of selections. + * That is hard. + * So we just use the existing _saveSelection/_restoreSelection mechanism + * which is potentially very costly. + */ + hintRightClickPerturbingSelection() { + this._saveSelection(); + }, + + /** + * When a right-click on the thread pane altered our selection (which we + * should have received a call to |hintRightClickPerturbingSelection| for), + * we should receive this notification from + * |RestoreSelectionWithoutContentLoad| when it wants to put things back. + */ + hintRightClickSelectionPerturbationDone() { + this._restoreSelection(); + }, + // @} + /* ===== End hints from the command infrastructure ==== */ + + _updateThreadDisplay() { + if (this.active) { + if (this.view.dbView) { + UpdateSortIndicators( + this.view.dbView.sortType, + this.view.dbView.sortOrder + ); + SetNewsFolderColumns(); + UpdateSelectCol(); + } + } + }, + + /** + * Update the UI display apart from the thread tree because the folder being + * displayed has changed. This can be the result of changing the folder in + * this FolderDisplayWidget, or because this FolderDisplayWidget is being + * made active. _updateThreadDisplay handles the parts of the thread tree + * that need updating. + */ + _updateContextDisplay() { + if (this.active) { + UpdateStatusQuota(this.displayedFolder); + + // - mail view combo-box. + this.onMailViewChanged(); + } + }, + + /** + * @name Activation Control + * @protected + */ + // @{ + + /** + * Run the provided notification function right now if we are 'active' (the + * currently displayed tab), otherwise queue it to be run when we become + * active. We do this because our tabbing model uses multiplexed (reused) + * widgets, and extensions likewise depend on these global/singleton things. + * If the requested notification function is already queued, it will not be + * added a second time, and the original call ordering will be maintained. + * If a new call ordering is required, the list of notifications should + * probably be reset by the 'big bang' event (new view creation?). + */ + _notifyWhenActive(aNotificationFunc) { + if (this._active) { + aNotificationFunc.call(this); + } else if ( + !this._notificationsPendingActivation.includes(aNotificationFunc) + ) { + this._notificationsPendingActivation.push(aNotificationFunc); + } + }, + + /** + * Some notifications cannot run while the FolderDisplayWidget is inactive + * (presumbly because it is in a background tab). We accumulate those in + * _notificationsPendingActivation and then this method runs them when we + * become active again. + */ + _runNotificationsPendingActivation() { + if (!this._notificationsPendingActivation.length) { + return; + } + + let pendingNotifications = this._notificationsPendingActivation; + this._notificationsPendingActivation = []; + for (let notif of pendingNotifications) { + notif.call(this); + } + }, + + // This is not guaranteed to be up to date if the folder display is active + _folderPaneVisible: null, + + /** + * Whether the folder pane is visible. When we're inactive, we stash the value + * in |this._folderPaneVisible|. + */ + get folderPaneVisible() { + // Early return if the user wants to use Thunderbird without an email + // account and no account is configured. + if ( + Services.prefs.getBoolPref("app.use_without_mail_account", false) && + !MailServices.accounts.accounts.length + ) { + return false; + } + + if (this._active) { + let folderPaneBox = document.getElementById("folderPaneBox"); + if (folderPaneBox) { + return !folderPaneBox.collapsed; + } + } else { + return this._folderPaneVisible; + } + + return null; + }, + + /** + * Sets the visibility of the folder pane. This should reflect reality and + * not define it (for active tabs at least). + */ + set folderPaneVisible(aVisible) { + this._folderPaneVisible = aVisible; + }, + + get active() { + return this._active; + }, + + /** + * Make this FolderDisplayWidget the 'active' widget by updating globals and + * linking us up to the UI widgets. This is intended for use by the tabbing + * logic. + */ + makeActive(aWasInactive) { + let wasInactive = !this._active; + + // -- globals + // update per-tab globals that we own + gFolderDisplay = this; // eslint-disable-line no-global-assign + gDBView = this.view.dbView; // eslint-disable-line no-global-assign + messenger = this.messenger; // eslint-disable-line no-global-assign + + // update singleton globals' state + msgWindow.openFolder = this.view.displayedFolder; + + this._active = true; + this._runNotificationsPendingActivation(); + + FolderDisplayListenerManager._fireListeners("onMakeActive", [ + this, + aWasInactive, + ]); + + // -- UI + + // thread pane if we have a db view + if (this.view.dbView) { + // Make sure said thread pane is visible. If we do this after we re-root + // the tree, the thread pane may not actually replace the account central + // pane. Concerning... + this._showThreadPane(); + + // some things only need to happen if we are transitioning from inactive + // to active + if (wasInactive) { + if (this.tree) { + // Setting the 'view' attribute on treeBox results in the following + // effective calls, noting that in makeInactive we made sure to null + // out its view so that it won't try and clean up any views or their + // selections. (The actual actions happen in + // nsTreeBodyFrame::SetView) + // - this.view.dbView.selection.tree = this.tree + // - this.view.dbView.setTree(this.tree) + // - this.tree.view = this.view.dbView (in + // nsTreeBodyObject::SetView) + this.tree.view = this.view.dbView; + + if (this._savedFirstVisibleRow != null) { + this.tree.scrollToRow(this._savedFirstVisibleRow); + } + } + } + + // Always restore the column state if we have persisted state. We restore + // state on folder entry, in which case we were probably not inactive. + this._restoreColumnStates(); + + // update the columns and such that live inside the thread pane + this._updateThreadDisplay(); + } + + this._updateContextDisplay(); + }, + + /** + * Cause the displayBox to display the thread pane. + */ + _showThreadPane() { + document.getElementById("accountCentralBox").collapsed = true; + document.getElementById("threadPaneBox").collapsed = false; + }, + + /** + * Cause the displayBox to display the (preference configurable) account + * central page. + */ + _showAccountCentral() { + if (!this.displayedFolder && MailServices.accounts.accounts.length > 0) { + // If we have any accounts set up, but no folder is selected yet, + // we expect another selection event to come when session restore finishes. + // Until then, do nothing. + return; + } + document.getElementById("accountCentralBox").collapsed = false; + document.getElementById("threadPaneBox").collapsed = true; + + // Prevent a second load if necessary. + let loadURL = + "chrome://messenger/content/msgAccountCentral.xhtml" + + (this.displayedFolder + ? "?folderURI=" + encodeURIComponent(this.displayedFolder.URI) + : ""); + if (window.frames.accountCentralPane.location.href != loadURL) { + window.frames.accountCentralPane.location.href = loadURL; + } + }, + + /** + * Call this when the tab using us is being hidden. + */ + makeInactive() { + // - things to do before we mark ourselves inactive (because they depend on + // us being active) + + // getColumnStates returns _savedColumnStates when we are inactive (and is + // used by _saveColumnStates) so we must do this before marking inactive. + this._saveColumnStates(); + + // - mark us inactive + this._active = false; + + // - (everything after this point doesn't care that we are marked inactive) + // save the folder pane's state always + this._folderPaneVisible = + !document.getElementById("folderPaneBox").collapsed; + + if (this.view.dbView) { + if (this.tree) { + this._savedFirstVisibleRow = this.tree.getFirstVisibleRow(); + } + + // save the message pane's state only when it is potentially visible + this.messagePaneCollapsed = document.getElementById( + "messagepaneboxwrapper" + ).collapsed; + } + }, + // @} + + /** + * @name Command Support + */ + // @{ + + /** + * @returns true if there is a db view and the command is enabled on the view. + * This function hides some of the XPCOM-odditities of the getCommandStatus + * call. + */ + getCommandStatus(aCommandType, aEnabledObj, aCheckStatusObj) { + // no view means not enabled + if (!this.view.dbView) { + return false; + } + let enabledObj = {}, + checkStatusObj = {}; + this.view.dbView.getCommandStatus(aCommandType, enabledObj, checkStatusObj); + return enabledObj.value; + }, + + /** + * Make code cleaner by allowing peoples to call doCommand on us rather than + * having to do folderDisplayWidget.view.dbView.doCommand. + * + * @param aCommandName The command name to invoke. + */ + doCommand(aCommandName) { + return this.view.dbView && this.view.dbView.doCommand(aCommandName); + }, + + /** + * Make code cleaner by allowing peoples to call doCommandWithFolder on us + * rather than having to do: + * folderDisplayWidget.view.dbView.doCommandWithFolder. + * + * @param aCommandName The command name to invoke. + * @param aFolder The folder context for the command. + */ + doCommandWithFolder(aCommandName, aFolder) { + return ( + this.view.dbView && + this.view.dbView.doCommandWithFolder(aCommandName, aFolder) + ); + }, + // @} + + /** + * @returns true when account central is being displayed. + * @groupName Displayed + */ + get isAccountCentralDisplayed() { + return this.view.dbView == null; + }, + + /** + * @name Navigation + * @protected + */ + // @{ + + /** + * Navigate using nsMsgNavigationType rules and ensuring the resulting row is + * visible. This is trickier than it used to be because we now support + * treating collapsed threads as the set of all the messages in the collapsed + * thread rather than just the root message in that thread. + * + * @param {nsMsgNavigationType} aNavType navigation command. + * @param {boolean} [aSelect=true] should we select the message if we find + * one? + * + * @returns true if the navigation constraint matched anything, false if not. + * We will have navigated if true, we will have done nothing if false. + */ + navigate(aNavType, aSelect) { + if (aSelect === undefined) { + aSelect = true; + } + let resultKeyObj = {}, + resultIndexObj = {}, + threadIndexObj = {}; + + let summarizeSelection = this.summarizeSelectionInFolder; + + let treeSelection = this.treeSelection; // potentially magic getter + let currentIndex = treeSelection ? treeSelection.currentIndex : 0; + + let viewIndex; + // if we're doing next unread, and a collapsed thread is selected, and + // the top level message is unread, just set the result manually to + // the top level message, without using viewNavigate. + if ( + summarizeSelection && + aNavType == Ci.nsMsgNavigationType.nextUnreadMessage && + currentIndex != -1 && + this.view.isCollapsedThreadAtIndex(currentIndex) && + !(this.view.dbView.getFlagsAt(currentIndex) & Ci.nsMsgMessageFlags.Read) + ) { + viewIndex = currentIndex; + } else { + // always 'wrap' because the start index is relative to the selection. + // (keep in mind that many forms of navigation do not care about the + // starting position or 'wrap' at all; for example, firstNew just finds + // the first new message.) + // allegedly this does tree-expansion for us. + this.view.dbView.viewNavigate( + aNavType, + resultKeyObj, + resultIndexObj, + threadIndexObj, + true + ); + viewIndex = resultIndexObj.value; + } + + if (viewIndex == nsMsgViewIndex_None) { + return false; + } + + // - Expand if required. + // (The nsMsgDBView isn't really aware of the varying semantics of + // collapsed threads, so viewNavigate might tell us about the root message + // and leave it collapsed, not realizing that it needs to be expanded.) + if (summarizeSelection && this.view.isCollapsedThreadAtIndex(viewIndex)) { + this.view.dbView.toggleOpenState(viewIndex); + } + + if (aSelect) { + this.selectViewIndex(viewIndex); + } else { + this.ensureRowIsVisible(viewIndex); + } + return true; + }, + + /** + * Push a call to |navigate| to be what we do once we successfully open the + * next folder. This is intended to be used by cross-folder navigation + * code. It should call this method before triggering the folder change. + */ + pushNavigation(aNavType, aSelect) { + this._pendingNavigation = [aNavType, aSelect]; + }, + // @} + + /** + * @name Selection + */ + // @{ + + /** + * @returns the message header for the first selected message, or null if + * there is no selected message. + * + * If the user has right-clicked on a message, this method will return that + * message and not the 'current index' (the dude with the dotted selection + * rectangle around him.) If you instead always want the currently + * displayed message (which is not impacted by right-clicking), then you + * would want to access the displayedMessage property on the + * MessageDisplayWidget. You can get to that via the messageDisplay + * attribute on this object or (potentially) via the gMessageDisplay object. + */ + get selectedMessage() { + // there are inconsistencies in hdrForFirstSelectedMessage between + // nsMsgDBView and nsMsgSearchDBView in whether they use currentIndex, + // do it ourselves. (nsMsgDBView does not use currentIndex, search does.) + let treeSelection = this.treeSelection; + if (!treeSelection || !treeSelection.count) { + return null; + } + let minObj = {}, + maxObj = {}; + treeSelection.getRangeAt(0, minObj, maxObj); + return this.view.dbView.getMsgHdrAt(minObj.value); + }, + + /** + * @returns true if there is a selected message and it's an RSS feed message; + * a feed message does not have to be in an rss account folder if stored in + * Tb15 and later. + */ + get selectedMessageIsFeed() { + return FeedUtils.isFeedMessage(this.selectedMessage); + }, + + /** + * @returns true if there is a selected message and it's an IMAP message. + */ + get selectedMessageIsImap() { + let message = this.selectedMessage; + return Boolean( + message && + message.folder && + message.folder.flags & Ci.nsMsgFolderFlags.ImapBox + ); + }, + + /** + * @returns true if there is a selected message and it's a news message. It + * would be great if messages knew this about themselves, but they don't. + */ + get selectedMessageIsNews() { + let message = this.selectedMessage; + return Boolean( + message && + message.folder && + message.folder.flags & Ci.nsMsgFolderFlags.Newsgroup + ); + }, + + /** + * @returns true if there is a selected message and it's an external message, + * meaning it is loaded from an .eml file on disk or is an rfc822 attachment + * on a message. + */ + get selectedMessageIsExternal() { + let message = this.selectedMessage; + // Dummy messages currently lack a folder. This is not a great heuristic. + // I have annotated msgHdrView.js which provides the dummy header to + // express this implementation dependency. + // (Currently, since external mails can only be opened in standalone windows + // which subclass us, we could always return false, and have the subclass + // return true using its own heuristics. But since we are moving to a tab + // model more heavily, at some point the 3-pane will need this.) + return Boolean(message && !message.folder); + }, + + /** + * @returns true if there is a selected message and the message belongs to an + * ignored thread. + */ + get selectedMessageThreadIgnored() { + let message = this.selectedMessage; + return Boolean( + message && + message.folder && + message.folder.msgDatabase.isIgnored(message.messageKey) + ); + }, + + /** + * @returns true if there is a selected message and the message is the base + * message for an ignored subthread. + */ + get selectedMessageSubthreadIgnored() { + let message = this.selectedMessage; + return Boolean( + message && message.folder && message.flags & Ci.nsMsgMessageFlags.Ignored + ); + }, + + /** + * @returns true if there is a selected message and the message belongs to a + * watched thread. + */ + get selectedMessageThreadWatched() { + let message = this.selectedMessage; + return Boolean( + message && + message.folder && + message.folder.msgDatabase.isWatched(message.messageKey) + ); + }, + + /** + * @returns the number of selected messages. If summarizeSelectionInFolder is + * true, then any collapsed thread roots that are selected will also + * conceptually have all of the messages in that thread selected. + */ + get selectedCount() { + return this.selectedMessages.length; + }, + + /** + * Provides a list of the view indices that are selected which is *not* the + * same as the rows of the selected messages. When + * summarizeSelectionInFolder is true, messages may be selected but not + * visible (because the thread root is selected.) + * You probably want to use the |selectedMessages| attribute instead of this + * one. (Or selectedMessageUris in some rare cases.) + * + * If the user has right-clicked on a message, this will return that message + * and not the selection prior to the right-click. + * + * @returns a list of the view indices that are currently selected + */ + get selectedIndices() { + if (!this.view.dbView) { + return []; + } + + return this.view.dbView.getIndicesForSelection(); + }, + + /** + * Provides a list of the message headers for the currently selected messages. + * If summarizeSelectionInFolder is true, then any collapsed thread roots + * that are selected will also (conceptually) have all of the messages in + * that thread selected and they will be included in the returned list. + * + * If the user has right-clicked on a message, this will return that message + * (and any collapsed children if so enabled) and not the selection prior to + * the right-click. + * + * @returns a list of the message headers for the currently selected messages. + * If there are no selected messages, the result is an empty list. + */ + get selectedMessages() { + if (!this._active && this._savedSelection?.messages) { + return this._savedSelection.messages + .map(savedInfo => this.view.getMsgHdrForMessageID(savedInfo.messageId)) + .filter(msgHdr => !!msgHdr); + } + if (!this.view.dbView) { + return []; + } + return this.view.dbView.getSelectedMsgHdrs(); + }, + + /** + * @returns a list of the URIs for the currently selected messages or null + * (instead of a list) if there are no selected messages. Do not + * pass around URIs unless you have a good reason. Legacy code is an + * ok reason. + * + * If the user has right-clicked on a message, this will return that message's + * URI and not the selection prior to the right-click. + */ + get selectedMessageUris() { + if (!this.view.dbView) { + return null; + } + + let messageArray = this.view.dbView.getURIsForSelection(); + return messageArray.length ? messageArray : null; + }, + + /** + * @returns true if all the selected messages can be archived, false otherwise. + */ + get canArchiveSelectedMessages() { + return false; + }, + + /** + * The maximum number of messages canMarkThreadAsRead will look through. + * If the number exceeds this limit, as a performance measure, we return + * true rather than looking through the messages and possible + * submessages. + */ + MAX_COUNT_FOR_MARK_THREAD: 1000, + + /** + * Check if the thread for the currently selected message can be marked as + * read. A thread can be marked as read if and only if it has at least one + * unread message. + */ + get canMarkThreadAsRead() { + if ( + (this.displayedFolder && this.displayedFolder.getNumUnread(false) > 0) || + this.view._underlyingData === this.view.kUnderlyingSynthetic + ) { + // If the messages limit is exceeded we bail out early and return true. + if (this.selectedIndices.length > this.MAX_COUNT_FOR_MARK_THREAD) { + return true; + } + + for (let i of this.selectedIndices) { + if ( + this.view.dbView.getThreadContainingIndex(i).numUnreadChildren > 0 + ) { + return true; + } + } + } + return false; + }, + + /** + * @returns true if all the selected messages can be deleted from their + * folders, false otherwise. + */ + get canDeleteSelectedMessages() { + if (!this.view.dbView) { + return false; + } + + let selectedMessages = this.selectedMessages; + for (let i = 0; i < selectedMessages.length; ++i) { + if ( + selectedMessages[i].folder && + (!selectedMessages[i].folder.canDeleteMessages || + selectedMessages[i].folder.flags & Ci.nsMsgFolderFlags.Newsgroup) + ) { + return false; + } + } + return true; + }, + + /** + * Clear the tree selection, making sure the message pane is cleared and + * the context display (toolbars, etc.) are updated. + */ + clearSelection() { + let treeSelection = this.treeSelection; // potentially magic getter + if (!treeSelection) { + return; + } + treeSelection.clearSelection(); + this._updateContextDisplay(); + }, + + // Whether we're about to select a message + _aboutToSelectMessage: false, + + /** + * This needs to be called to let us know that a selectMessage or equivalent + * is coming up right after a show() call, so that we know that a double + * message load won't be happening. + * + * This can be assumed to be idempotent. + */ + selectMessageComingUp() { + this._aboutToSelectMessage = true; + }, + + /** + * Select a message for display by header. Attempt to select the message + * right now. If we were unable to find it, update our saved selection + * to want to display the message. Threads are expanded to find the header. + * + * @param aMsgHdr The message header to select for display. + * @param [aForceSelect] If the message is not in the view and this is true, + * we will drop any applied view filters to look for the + * message. The dropping of view filters is persistent, + * so use with care. Defaults to false. + */ + selectMessage(aMsgHdr, aForceSelect) { + let viewIndex = this.view.getViewIndexForMsgHdr(aMsgHdr, aForceSelect); + if (viewIndex != nsMsgViewIndex_None) { + this._savedSelection = null; + this.selectViewIndex(viewIndex); + } else { + this._savedSelection = { + messages: [{ messageId: aMsgHdr.messageId }], + forceSelect: aForceSelect, + }; + // queue the selection to be restored once we become active if we are not + // active. + if (!this.active) { + this._notifyWhenActive(this._restoreSelection); + } + } + + // Do this here instead of at the beginning to prevent reentrancy issues + this._aboutToSelectMessage = false; + }, + + /** + * Select all of the provided nsIMsgDBHdrs in the aMessages array, expanding + * threads as required. If we were not able to find all of the messages, + * update our saved selection to want to display the messages. The messages + * will then be selected when we are made active or all messages in the + * folder complete loading. This is to accommodate the use-case where we + * are backed by an in-progress search and no + * + * @param aMessages An array of nsIMsgDBHdr instances. + * @param [aForceSelect] If a message is not in the view and this is true, + * we will drop any applied view filters to look for the + * message. The dropping of view filters is persistent, + * so use with care. Defaults to false. + * @param aDoNotNeedToFindAll If true (can be omitted and left undefined), we + * do not attempt to save the selection for future use. This is intended + * for use by the _restoreSelection call which is the end-of-the-line for + * restoring the selection. (Once it gets called all of our messages + * should have already been loaded.) + */ + selectMessages(aMessages, aForceSelect, aDoNotNeedToFindAll) { + let treeSelection = this.treeSelection; // potentially magic getter + let foundAll = true; + if (treeSelection) { + let minRow = null, + maxRow = null; + + treeSelection.selectEventsSuppressed = true; + treeSelection.clearSelection(); + + for (let msgHdr of aMessages) { + let viewIndex = this.view.getViewIndexForMsgHdr(msgHdr, aForceSelect); + + if (viewIndex != nsMsgViewIndex_None) { + if (minRow == null || viewIndex < minRow) { + minRow = viewIndex; + } + if (maxRow == null || viewIndex > maxRow) { + maxRow = viewIndex; + } + // nsTreeSelection is actually very clever about doing this + // efficiently. + treeSelection.rangedSelect(viewIndex, viewIndex, true); + } else { + foundAll = false; + } + + // make sure the selection is as visible as possible + if (minRow != null) { + this.ensureRowRangeIsVisible(minRow, maxRow); + } + } + + treeSelection.selectEventsSuppressed = false; + + // If we haven't selected every message, we'll set |this._savedSelection| + // below, so it's fine to null it out at this point. + this._savedSelection = null; + } + + // Do this here instead of at the beginning to prevent reentrancy issues + this._aboutToSelectMessage = false; + + // Two cases. + // 1. The tree selection isn't there at all. + // 2. The tree selection is there, and we needed to find all messages, but + // we didn't. + if (!treeSelection || (!aDoNotNeedToFindAll && !foundAll)) { + this._savedSelection = { + messages: aMessages.map(msgHdr => ({ messageId: msgHdr.messageId })), + forceSelect: aForceSelect, + }; + if (!this.active) { + this._notifyWhenActive(this._restoreSelection); + } + } + }, + + /** + * Select the message at view index. + * + * @param aViewIndex The view index to select. This will be bounds-checked + * and if it is outside the bounds, we will clear the selection and + * bail. + */ + selectViewIndex(aViewIndex) { + let treeSelection = this.treeSelection; + // if we have no selection, we can't select something + if (!treeSelection) { + return; + } + let rowCount = this.view.dbView.rowCount; + if ( + aViewIndex == nsMsgViewIndex_None || + aViewIndex < 0 || + aViewIndex >= rowCount + ) { + this.clearSelection(); + return; + } + + // Check whether the index is already selected/current. This can be the + // case when we are here as the result of a deletion. Assuming + // nsMsgDBView::NoteChange ran and was not suppressing change + // notifications, then it's very possible the selection is already where + // we want it to go. However, in that case, nsMsgDBView::SelectionChanged + // bailed without doing anything because m_deletingRows... + // So we want to generate a change notification if that is the case. (And + // we still want to call ensureRowIsVisible, as there may be padding + // required.) + if ( + treeSelection.count == 1 && + (treeSelection.currentIndex == aViewIndex || + treeSelection.isSelected(aViewIndex)) + ) { + // Make sure the index we just selected is also the current index. + // This can happen when the tree selection adjusts itself as a result of + // changes to the tree as a result of deletion. This will not trigger + // a notification. + treeSelection.select(aViewIndex); + this.view.dbView.selectionChanged(); + } else { + // Previous code was concerned about avoiding updating commands on the + // assumption that only the selection count mattered. We no longer + // make this assumption. + // Things that may surprise you about the call to treeSelection.select: + // 1) This ends up calling the onselect method defined on the XUL 'tree' + // tag. For the 3pane this is the ThreadPaneSelectionChanged method in + // threadPane.js. That code checks a global to see if it is dealing + // with a right-click, and ignores it if so. + treeSelection.select(aViewIndex); + } + + if (this._active) { + this.ensureRowIsVisible(aViewIndex); + } + + // The saved selection is invalidated, since we've got something newer + this._savedSelection = null; + + // Do this here instead of at the beginning to prevent reentrancy issues + this._aboutToSelectMessage = false; + }, + + /** + * For every selected message in the display that is part of a (displayed) + * thread and is not the root message, de-select it and ensure that the + * root message of the thread is selected. + * This is primarily intended to be used when collapsing visible threads. + * + * We do nothing if we are not in a threaded display mode. + */ + selectSelectedThreadRoots() { + if (!this.view.showThreaded) { + return; + } + + // There are basically two implementation strategies available to us: + // 1) For each selected view index with a level > 0, keep walking 'up' + // (numerically smaller) until we find a message with level 0. + // The inefficiency here is the potentially large number of JS calls + // into XPCOM space that will be required. + // 2) Ask for the thread that each view index belongs to, use that to + // efficiently retrieve the thread root, then find the root using + // the message header. The inefficiency here is that the view + // currently does a linear scan, albeit a relatively efficient one. + // And the winner is... option 2, because the code is simpler because we + // can reuse selectMessages to do most of the work. + let selectedIndices = this.selectedIndices; + let newSelectedMessages = []; + let dbView = this.view.dbView; + for (let index of selectedIndices) { + let thread = dbView.getThreadContainingIndex(index); + newSelectedMessages.push(thread.getRootHdr()); + } + this.selectMessages(newSelectedMessages); + }, + + // @} + + /** + * @name Ensure Visibility + */ + // @{ + + /** + * Minimum number of lines to display between the 'focused' message and the + * top / bottom of the thread pane. + */ + get visibleRowPadding() { + let topPadding, bottomPadding; + + // If we can get the height of the folder pane, treat the values as + // percentages of that. + if (this.tree) { + let topPercentPadding = Services.prefs.getIntPref( + "mail.threadpane.padding.top_percent" + ); + let bottomPercentPadding = Services.prefs.getIntPref( + "mail.threadpane.padding.bottom_percent" + ); + + // Assume the bottom row is half-visible and should generally be ignored. + // (We could actually do the legwork to see if there is a partial one...) + let paneHeight = this.tree.getPageLength() - 1; + + // Convert from percentages to absolute row counts. + topPadding = Math.ceil((topPercentPadding / 100) * paneHeight); + bottomPadding = Math.ceil((bottomPercentPadding / 100) * paneHeight); + + // We need one visible row not counted in either padding, for the actual + // target message. Also helps correct for rounding errors. + if (topPadding + bottomPadding > paneHeight) { + if (topPadding > bottomPadding) { + topPadding--; + } else { + bottomPadding--; + } + } + } else { + // Something's gone wrong elsewhere, and we likely have bigger problems. + topPadding = 0; + bottomPadding = 0; + console.error("Unable to get height of folder pane (treeBox is null)"); + } + + return [topPadding, bottomPadding]; + }, + + /** + * Ensure the given view index is visible, optionally with some padding. + * By padding, we mean that the index will not be the first or last message + * displayed, but rather have messages on either side. + * We have the concept of a 'lip' when we are at the end of the message + * display. If we are near the end of the display, we want to show an + * empty row (at the bottom) so the user knows they are at the end. Also, + * if a message shows up that is new and things are sorted ascending, this + * turns out to be useful. + */ + ensureRowIsVisible(aViewIndex, aBounced) { + // Dealing with the tree view layout is a nightmare, let's just always make + // sure we re-schedule ourselves. The most particular rationale here is + // that the message pane may be toggling its state and it's much simpler + // and reliable if we ensure that all of FolderDisplayWidget's state + // change logic gets to run to completion before we run ourselves. + if (!aBounced) { + let dis = this; + window.setTimeout(function () { + dis.ensureRowIsVisible(aViewIndex, true); + }, 0); + } + + let tree = this.tree; + if (!tree || !tree.view) { + return; + } + + // try and trigger a reflow... + tree.getBoundingClientRect(); + + let maxIndex = tree.view.rowCount - 1; + + let first = tree.getFirstVisibleRow(); + // Assume the bottom row is half-visible and should generally be ignored. + // (We could actually do the legwork to see if there is a partial one...) + const halfVisible = 1; + let last = tree.getLastVisibleRow() - halfVisible; + let span = tree.getPageLength() - halfVisible; + let [topPadding, bottomPadding] = this.visibleRowPadding; + + let target; + if (aViewIndex >= last - bottomPadding) { + // The index is after the last visible guy (with padding), + // move down so that the target index is padded in 1 from the bottom. + target = Math.min(maxIndex, aViewIndex + bottomPadding) - span; + } else if (aViewIndex <= first + topPadding) { + // The index is before the first visible guy (with padding), move up. + target = Math.max(0, aViewIndex - topPadding); + } else { + // It is already visible. + return; + } + + // this sets the first visible row + tree.scrollToRow(target); + }, + + /** + * Ensure that the given range of rows is maximally visible in the thread + * pane. If the range is larger than the number of rows that can be + * displayed in the thread pane, we bias towards showing the min row (with + * padding). + * + * @param aMinRow The numerically smallest row index defining the start of + * the inclusive range. + * @param aMaxRow The numberically largest row index defining the end of the + * inclusive range. + */ + ensureRowRangeIsVisible(aMinRow, aMaxRow, aBounced) { + // Dealing with the tree view layout is a nightmare, let's just always make + // sure we re-schedule ourselves. The most particular rationale here is + // that the message pane may be toggling its state and it's much simpler + // and reliable if we ensure that all of FolderDisplayWidget's state + // change logic gets to run to completion before we run ourselves. + if (!aBounced) { + let dis = this; + window.setTimeout(function () { + dis.ensureRowRangeIsVisible(aMinRow, aMaxRow, true); + }, 0); + } + + let tree = this.tree; + if (!tree) { + return; + } + let first = tree.getFirstVisibleRow(); + const halfVisible = 1; + let last = tree.getLastVisibleRow() - halfVisible; + let span = tree.getPageLength() - halfVisible; + let [topPadding, bottomPadding] = this.visibleRowPadding; + + // bail if the range is already visible with padding constraints handled + if (first + topPadding <= aMinRow && last - bottomPadding >= aMaxRow) { + return; + } + + let target; + // if the range is bigger than we can fit, optimize position for the min row + // with padding to make it obvious the range doesn't extend above the row. + if (aMaxRow - aMinRow > span) { + target = Math.max(0, aMinRow - topPadding); + } else { + // So the range must fit, and it's a question of how we want to position + // it. For now, the answer is we try and center it, why not. + let rowSpan = aMaxRow - aMinRow + 1; + let halfSpare = Math.floor( + (span - rowSpan - topPadding - bottomPadding) / 2 + ); + target = aMinRow - halfSpare - topPadding; + } + tree.scrollToRow(target); + }, + + /** + * Ensure that the selection is visible to the extent possible. + */ + ensureSelectionIsVisible() { + let treeSelection = this.treeSelection; // potentially magic getter + if (!treeSelection || !treeSelection.count) { + return; + } + + let minRow = null, + maxRow = null; + + let rangeCount = treeSelection.getRangeCount(); + for (let iRange = 0; iRange < rangeCount; iRange++) { + let rangeMinObj = {}, + rangeMaxObj = {}; + treeSelection.getRangeAt(iRange, rangeMinObj, rangeMaxObj); + let rangeMin = rangeMinObj.value, + rangeMax = rangeMaxObj.value; + if (minRow == null || rangeMin < minRow) { + minRow = rangeMin; + } + if (maxRow == null || rangeMax > maxRow) { + maxRow = rangeMax; + } + } + + this.ensureRowRangeIsVisible(minRow, maxRow); + }, + // @} +}; + +function SetNewsFolderColumns() { + var sizeColumn = document.getElementById("sizeCol"); + var bundle = document.getElementById("bundle_messenger"); + + if (gDBView.usingLines) { + sizeColumn.setAttribute("label", bundle.getString("linesColumnHeader")); + sizeColumn.setAttribute( + "tooltiptext", + bundle.getString("linesColumnTooltip2") + ); + } else { + sizeColumn.setAttribute("label", bundle.getString("sizeColumnHeader")); + sizeColumn.setAttribute( + "tooltiptext", + bundle.getString("sizeColumnTooltip2") + ); + } +} + +function UpdateStatusQuota(folder) { + if (!document.getElementById("quotaPanel")) { + // No quotaPanel in here, like for the search window. + return; + } + + if (!(folder && folder instanceof Ci.nsIMsgImapMailFolder)) { + document.getElementById("quotaPanel").hidden = true; + return; + } + + let quotaUsagePercentage = q => + Number((100n * BigInt(q.usage)) / BigInt(q.limit)); + + // For display on main window panel only include quota names containing + // "STORAGE" or "MESSAGE". This will exclude unusual quota names containing + // items like "MAILBOX" and "LEVEL" from the panel bargraph. All quota names + // will still appear on the folder properties quota window. + // Note: Quota name is typically something like "User Quota / STORAGE". + let folderQuota = folder + .getQuota() + .filter( + quota => + quota.name.toUpperCase().includes("STORAGE") || + quota.name.toUpperCase().includes("MESSAGE") + ); + // If folderQuota not empty, find the index of the element with highest + // percent usage and determine if it is above the panel display threshold. + if (folderQuota.length > 0) { + let highest = folderQuota.reduce((acc, current) => + quotaUsagePercentage(acc) > quotaUsagePercentage(current) ? acc : current + ); + let percent = quotaUsagePercentage(highest); + if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.show") + ) { + document.getElementById("quotaPanel").hidden = true; + } else { + document.getElementById("quotaPanel").hidden = false; + document.getElementById("quotaMeter").setAttribute("value", percent); + var bundle = document.getElementById("bundle_messenger"); + document.getElementById("quotaLabel").value = bundle.getFormattedString( + "percent", + [percent] + ); + document.getElementById("quotaLabel").tooltipText = + bundle.getFormattedString("quotaTooltip2", [ + highest.usage, + highest.limit, + ]); + let quotaPanel = document.getElementById("quotaPanel"); + if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.warning") + ) { + quotaPanel.classList.remove("alert-warning", "alert-critical"); + } else if ( + percent < + Services.prefs.getIntPref("mail.quota.mainwindow_threshold.critical") + ) { + quotaPanel.classList.remove("alert-critical"); + quotaPanel.classList.add("alert-warning"); + } else { + quotaPanel.classList.remove("alert-warning"); + quotaPanel.classList.add("alert-critical"); + } + } + } else { + document.getElementById("quotaPanel").hidden = true; + } +} diff --git a/comm/mail/base/content/globalOverlay.js b/comm/mail/base/content/globalOverlay.js new file mode 100644 index 0000000000..b31751a490 --- /dev/null +++ b/comm/mail/base/content/globalOverlay.js @@ -0,0 +1,122 @@ +/* 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/. */ + +/** + * Notifies observers that quitting has been requested. + * + * @returns {boolean} - True if an observer prevented quitting, false otherwise. + */ +function canQuitApplication() { + try { + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested"); + + // Something aborted the quit process. + if (cancelQuit.data) { + return false; + } + } catch (ex) {} + return true; +} + +/** + * Quit the application if no `quit-application-requested` observer prevents it. + */ +function goQuitApplication() { + if (!canQuitApplication()) { + return false; + } + + Services.startup.quit(Ci.nsIAppStartup.eAttemptQuit); + return true; +} + +/** + * Gets the first registered controller that returns true for both + * `supportsCommand` and `isCommandEnabled`, or null if no controllers + * return true for both. + * + * @param {string} command - The command name to pass to controllers. + * @returns {nsIController|null} + */ +function getEnabledControllerForCommand(command) { + // The first controller for which `supportsCommand` returns true. + let controllerA = + top.document.commandDispatcher.getControllerForCommand(command); + if (controllerA?.isCommandEnabled(command)) { + return controllerA; + } + + // Didn't find a controller, or `isCommandEnabled` returned false? + // Try the other controllers. Note this isn't exactly the same set + // of controllers as `commandDispatcher` has. + for (let i = 0; i < top.controllers.getControllerCount(); i++) { + let controllerB = top.controllers.getControllerAt(i); + if ( + controllerB !== controllerA && + controllerB.supportsCommand(command) && + controllerB.isCommandEnabled(command) + ) { + return controllerB; + } + } + + return null; +} + +/** + * Updates the enabled state of the element with the ID `command`. The command + * is considered enabled if at least one controller returns true for both + * `supportsCommand` and `isCommandEnabled`. + * + * @param {string} command - The command name to pass to controllers. + */ +function goUpdateCommand(command) { + try { + goSetCommandEnabled(command, !!getEnabledControllerForCommand(command)); + } catch (e) { + console.error(`An error occurred updating the ${command} command: ${e}`); + } +} + +/** + * Calls `doCommand` on the first controller that returns true for both + * `supportsCommand` and `isCommandEnabled`. + * + * @param {string} command - The command name to pass to controllers. + * @param {any[]} args - Any number of arguments to pass to the chosen + * controller. Note that passing arguments is not part of the `nsIController` + * interface and only possible for JS controllers. + */ +function goDoCommand(command, ...args) { + try { + let controller = getEnabledControllerForCommand(command); + if (controller) { + controller = controller.wrappedJSObject ?? controller; + controller.doCommand(command, ...args); + } + } catch (e) { + console.error(`An error occurred executing the ${command} command: ${e}`); + } +} + +/** + * Updates the enabled state of the element with the ID `id`. + * + * @param {string} id + * @param {boolean} enabled + */ +function goSetCommandEnabled(id, enabled) { + let node = document.getElementById(id); + + if (node) { + if (enabled) { + node.removeAttribute("disabled"); + } else { + node.setAttribute("disabled", "true"); + } + } +} diff --git a/comm/mail/base/content/glodaFacetTab.js b/comm/mail/base/content/glodaFacetTab.js new file mode 100644 index 0000000000..f194b2c24b --- /dev/null +++ b/comm/mail/base/content/glodaFacetTab.js @@ -0,0 +1,111 @@ +/* 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/. */ + +ChromeUtils.defineModuleGetter( + this, + "GlodaMsgSearcher", + "resource:///modules/gloda/GlodaMsgSearcher.jsm" +); + +var glodaFacetTabType = { + name: "glodaFacet", + perTabPanel: "vbox", + lastTabId: 0, + strings: Services.strings.createBundle( + "chrome://messenger/locale/glodaFacetView.properties" + ), + modes: { + glodaFacet: { + // this is what get exposed on the tab for icon purposes + type: "glodaSearch", + }, + }, + openTab(aTab, aArgs) { + // If aArgs is empty, default to a blank user search. + if (!Object.keys(aArgs).length) { + aArgs = { searcher: new GlodaMsgSearcher(null, "") }; + } + // we have no browser until our XUL document loads + aTab.browser = null; + + aTab.tabNode.setIcon( + "chrome://messenger/skin/icons/new/compact/search.svg" + ); + + // First clone the page and set up the basics. + let clone = document + .getElementById("glodaTab") + .firstElementChild.cloneNode(true); + + aTab.panel.setAttribute("id", "glodaTab" + this.lastTabId); + aTab.panel.appendChild(clone); + aTab.iframe = aTab.panel.querySelector("iframe"); + + if ("query" in aArgs) { + aTab.query = aArgs.query; + aTab.collection = aTab.query.getCollection(); + + aTab.title = this.strings.GetStringFromName( + "glodaFacetView.tab.query.label" + ); + aTab.searchString = null; + } else if ("searcher" in aArgs) { + aTab.searcher = aArgs.searcher; + aTab.collection = aTab.searcher.getCollection(); + aTab.query = aTab.searcher.query; + if ("IMSearcher" in aArgs) { + aTab.IMSearcher = aArgs.IMSearcher; + aTab.IMCollection = aArgs.IMSearcher.getCollection(); + aTab.IMQuery = aTab.IMSearcher.query; + } + + let searchString = aTab.searcher.searchString; + aTab.searchInputValue = aTab.searchString = searchString; + aTab.title = searchString + ? searchString + : this.strings.GetStringFromName("glodaFacetView.tab.search.label"); + } else if ("collection" in aArgs) { + aTab.collection = aArgs.collection; + + aTab.title = this.strings.GetStringFromName( + "glodaFacetView.tab.query.label" + ); + aTab.searchString = null; + } + + function xulLoadHandler() { + aTab.iframe.contentWindow.tab = aTab; + aTab.browser = aTab.iframe.contentDocument.getElementById("browser"); + aTab.browser.setAttribute( + "src", + "chrome://messenger/content/glodaFacetView.xhtml" + ); + + // Wire up the search input icon click event + let searchInput = aTab.panel.querySelector(".remote-gloda-search"); + searchInput.focus(); + } + + aTab.iframe.contentWindow.addEventListener("load", xulLoadHandler, { + capture: false, + once: true, + }); + aTab.iframe.setAttribute( + "src", + "chrome://messenger/content/glodaFacetViewWrapper.xhtml" + ); + + this.lastTabId++; + }, + closeTab(aTab) {}, + saveTabState(aTab) { + // nothing to do; we are not multiplexed + }, + showTab(aTab) { + // nothing to do; we are not multiplexed + }, + getBrowser(aTab) { + return aTab.browser; + }, +}; diff --git a/comm/mail/base/content/glodaFacetView.js b/comm/mail/base/content/glodaFacetView.js new file mode 100644 index 0000000000..db8bce8150 --- /dev/null +++ b/comm/mail/base/content/glodaFacetView.js @@ -0,0 +1,1114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * This file provides the global context for the faceting environment. In the + * Model View Controller (paradigm), we are the view and the XBL widgets are + * the the view and controller. + * + * Because much of the work related to faceting is not UI-specific, we try and + * push as much of it into mailnews/db/gloda/Facet.jsm. In some cases we may + * get it wrong and it may eventually want to migrate. + */ + +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm"); +var { Gloda } = ChromeUtils.import("resource:///modules/gloda/GlodaPublic.jsm"); +var { GlodaConstants } = ChromeUtils.import( + "resource:///modules/gloda/GlodaConstants.jsm" +); +var { GlodaSyntheticView } = ChromeUtils.import( + "resource:///modules/gloda/GlodaSyntheticView.jsm" +); +var { FacetDriver, FacetUtils } = ChromeUtils.import( + "resource:///modules/gloda/Facet.jsm" +); + +var glodaFacetStrings = Services.strings.createBundle( + "chrome://messenger/locale/glodaFacetView.properties" +); + +/** + * Object containing query-explanantion binding methods. + */ +const QueryExplanation = { + get node() { + return document.getElementById("query-explanation"); + }, + /** + * Indicate that we are based on a fulltext search + */ + setFulltext(aMsgSearcher) { + while (this.node.hasChildNodes()) { + this.node.lastChild.remove(); + } + + const spanify = (text, classNames) => { + const span = document.createElement("span"); + span.setAttribute("class", classNames); + span.textContent = text; + this.node.appendChild(span); + return span; + }; + + const searchLabel = glodaFacetStrings.GetStringFromName( + "glodaFacetView.search.label2" + ); + spanify(searchLabel, "explanation-fulltext-label"); + + const criteriaText = glodaFacetStrings.GetStringFromName( + "glodaFacetView.constraints.query.fulltext." + + (aMsgSearcher.andTerms ? "and" : "or") + + "JoinWord" + ); + for (let [iTerm, term] of aMsgSearcher.fulltextTerms.entries()) { + if (iTerm) { + spanify(criteriaText, "explanation-fulltext-criteria"); + } + spanify(term, "explanation-fulltext-term"); + } + }, + setQuery(msgQuery) { + try { + while (this.node.hasChildNodes()) { + this.node.lastChild.remove(); + } + + const spanify = (text, classNames) => { + const span = document.createElement("span"); + span.setAttribute("class", classNames); + span.textContent = text; + this.node.appendChild(span); + return span; + }; + + let label = glodaFacetStrings.GetStringFromName( + "glodaFacetView.search.label2" + ); + spanify(label, "explanation-query-label"); + + let constraintStrings = []; + for (let constraint of msgQuery._constraints) { + if (constraint[0] != 1) { + // No idea what this is about. + return; + } + if (constraint[1].attributeName == "involves") { + let involvesLabel = glodaFacetStrings.GetStringFromName( + "glodaFacetView.constraints.query.involves.label" + ); + involvesLabel = involvesLabel.replace("#1", constraint[2].value); + spanify(involvesLabel, "explanation-query-involves"); + } else if (constraint[1].attributeName == "tag") { + const tagLabel = glodaFacetStrings.GetStringFromName( + "glodaFacetView.constraints.query.tagged.label" + ); + const tag = constraint[2]; + const tagNode = document.createElement("span"); + const color = MailServices.tags.getColorForKey(tag.key); + tagNode.setAttribute("class", "message-tag"); + if (color) { + let textColor = !TagUtils.isColorContrastEnough(color) + ? "white" + : "black"; + tagNode.setAttribute( + "style", + "color: " + textColor + "; background-color: " + color + ";" + ); + } + tagNode.textContent = tag.tag; + spanify(tagLabel, "explanation-query-tagged"); + this.node.appendChild(tagNode); + } + } + label = label + constraintStrings.join(", "); // XXX l10n? + } catch (e) { + console.error(e); + } + }, +}; + +/** + * Object containing facets binding methods. + */ +const UIFacets = { + get node() { + return document.getElementById("facets"); + }, + clearFacets() { + while (this.node.hasChildNodes()) { + this.node.lastChild.remove(); + } + }, + addFacet(type, attrDef, args) { + let facet; + + if (type === "boolean") { + facet = document.createElement("facet-boolean"); + } else if (type === "boolean-filtered") { + facet = document.createElement("facet-boolean-filtered"); + } else if (type === "discrete") { + facet = document.createElement("facet-discrete"); + } else { + facet = document.createElement("div"); + facet.setAttribute("class", "facetious"); + } + + facet.attrDef = attrDef; + facet.nounDef = attrDef.objectNounDef; + facet.setAttribute("type", type); + + for (let key in args) { + facet[key] = args[key]; + } + + facet.setAttribute("name", attrDef.attributeName); + this.node.appendChild(facet); + + return facet; + }, +}; + +/** + * Represents the active constraints on a singular facet. Singular facets can + * only have an inclusive set or an exclusive set, but not both. Non-singular + * facets can have both. Because they are different worlds, non-singular gets + * its own class, |ActiveNonSingularConstraint|. + */ +function ActiveSingularConstraint(aFaceter, aRanged) { + this.faceter = aFaceter; + this.attrDef = aFaceter.attrDef; + this.facetDef = aFaceter.facetDef; + this.ranged = Boolean(aRanged); + this.clear(); +} +ActiveSingularConstraint.prototype = { + _makeQuery() { + // have the faceter make the query and the invert decision for us if it + // implements the makeQuery method. + if ("makeQuery" in this.faceter) { + [this.query, this.invertQuery] = this.faceter.makeQuery( + this.groupValues, + this.inclusive + ); + return; + } + + let query = (this.query = Gloda.newQuery(GlodaConstants.NOUN_MESSAGE)); + let constraintFunc; + // If the facet definition references a queryHelper defined by the noun + // type, use that instead of the standard constraint function. + if ("queryHelper" in this.facetDef) { + constraintFunc = + query[this.attrDef.boundName + this.facetDef.queryHelper]; + } else { + constraintFunc = + query[ + this.ranged + ? this.attrDef.boundName + "Range" + : this.attrDef.boundName + ]; + } + constraintFunc.apply(query, this.groupValues); + + this.invertQuery = !this.inclusive; + }, + /** + * Adjust the constraint given the incoming faceting constraint desired. + * Mainly, if the inclusive flag is the same as what we already have, we + * just append the new values to the existing set of values. If it is not + * the same, we replace them. + * + * @returns true if the caller needs to revalidate their understanding of the + * constraint because we have flipped whether we are inclusive or + * exclusive and have thrown away some constraints as a result. + */ + constrain(aInclusive, aGroupValues) { + if (aInclusive == this.inclusive) { + this.groupValues = this.groupValues.concat(aGroupValues); + this._makeQuery(); + return false; + } + + let needToRevalidate = this.inclusive != null; + this.inclusive = aInclusive; + this.groupValues = aGroupValues; + this._makeQuery(); + + return needToRevalidate; + }, + /** + * Relax something we previously constrained. Remove it, some might say. It + * is possible after relaxing that we will no longer be an active constraint. + * + * @returns true if we are no longer constrained at all. + */ + relax(aInclusive, aGroupValues) { + if (aInclusive != this.inclusive) { + throw new Error("You can't relax a constraint that isn't possible."); + } + + for (let groupValue of aGroupValues) { + let index = this.groupValues.indexOf(groupValue); + if (index == -1) { + throw new Error("Tried to relax a constraint that was not in force."); + } + this.groupValues.splice(index, 1); + } + if (this.groupValues.length == 0) { + this.clear(); + return true; + } + this._makeQuery(); + + return false; + }, + /** + * Indicate whether this constraint is actually doing anything anymore. + */ + get isConstrained() { + return this.inclusive != null; + }, + /** + * Clear the constraint so that the next call to adjust initializes it. + */ + clear() { + this.inclusive = null; + this.groupValues = null; + this.query = null; + this.invertQuery = null; + }, + /** + * Filter the items against our constraint. + */ + sieve(aItems) { + let query = this.query; + let expectedResult = !this.invertQuery; + return aItems.filter(item => query.test(item) == expectedResult); + }, + isIncludedGroup(aGroupValue) { + if (!this.inclusive) { + return false; + } + return this.groupValues.includes(aGroupValue); + }, + isExcludedGroup(aGroupValue) { + if (this.inclusive) { + return false; + } + return this.groupValues.includes(aGroupValue); + }, +}; + +function ActiveNonSingularConstraint(aFaceter, aRanged) { + this.faceter = aFaceter; + this.attrDef = aFaceter.attrDef; + this.facetDef = aFaceter.facetDef; + this.ranged = Boolean(aRanged); + + this.clear(); +} +ActiveNonSingularConstraint.prototype = { + _makeQuery(aInclusive, aGroupValues) { + // have the faceter make the query and the invert decision for us if it + // implements the makeQuery method. + if ("makeQuery" in this.faceter) { + // returns [query, invertQuery] directly + return this.faceter.makeQuery(aGroupValues, aInclusive); + } + + let query = Gloda.newQuery(GlodaConstants.NOUN_MESSAGE); + let constraintFunc; + // If the facet definition references a queryHelper defined by the noun + // type, use that instead of the standard constraint function. + if ("queryHelper" in this.facetDef) { + constraintFunc = + query[this.attrDef.boundName + this.facetDef.queryHelper]; + } else { + constraintFunc = + query[ + this.ranged + ? this.attrDef.boundName + "Range" + : this.attrDef.boundName + ]; + } + constraintFunc.apply(query, aGroupValues); + + return [query, false]; + }, + + /** + * Adjust the constraint given the incoming faceting constraint desired. + * Mainly, if the inclusive flag is the same as what we already have, we + * just append the new values to the existing set of values. If it is not + * the same, we replace them. + */ + constrain(aInclusive, aGroupValues) { + let groupIdAttr = this.attrDef.objectNounDef.isPrimitive + ? null + : this.facetDef.groupIdAttr; + let idMap = aInclusive ? this.includedGroupIds : this.excludedGroupIds; + let valList = aInclusive + ? this.includedGroupValues + : this.excludedGroupValues; + for (let groupValue of aGroupValues) { + let valId = + groupIdAttr !== null && groupValue != null + ? groupValue[groupIdAttr] + : groupValue; + idMap[valId] = true; + valList.push(groupValue); + } + + let [query, invertQuery] = this._makeQuery(aInclusive, valList); + if (aInclusive && !invertQuery) { + this.includeQuery = query; + } else { + this.excludeQuery = query; + } + + return false; + }, + /** + * Relax something we previously constrained. Remove it, some might say. It + * is possible after relaxing that we will no longer be an active constraint. + * + * @returns true if we are no longer constrained at all. + */ + relax(aInclusive, aGroupValues) { + let groupIdAttr = this.attrDef.objectNounDef.isPrimitive + ? null + : this.facetDef.groupIdAttr; + let idMap = aInclusive ? this.includedGroupIds : this.excludedGroupIds; + let valList = aInclusive + ? this.includedGroupValues + : this.excludedGroupValues; + for (let groupValue of aGroupValues) { + let valId = + groupIdAttr !== null && groupValue != null + ? groupValue[groupIdAttr] + : groupValue; + if (!(valId in idMap)) { + throw new Error("Tried to relax a constraint that was not in force."); + } + delete idMap[valId]; + + let index = valList.indexOf(groupValue); + valList.splice(index, 1); + } + + if (valList.length == 0) { + if (aInclusive) { + this.includeQuery = null; + } else { + this.excludeQuery = null; + } + } else { + let [query, invertQuery] = this._makeQuery(aInclusive, valList); + if (aInclusive && !invertQuery) { + this.includeQuery = query; + } else { + this.excludeQuery = query; + } + } + + return this.includeQuery == null && this.excludeQuery == null; + }, + /** + * Indicate whether this constraint is actually doing anything anymore. + */ + get isConstrained() { + return this.includeQuery == null && this.excludeQuery == null; + }, + /** + * Clear the constraint so that the next call to adjust initializes it. + */ + clear() { + this.includeQuery = null; + this.includedGroupIds = {}; + this.includedGroupValues = []; + + this.excludeQuery = null; + this.excludedGroupIds = {}; + this.excludedGroupValues = []; + }, + /** + * Filter the items against our constraint. + */ + sieve(aItems) { + let includeQuery = this.includeQuery; + let excludeQuery = this.excludeQuery; + return aItems.filter( + item => + (!includeQuery || includeQuery.test(item)) && + (!excludeQuery || !excludeQuery.test(item)) + ); + }, + isIncludedGroup(aGroupValue) { + let valId = aGroupValue[this.facetDef.groupIdAttr]; + return valId in this.includedGroupIds; + }, + isExcludedGroup(aGroupValue) { + let valId = aGroupValue[this.facetDef.groupIdAttr]; + return valId in this.excludedGroupIds; + }, +}; + +var FacetContext = { + facetDriver: new FacetDriver(Gloda.lookupNounDef("message"), window), + + /** + * The root collection which our active set is a subset of. We hold onto this + * for garbage collection reasons, although the tab that owns us should also + * be holding on. + */ + _collection: null, + set collection(aCollection) { + this._collection = aCollection; + }, + get collection() { + return this._collection; + }, + + _sortBy: null, + get sortBy() { + return this._sortBy; + }, + set sortBy(val) { + try { + if (val == this._sortBy) { + return; + } + this._sortBy = val; + this.build(this._sieveAll()); + } catch (e) { + console.error(e); + } + }, + /** + * List of the current working set + */ + _activeSet: null, + get activeSet() { + return this._activeSet; + }, + + /** + * fullSet is a special attribute which is passed a set of items that we're + * displaying, but the order of which is determined by the sortBy property. + * On setting the fullSet, we compute both sorted lists, and then on getting, + * we return the appropriate one. + */ + get fullSet() { + return this._sortBy == "-dascore" + ? this._relevantSortedItems + : this._dateSortedItems; + }, + + set fullSet(items) { + let scores; + if (this.searcher && this.searcher.scores) { + scores = this.searcher.scores; + } else { + scores = Gloda.scoreNounItems(items); + } + let scoredItems = items.map(function (item, index) { + return [scores[index], item]; + }); + scoredItems.sort((a, b) => b[0] - a[0]); + this._relevantSortedItems = scoredItems.map(scoredItem => scoredItem[1]); + + this._dateSortedItems = this._relevantSortedItems + .concat() + .sort((a, b) => b.date - a.date); + }, + + initialBuild() { + if (this.searcher) { + QueryExplanation.setFulltext(this.searcher); + } else { + QueryExplanation.setQuery(this.collection.query); + } + // we like to sort them so should clone the list + this.faceters = this.facetDriver.faceters.concat(); + + this._timelineShown = !Services.prefs.getBoolPref( + "gloda.facetview.hidetimeline" + ); + + this.everFaceted = false; + this._activeConstraints = {}; + if (this.searcher) { + let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby"); + this._sortBy = sortByPref == 0 || sortByPref == 2 ? "-dascore" : "-date"; + } else { + this._sortBy = "-date"; + } + this.fullSet = this._removeDupes(this._collection.items.concat()); + if ("IMCollection" in this) { + this.fullSet = this.fullSet.concat(this.IMCollection.items); + } + this.build(this.fullSet); + }, + + /** + * Remove duplicate messages from search results. + * + * @param aItems the initial set of messages to deduplicate + * @returns the subset of those, with duplicates removed. + * + * Some IMAP servers (here's looking at you, Gmail) will create message + * duplicates unbeknownst to the user. We'd like to deal with them earlier + * in the pipeline, but that's a bit hard right now. So as a workaround + * we'd rather not show them in the Search Results UI. The simplest way + * of doing that is just to cull (from the display) messages with have the + * Message-ID of a message already displayed. + */ + _removeDupes(aItems) { + let deduped = []; + let msgIdsSeen = {}; + for (let item of aItems) { + if (item.headerMessageID in msgIdsSeen) { + continue; + } + deduped.push(item); + msgIdsSeen[item.headerMessageID] = true; + } + return deduped; + }, + + /** + * Kick-off a new faceting pass. + * + * @param aNewSet the set of items to facet. + * @param aCallback the callback to invoke when faceting is completed. + */ + build(aNewSet, aCallback) { + this._activeSet = aNewSet; + this._callbackOnFacetComplete = aCallback; + this.facetDriver.go(this._activeSet, this.facetingCompleted, this); + }, + + /** + * Attempt to figure out a reasonable number of rows to limit each facet to + * display. While the number will ordinarily be dominated by the maximum + * number of rows we believe the user can easily scan, this may also be + * impacted by layout concerns (since we want to avoid scrolling). + */ + planLayout() { + // XXX arbitrary! + this.maxDisplayRows = 8; + this.maxMessagesToShow = 10; + }, + + /** + * Clean up the UI in preparation for a new query to come in. + */ + _resetUI() { + for (let faceter of this.faceters) { + if (faceter.xblNode && !faceter.xblNode.explicit) { + faceter.xblNode.remove(); + } + faceter.xblNode = null; + faceter.constraint = null; + } + }, + + _groupCountComparator(a, b) { + return b.groupCount - a.groupCount; + }, + /** + * Tells the UI about all the facets when notified by the |facetDriver| when + * it is done faceting everything. + */ + facetingCompleted() { + this.planLayout(); + + if (!this.everFaceted) { + this.everFaceted = true; + this.faceters.sort(this._groupCountComparator); + for (let faceter of this.faceters) { + let attrName = faceter.attrDef.attributeName; + let explicitBinding = document.getElementById("facet-" + attrName); + + if (explicitBinding) { + explicitBinding.explicit = true; + explicitBinding.faceter = faceter; + explicitBinding.attrDef = faceter.attrDef; + explicitBinding.facetDef = faceter.facetDef; + explicitBinding.nounDef = faceter.attrDef.objectNounDef; + explicitBinding.orderedGroups = faceter.orderedGroups; + // explicit booleans should always be displayed for consistency + if ( + faceter.groupCount >= 1 || + explicitBinding.getAttribute("type").includes("boolean") + ) { + try { + explicitBinding.build(true); + } catch (e) { + console.error(e); + } + explicitBinding.removeAttribute("uninitialized"); + } + faceter.xblNode = explicitBinding; + continue; + } + + // ignore facets that do not vary! + if (faceter.groupCount <= 1) { + faceter.xblNode = null; + continue; + } + + faceter.xblNode = UIFacets.addFacet(faceter.type, faceter.attrDef, { + faceter, + facetDef: faceter.facetDef, + orderedGroups: faceter.orderedGroups, + maxDisplayRows: this.maxDisplayRows, + explicit: false, + }); + } + } else { + for (let faceter of this.faceters) { + // Do not bother with un-displayed facets, or that are locked by a + // constraint. But do bother if the widget can be updated without + // losing important data. + if ( + !faceter.xblNode || + (faceter.constraint && !faceter.xblNode.canUpdate) + ) { + continue; + } + + // hide things that have 0/1 groups now and are not constrained and not + // explicit + if ( + faceter.groupCount <= 1 && + !faceter.constraint && + (!faceter.xblNode.explicit || faceter.type == "date") + ) { + faceter.xblNode.style.display = "none"; + } else { + // otherwise, update + faceter.xblNode.orderedGroups = faceter.orderedGroups; + faceter.xblNode.build(false); + faceter.xblNode.removeAttribute("style"); + } + } + } + + if (!this._timelineShown) { + this._hideTimeline(true); + } + + this._showResults(); + + if (this._callbackOnFacetComplete) { + let callback = this._callbackOnFacetComplete; + this._callbackOnFacetComplete = null; + callback(); + } + }, + + _showResults() { + let results = document.getElementById("results"); + let numMessageToShow = Math.min( + this.maxMessagesToShow * this._numPages, + this._activeSet.length + ); + results.setMessages(this._activeSet.slice(0, numMessageToShow)); + + let showLoading = document.getElementById("showLoading"); + showLoading.style.display = "none"; // Hide spinner, we're done thinking. + + let showEmpty = document.getElementById("showEmpty"); + let showAll = document.getElementById("gloda-showall"); + // Check for no messages at all. + if (this._activeSet.length == 0) { + showEmpty.style.display = "block"; + showAll.style.display = "none"; + } else { + showEmpty.style.display = "none"; + showAll.style.display = "block"; + } + + let showMore = document.getElementById("showMore"); + showMore.style.display = + this._activeSet.length > numMessageToShow ? "block" : "none"; + }, + + showMore() { + this._numPages += 1; + this._showResults(); + }, + + zoomOut() { + let facetDate = document.getElementById("facet-date"); + this.removeFacetConstraint( + facetDate.faceter, + true, + facetDate.vis.constraints + ); + facetDate.setAttribute("zoomedout", "true"); + }, + + toggleTimeline() { + try { + this._timelineShown = !this._timelineShown; + if (this._timelineShown) { + this._showTimeline(); + } else { + this._hideTimeline(false); + } + } catch (e) { + console.error(e); + } + }, + + _showTimeline() { + let facetDate = document.getElementById("facet-date"); + if (facetDate.style.display == "none") { + facetDate.style.display = "inherit"; + // Force binding attachment so the transition to the + // visible state actually happens. + facetDate.getBoundingClientRect(); + } + let listener = () => { + // Need to set overflow to visible so that the zoom button + // is not cut off at the top, and overflow=hidden causes + // the transition to not work as intended. + facetDate.removeAttribute("style"); + }; + facetDate.addEventListener("transitionend", listener, { once: true }); + facetDate.removeAttribute("hide"); + document.getElementById("date-toggle").setAttribute("checked", "true"); + Services.prefs.setBoolPref("gloda.facetview.hidetimeline", false); + }, + + _hideTimeline(immediate) { + let facetDate = document.getElementById("facet-date"); + if (immediate) { + facetDate.style.display = "none"; + } + facetDate.style.overflow = "hidden"; + facetDate.setAttribute("hide", "true"); + document.getElementById("date-toggle").removeAttribute("checked"); + Services.prefs.setBoolPref("gloda.facetview.hidetimeline", true); + }, + + _timelineShown: true, + + /** For use in hovering specific results. */ + fakeResultFaceter: {}, + /** For use in hovering specific results. */ + fakeResultAttr: {}, + + _numPages: 1, + _HOVER_STABILITY_DURATION_MS: 100, + _brushedFacet: null, + _brushedGroup: null, + _brushedItems: null, + _brushTimeout: null, + hoverFacet(aFaceter, aAttrDef, aGroupValue, aGroupItems) { + // bail if we are already brushing this item + if (this._brushedFacet == aFaceter && this._brushedGroup == aGroupValue) { + return; + } + + this._brushedFacet = aFaceter; + this._brushedGroup = aGroupValue; + this._brushedItems = aGroupItems; + + if (this._brushTimeout != null) { + clearTimeout(this._brushTimeout); + } + this._brushTimeout = setTimeout( + this._timeoutHoverWrapper, + this._HOVER_STABILITY_DURATION_MS, + this + ); + }, + _timeoutHover() { + this._brushTimeout = null; + for (let faceter of this.faceters) { + if (faceter == this._brushedFacet || !faceter.xblNode) { + continue; + } + + if (this._brushedItems != null) { + faceter.xblNode.brushItems(this._brushedItems); + } else { + faceter.xblNode.clearBrushedItems(); + } + } + }, + _timeoutHoverWrapper(aThis) { + aThis._timeoutHover(); + }, + unhoverFacet(aFaceter, aAttrDef, aGroupValue, aGroupItems) { + // have we already brushed from some other source already? ignore then. + if (this._brushedFacet != aFaceter || this._brushedGroup != aGroupValue) { + return; + } + + // reuse hover facet to null everyone out + this.hoverFacet(null, null, null, null); + }, + + /** + * Maps attribute names to their corresponding |ActiveConstraint|, if they + * have one. + */ + _activeConstraints: null, + /** + * Called by facet bindings when the user does some clicking and wants to + * impose a new constraint. + * + * @param aFaceter The faceter that is the source of this constraint. We + * need to know this because once a facet has a constraint attached, + * the UI stops updating it. + * @param {boolean} aInclusive Is this an inclusive (true) or exclusive + * (false) constraint? The constraint instance is the one that deals with + * the nuances resulting from this. + * @param aGroupValues A list of the group values this constraint covers. In + * general, we expect that only one group value will be present in the + * list since this method should get called each time the user clicks + * something. Previously, we provided support for an "other" case which + * covered multiple groupValues so a single click needed to be able to + * pass in a list. The "other" case is gone now, but semantically it's + * okay for us to support a list. + * @param [aRanged] Is it a ranged constraint? (Currently only for dates) + * @param [aNukeExisting] Do we need to replace the existing constraint and + * re-sieve everything? This currently only happens for dates, where + * our display allows a click to actually make our range more generic + * than it currently is. (But this only matters if we already have + * a date constraint applied.) + * @param [aCallback] The callback to call once (re-)faceting has completed. + * + * @returns true if the caller needs to revalidate because the constraint has + * changed in a way other than explicitly requested. This can occur if + * a singular constraint flips its inclusive state and throws away + * constraints. + */ + addFacetConstraint( + aFaceter, + aInclusive, + aGroupValues, + aRanged, + aNukeExisting, + aCallback + ) { + let attrName = aFaceter.attrDef.attributeName; + + let constraint; + let needToSieveAll = false; + if (attrName in this._activeConstraints) { + constraint = this._activeConstraints[attrName]; + + needToSieveAll = true; + if (aNukeExisting) { + constraint.clear(); + } + } else { + let constraintClass = aFaceter.attrDef.singular + ? ActiveSingularConstraint + : ActiveNonSingularConstraint; + constraint = this._activeConstraints[attrName] = new constraintClass( + aFaceter, + aRanged + ); + aFaceter.constraint = constraint; + } + let needToRevalidate = constraint.constrain(aInclusive, aGroupValues); + + // Given our current implementation, we can only be further constraining our + // active set, so we can just sieve the existing active set with the + // (potentially updated) constraint. In some cases, it would be much + // cheaper to use the facet's knowledge about the items in the groups, but + // for now let's keep a single code-path for how we refine the active set. + this.build( + needToSieveAll ? this._sieveAll() : constraint.sieve(this.activeSet), + aCallback + ); + + return needToRevalidate; + }, + + /** + * Remove a constraint previously imposed by addFacetConstraint. The + * constraint must still be active, which means you need to pay attention + * when |addFacetConstraint| returns true indicating that you need to + * revalidate. + * + * @param aFaceter + * @param aInclusive Whether the group values were previously included / + * excluded. If you want to remove some values that were included and + * some that were excluded then you need to call us once for each case. + * @param aGroupValues The list of group values to remove. + * @param aCallback The callback to call once all facets have been updated. + * + * @returns true if the constraint has been completely removed. Under the + * current regime, this will likely cause the binding that is calling us + * to be rebuilt, so be aware if you are trying to do any cool animation + * that might no longer make sense. + */ + removeFacetConstraint(aFaceter, aInclusive, aGroupValues, aCallback) { + let attrName = aFaceter.attrDef.attributeName; + let constraint = this._activeConstraints[attrName]; + + let constraintGone = false; + + if (constraint.relax(aInclusive, aGroupValues)) { + delete this._activeConstraints[attrName]; + aFaceter.constraint = null; + constraintGone = true; + } + + // we definitely need to re-sieve everybody in this case... + this.build(this._sieveAll(), aCallback); + + return constraintGone; + }, + + /** + * Sieve the items from the underlying collection against all constraints, + * returning the value. + */ + _sieveAll() { + let items = this.fullSet; + + for (let elem in this._activeConstraints) { + items = this._activeConstraints[elem].sieve(items); + } + + return items; + }, + + toggleFulltextCriteria() { + this.tab.searcher.andTerms = !this.tab.searcher.andTerms; + this._resetUI(); + this.collection = this.tab.searcher.getCollection(this); + }, + + /** + * Show the active message set in a 3-pane tab. + */ + showActiveSetInTab() { + let tabmail = this.rootWin.document.getElementById("tabmail"); + tabmail.openTab("mail3PaneTab", { + folderPaneVisible: false, + syntheticView: new GlodaSyntheticView({ + collection: Gloda.explicitCollection( + GlodaConstants.NOUN_MESSAGE, + this.activeSet + ), + }), + title: this.tab.title, + }); + }, + + /** + * Show the conversation in a new 3-pane tab. + * + * @param {glodaFacetBindings.xml#result-message} aResultMessage The + * result the user wants to see in more details. + * @param {boolean} [aBackground] Whether it should be in the background. + */ + showConversationInTab(aResultMessage, aBackground) { + let tabmail = this.rootWin.document.getElementById("tabmail"); + let message = aResultMessage.message; + if ( + "IMCollection" in this && + message instanceof Gloda.lookupNounDef("im-conversation").clazz + ) { + tabmail.openTab("chat", { + convType: "log", + conv: message, + searchTerm: aResultMessage.firstMatchText, + background: aBackground, + }); + return; + } + tabmail.openTab("mail3PaneTab", { + folderPaneVisible: false, + syntheticView: new GlodaSyntheticView({ + conversation: message.conversation, + message, + }), + title: message.conversation.subject, + background: aBackground, + }); + }, + + onItemsAdded(aItems, aCollection) {}, + onItemsModified(aItems, aCollection) {}, + onItemsRemoved(aItems, aCollection) {}, + onQueryCompleted(aCollection) { + if ( + this.tab.query.completed && + (!("IMQuery" in this.tab) || this.tab.IMQuery.completed) + ) { + this.initialBuild(); + } + }, +}; + +/** + * addEventListener betrayals compel us to establish our link with the + * outside world from inside. NeilAway suggests the problem might have + * been the registration of the listener prior to initiating the load. Which + * is odd considering it works for the XUL case, but I could see how that might + * differ. Anywho, this works for now and is a delightful reference to boot. + */ +function reachOutAndTouchFrame() { + let us = window + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem); + + FacetContext.rootWin = us.rootTreeItem.domWindow; + + let parentWin = us.parent.domWindow; + let aTab = (FacetContext.tab = parentWin.tab); + parentWin.tab = null; + window.addEventListener("resize", function () { + document.getElementById("facet-date").build(true); + }); + // we need to hook the context up as a listener in all cases since + // removal notifications are required. + if ("searcher" in aTab) { + FacetContext.searcher = aTab.searcher; + aTab.searcher.listener = FacetContext; + if ("IMSearcher" in aTab) { + FacetContext.IMSearcher = aTab.IMSearcher; + aTab.IMSearcher.listener = FacetContext; + } + } else { + FacetContext.searcher = null; + aTab.collection.listener = FacetContext; + } + FacetContext.collection = aTab.collection; + if ("IMCollection" in aTab) { + FacetContext.IMCollection = aTab.IMCollection; + } + + // if it has already completed, we need to prod things + if ( + aTab.query.completed && + (!("IMQuery" in aTab) || aTab.IMQuery.completed) + ) { + FacetContext.initialBuild(); + } +} + +function clickOnBody(event) { + if (event.bubbles) { + document.querySelector("facet-popup-menu").hide(); + } + return 0; +} diff --git a/comm/mail/base/content/glodaFacetView.xhtml b/comm/mail/base/content/glodaFacetView.xhtml new file mode 100644 index 0000000000..2b53355e0e --- /dev/null +++ b/comm/mail/base/content/glodaFacetView.xhtml @@ -0,0 +1,123 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd" [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%brandDTD; +<!ENTITY % facetViewDTD SYSTEM "chrome://messenger/locale/glodaFacetView.dtd"> +%facetViewDTD; ]> +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" +> + <head> + <!-- Themes --> + <link + rel="stylesheet" + href="chrome://messenger/skin/glodaFacetView.css" + type="text/css" + /> + <!-- Custom elements --> + <script src="chrome://messenger/content/glodaFacet.js"></script> + <!-- Global Context --> + <script src="chrome://messenger/content/glodaFacetView.js"></script> + <!-- Libs --> + <script src="chrome://messenger/content/protovis-r2.6-modded.js"></script> + <!-- Facet Binding Stuff that doesn't belong in XBL --> + <script src="chrome://messenger/content/glodaFacetVis.js"></script> + </head> + <body + id="body" + onload="reachOutAndTouchFrame()" + onmouseup="return clickOnBody(event)" + > + <facet-popup-menu class="popup-menu" variety="invisible" /> + <div id="gloda-facet-view"> + <div class="facets facets-sidebar" id="facets"> + <h1 id="filter-header-label">&glodaFacetView.filters.label;</h1> + <div> + <facet-boolean + id="facet-fromMe" + type="boolean" + attr="fromMe" + uninitialized="true" + /> + <facet-boolean + id="facet-toMe" + type="boolean" + attr="toMe" + uninitialized="true" + /> + <facet-boolean + id="facet-star" + type="boolean" + attr="star" + uninitialized="true" + /><br /> + <facet-boolean-filtered + id="facet-attachmentTypes" + type="boolean-filtered" + attr="attachmentTypes" + groupDisplayProperty="categoryLabel" + uninitialized="true" + /> + </div> + </div> + + <div id="main-column"> + <div id="header"> + <div id="query-explanation" /> + <a + id="gloda-showall" + class="results-message-showall-button" + title="&glodaFacetView.openEmailAsList.tooltip;" + onclick="FacetContext.showActiveSetInTab();" + onkeypress="if (event.charCode == KeyEvent.DOM_VK_SPACE) { FacetContext.showActiveSetInTab(); event.preventDefault(); }" + tabindex="0" + > + &glodaFacetView.openEmailAsList.label; + </a> + </div> + <div id="data-column"> + <!-- facet-results-message is put before facet-date here so that it gets upgraded first. + facet-date uses width of facet-results-message for the visualization. Using order property + we can show facet-date before facet-results-message --> + <facet-results-message id="results" class="results" /> + <facet-date id="facet-date" class="facetious" type="date" /> + <div class="loading" id="showLoading"> + <span class="loading"> + <img + class="loading" + src="chrome://global/skin/icons/loading.png" + alt="" + /> + &glodaFacetView.loading.label; + </span> + </div> + <div id="showEmpty" class="empty"> + <span class="empty"> + <img + class="empty" + src="chrome://messenger/skin/icons/empty-search-results.svg" + alt="" + /> + <br /> + &glodaFacetView.empty.label; + </span> + </div> + <button + id="showMore" + class="show-more" + tabindex="0" + onclick="FacetContext.showMore()" + onkeypress="if (event.charCode == KeyEvent.DOM_VK_SPACE) { FacetContext.showMore(); event.preventDefault() }" + > + &glodaFacetView.pageMore.label; + </button> + </div> + </div> + </div> + </body> +</html> diff --git a/comm/mail/base/content/glodaFacetViewWrapper.xhtml b/comm/mail/base/content/glodaFacetViewWrapper.xhtml new file mode 100644 index 0000000000..8b2ea1e8bc --- /dev/null +++ b/comm/mail/base/content/glodaFacetViewWrapper.xhtml @@ -0,0 +1,54 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<window + id="window" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <script src="chrome://messenger/content/viewZoomOverlay.js" /> + <script> + <![CDATA[ + function getBrowser() { + return document.getElementById('browser'); + } + ]]> + </script> + <commandset id="selectEditMenuItems"> + <command id="cmd_fullZoomReduce" oncommand="ZoomManager.reduce();" /> + <command id="cmd_fullZoomEnlarge" oncommand="ZoomManager.enlarge();" /> + <command id="cmd_fullZoomReset" oncommand="ZoomManager.reset();" /> + </commandset> + <keyset> + <!--move to locale--> + <key + id="key_fullZoomEnlarge" + key="+" + command="cmd_fullZoomEnlarge" + modifiers="accel" + /> + <key + id="key_fullZoomEnlarge2" + key="=" + command="cmd_fullZoomEnlarge" + modifiers="accel" + /> + <key + id="key_fullZoomReduce" + key="-" + command="cmd_fullZoomReduce" + modifiers="accel" + /> + <key + id="key_fullZoomReset" + key="0" + command="cmd_fullZoomReset" + modifiers="accel" + /> + </keyset> + <tooltip id="aHTMLTooltip" page="true" /> + <browser id="browser" flex="1" disablehistory="true" tooltip="aHTMLTooltip" /> +</window> diff --git a/comm/mail/base/content/glodaFacetVis.js b/comm/mail/base/content/glodaFacetVis.js new file mode 100644 index 0000000000..0060f67d92 --- /dev/null +++ b/comm/mail/base/content/glodaFacetVis.js @@ -0,0 +1,428 @@ +/* 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/. */ + +/* + * Facet visualizations that would be awkward in XBL. Allegedly because the + * interaciton idiom of a protovis-based visualization is entirely different + * from XBL, but also a lot because of the lack of good syntax highlighting. + */ + +/* import-globals-from glodaFacetView.js */ +/* import-globals-from protovis-r2.6-modded.js */ + +/** + * A date facet visualization abstraction. + */ +function DateFacetVis(aBinding, aCanvasNode) { + this.binding = aBinding; + this.canvasNode = aCanvasNode; + + this.faceter = aBinding.faceter; + this.attrDef = this.faceter.attrDef; +} +DateFacetVis.prototype = { + build() { + let resultsBarRect = document + .getElementById("results") + .getBoundingClientRect(); + this.allowedSpace = resultsBarRect.right - resultsBarRect.left; + this.render(); + }, + rebuild() { + this.render(); + }, + + _MIN_BAR_SIZE_PX: 9, + _BAR_SPACING_PX: 1, + + _MAX_BAR_SIZE_PX: 44, + + _AXIS_FONT: "10px sans-serif", + _AXIS_HEIGHT_NO_LABEL_PX: 6, + _AXIS_HEIGHT_WITH_LABEL_PX: 14, + _AXIS_VERT_SPACING_PX: 1, + _AXIS_HORIZ_MIN_SPACING_PX: 4, + + _MAX_DAY_COUNT_LABEL_DISPLAY: 10, + + /** + * Figure out how to chunk things given the linear space in pixels. In an + * ideal world we would not use pixels, avoiding tying ourselves to assumed + * pixel densities, but we do not live there. Reality wants crisp graphics + * and does not have enough pixels that you can ignore the pixel coordinate + * space and have things still look sharp (and good). + * + * Because of our love of sharpness, we will potentially under-use the space + * allocated to us. + * + * @param aPixels The number of linear content pixels we have to work with. + * You are in charge of the borders and such, so you subtract that off + * before you pass it in. + * @returns An object with attributes: + */ + makeIdealScaleGivenSpace(aPixels) { + let facet = this.faceter; + // build a scale and have it grow the edges based on the span + let scale = pv.Scales.dateTime(facet.oldest, facet.newest); + + const Span = pv.Scales.DateTimeScale.Span; + const MS_MIN = 60 * 1000, + MS_HOUR = 60 * MS_MIN, + MS_DAY = 24 * MS_HOUR, + MS_WEEK = 7 * MS_DAY, + MS_MONTHISH = 31 * MS_DAY, + MS_YEARISH = 366 * MS_DAY; + const roughMap = {}; + roughMap[Span.DAYS] = MS_DAY; + roughMap[Span.WEEKS] = MS_WEEK; + // we overestimate since we want to slightly underestimate pixel usage + // in enoughPix's rough estimate + roughMap[Span.MONTHS] = MS_MONTHISH; + roughMap[Span.YEARS] = MS_YEARISH; + + const minBarPix = this._MIN_BAR_SIZE_PX + this._BAR_SPACING_PX; + + let delta = facet.newest.valueOf() - facet.oldest.valueOf(); + let span, rules, barPixBudget; + // evil side-effect land + function enoughPix(aSpan) { + span = aSpan; + // do a rough guestimate before doing something potentially expensive... + barPixBudget = Math.floor(aPixels / (delta / roughMap[span])); + if (barPixBudget < minBarPix + 1) { + return false; + } + + rules = scale.ruleValues(span); + // + 0 because we want to over-estimate slightly for niceness rounding + // reasons + barPixBudget = Math.floor(aPixels / (rules.length + 0)); + delta = scale.max().valueOf() - scale.min().valueOf(); + return barPixBudget > minBarPix; + } + + // day is our smallest unit + const ALLOWED_SPANS = [Span.DAYS, Span.WEEKS, Span.MONTHS, Span.YEARS]; + for (let trySpan of ALLOWED_SPANS) { + if (enoughPix(trySpan)) { + // do the equivalent of nice() for our chosen span + scale.min(scale.round(scale.min(), trySpan, false)); + scale.max(scale.round(scale.max(), trySpan, true)); + // try again for paranoia, but mainly for the side-effect... + if (enoughPix(trySpan)) { + break; + } + } + } + + // - Figure out our labeling strategy + // normalize the symbols into an explicit ordering + let spandex = ALLOWED_SPANS.indexOf(span); + // from least-specific to most-specific + let labelTiers = []; + // add year spans in all cases, although whether we draw bars depends on if + // we are in year mode or not + labelTiers.push({ + rules: span == Span.YEARS ? rules : scale.ruleValues(Span.YEARS, true), + // We should not hit the null member of the array... + label: [{ year: "numeric" }, { year: "2-digit" }, null], + boost: span == Span.YEARS, + noFringe: span == Span.YEARS, + }); + // add month spans if we are days or weeks... + if (spandex < 2) { + labelTiers.push({ + rules: scale.ruleValues(Span.MONTHS, true), + // try to use the full month, falling back to the short month + label: [{ month: "long" }, { month: "short" }, null], + boost: false, + }); + } + // add week spans if our granularity is days... + if (span == Span.DAYS) { + let numDays = delta / MS_DAY; + + // find out how many days we are talking about and add days if it's small + // enough, display both the date and the day of the week + if (numDays <= this._MAX_DAY_COUNT_LABEL_DISPLAY) { + labelTiers.push({ + rules, + label: [{ day: "numeric" }, null], + boost: true, + noFringe: true, + }); + labelTiers.push({ + rules, + label: [{ weekday: "short" }, null], + boost: true, + noFringe: true, + }); + } else { + // show the weeks since we're at greater than a day time-scale + labelTiers.push({ + rules: scale.ruleValues(Span.WEEKS, true), + // labeling weeks is nonsensical; no one understands ISO weeks + // numbers. + label: [null], + boost: false, + }); + } + } + + return { scale, span, rules, barPixBudget, labelTiers }; + }, + + render() { + let { scale, span, rules, barPixBudget, labelTiers } = + this.makeIdealScaleGivenSpace(this.allowedSpace); + + barPixBudget = Math.floor(barPixBudget); + + let minBarPix = this._MIN_BAR_SIZE_PX + this._BAR_SPACING_PX; + let maxBarPix = this._MAX_BAR_SIZE_PX + this._BAR_SPACING_PX; + + let barPix = Math.max(minBarPix, Math.min(maxBarPix, barPixBudget)); + let width = barPix * (rules.length - 1); + + let totalAxisLabelHeight = 0; + let isRTL = window.getComputedStyle(this.binding).direction == "rtl"; + + // we need to do some font-metric calculations, so create a canvas... + let fontMetricCanvas = document.createElement("canvas"); + let ctx = fontMetricCanvas.getContext("2d"); + + // do the labeling logic, + for (let labelTier of labelTiers) { + let labelRules = labelTier.rules; + let perLabelBudget = width / (labelRules.length - 1); + for (let labelFormat of labelTier.label) { + let maxWidth = 0; + let displayValues = []; + for (let iRule = 0; iRule < labelRules.length - 1; iRule++) { + // is this at the either edge of the display? in that case, it might + // be partial... + let fringe = + labelRules.length > 2 && + (iRule == 0 || iRule == labelRules.length - 2); + let labelStartDate = labelRules[iRule]; + let labelEndDate = labelRules[iRule + 1]; + let labelText = labelFormat + ? labelStartDate.toLocaleDateString(undefined, labelFormat) + : null; + let labelStartNorm = Math.max(0, scale.normalize(labelStartDate)); + let labelEndNorm = Math.min(1, scale.normalize(labelEndDate)); + let labelBudget = (labelEndNorm - labelStartNorm) * width; + if (labelText) { + let labelWidth = ctx.measureText(labelText).width; + // discard labels at the fringe who don't fit in our budget + if (fringe && !labelTier.noFringe && labelWidth > labelBudget) { + labelText = null; + } else { + maxWidth = Math.max(labelWidth, maxWidth); + } + } + + displayValues.push([ + labelStartNorm, + labelEndNorm, + labelText, + labelStartDate, + labelEndDate, + ]); + } + // there needs to be space between the labels. (we may be over-padding + // here if there is only one label with the maximum width...) + maxWidth += this._AXIS_HORIZ_MIN_SPACING_PX; + + if (labelTier.boost && maxWidth > perLabelBudget) { + // we only boost labels that are the same span as the bins, so rules + // === labelRules at this point. (and barPix === perLabelBudget) + barPix = perLabelBudget = maxWidth; + width = barPix * (labelRules.length - 1); + } + if (maxWidth <= perLabelBudget) { + labelTier.displayValues = displayValues; + labelTier.displayLabel = labelFormat != null; + labelTier.vertHeight = labelFormat + ? this._AXIS_HEIGHT_WITH_LABEL_PX + : this._AXIS_HEIGHT_NO_LABEL_PX; + labelTier.vertOffset = totalAxisLabelHeight; + totalAxisLabelHeight += + labelTier.vertHeight + this._AXIS_VERT_SPACING_PX; + + break; + } + } + } + + let barWidth = barPix - this._BAR_SPACING_PX; + + width = barPix * (rules.length - 1); + // we ideally want this to be the same size as the max rows translates to... + let height = 100; + let ch = height - totalAxisLabelHeight; + + let [bins, maxBinSize] = this.binBySpan(scale, span, rules); + + // build empty bins for our hot bins + this.emptyBins = bins.map(bin => 0); + + let binScale = maxBinSize ? ch / maxBinSize : 1; + + let vis = (this.vis = new pv.Panel() + .canvas(this.canvasNode) + // dimensions + .width(width) + .height(ch) + // margins + .bottom(totalAxisLabelHeight)); + + let faceter = this.faceter; + let dis = this; + // bin bars... + vis + .add(pv.Bar) + .data(bins) + .bottom(0) + .height(d => Math.floor(d.items.length * binScale)) + .width(() => barWidth) + .left(function () { + return isRTL ? null : this.index * barPix; + }) + .right(function () { + return isRTL ? this.index * barPix : null; + }) + .fillStyle("var(--barColor)") + .event("mouseover", function (d) { + return this.fillStyle("var(--barHlColor)"); + }) + .event("mouseout", function (d) { + return this.fillStyle("var(--barColor)"); + }) + .event("click", function (d) { + dis.constraints = [[d.startDate, d.endDate]]; + dis.binding.setAttribute("zoomedout", "false"); + FacetContext.addFacetConstraint( + faceter, + true, + dis.constraints, + true, + true + ); + }); + + this.hotBars = vis + .add(pv.Bar) + .data(this.emptyBins) + .bottom(0) + .height(d => Math.floor(d * binScale)) + .width(() => barWidth) + .left(function () { + return this.index * barPix; + }) + .fillStyle("var(--barHlColor)"); + + for (let labelTier of labelTiers) { + let labelBar = vis + .add(pv.Bar) + .data(labelTier.displayValues) + .bottom(-totalAxisLabelHeight + labelTier.vertOffset) + .height(labelTier.vertHeight) + .left(d => (isRTL ? null : Math.floor(width * d[0]))) + .right(d => (isRTL ? Math.floor(width * d[0]) : null)) + .width(d => Math.floor(width * d[1]) - Math.floor(width * d[0]) - 1) + .fillStyle("var(--dateColor)") + .event("mouseover", function (d) { + return this.fillStyle("var(--dateHLColor)"); + }) + .event("mouseout", function (d) { + return this.fillStyle("var(--dateColor)"); + }) + .event("click", function (d) { + dis.constraints = [[d[3], d[4]]]; + dis.binding.setAttribute("zoomedout", "false"); + FacetContext.addFacetConstraint( + faceter, + true, + dis.constraints, + true, + true + ); + }); + + if (labelTier.displayLabel) { + labelBar + .anchor("top") + .add(pv.Label) + .font(this._AXIS_FONT) + .textAlign("center") + .textBaseline("top") + .textStyle("var(--dateTextColor)") + .text(d => d[2]); + } + } + + vis.render(); + }, + + hoverItems(aItems) { + let itemToBin = this.itemToBin; + let bins = this.emptyBins.concat(); + for (let item of aItems) { + if (item.id in itemToBin) { + bins[itemToBin[item.id]]++; + } + } + this.hotBars.data(bins); + this.vis.render(); + }, + + clearHover() { + this.hotBars.data(this.emptyBins); + this.vis.render(); + }, + + /** + * Bin items at the given span granularity with the set of rules generated + * for the given span. This could equally as well be done as a pre-built + * array of buckets with a linear scan of items and a calculation of what + * bucket they should be placed in. + */ + binBySpan(aScale, aSpan, aRules, aItems) { + let bins = []; + let maxBinSize = 0; + let binCount = aRules.length - 1; + let itemToBin = (this.itemToBin = {}); + + // We used to break this out by case, but that was a lot of code, and it was + // somewhat ridiculous. So now we just do the simple, if somewhat more + // expensive thing. Reviewer, feel free to thank me. + // We do a pass through the rules, mapping each rounded rule to a bin. We + // then do a pass through all of the items, rounding them down and using + // that to perform a lookup against the map. We could special-case the + // rounding, but I doubt it's worth it. + let binMap = {}; + for (let iRule = 0; iRule < binCount; iRule++) { + let binStartDate = aRules[iRule], + binEndDate = aRules[iRule + 1]; + binMap[binStartDate.valueOf().toString()] = iRule; + bins.push({ items: [], startDate: binStartDate, endDate: binEndDate }); + } + let attrKey = this.attrDef.boundName; + for (let item of this.faceter.validItems) { + let val = item[attrKey]; + // round it to the rule... + val = aScale.round(val, aSpan, false); + // which we can then map... + let itemBin = binMap[val.valueOf().toString()]; + itemToBin[item.id] = itemBin; + bins[itemBin].items.push(item); + } + for (let bin of bins) { + maxBinSize = Math.max(bin.items.length, maxBinSize); + } + + return [bins, maxBinSize]; + }, +}; diff --git a/comm/mail/base/content/helpMenu.inc.xhtml b/comm/mail/base/content/helpMenu.inc.xhtml new file mode 100644 index 0000000000..c69aa7ea6d --- /dev/null +++ b/comm/mail/base/content/helpMenu.inc.xhtml @@ -0,0 +1,43 @@ +# 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/. + +<menu id="helpMenu" + data-l10n-id="menu-help-help-title" + onpopupshowing="buildHelpMenu();"> + <menupopup id="menu_HelpPopup"> + <menuitem id="menu_openHelp" + data-l10n-id="menu-help-get-help" + key="key_openHelp" + oncommand="openSupportURL();"/> + <menuitem id="menu_openTour" + data-l10n-id="menu-help-explore-features" + oncommand="openLinkText(event, 'tourURL');"/> + <menuitem id="menu_keyboardShortcuts" + data-l10n-id="menu-help-shortcuts" + oncommand="openLinkText(event, 'keyboardShortcutsURL');"/> + <menuseparator/> + <menuitem id="getInvolved" + data-l10n-id="menu-help-get-involved" + oncommand="openLinkText(event, 'getInvolvedURL');"/> + <menuitem id="donationsPage" + data-l10n-id="menu-help-donation" + oncommand="openLinkText(event, 'donateURL');"/> + <menuitem id="feedbackPage" + data-l10n-id="menu-help-share-feedback" + oncommand="openLinkText(event, 'feedbackURL');"/> + <menuseparator id="functionsSeparator"/> + <menuitem id="helpTroubleshootMode" + data-l10n-id="menu-help-enter-troubleshoot-mode" + oncommand="safeModeRestart();"/> + <menuitem id="aboutsupport_open" + data-l10n-id="menu-help-troubleshooting-info" + oncommand="openAboutSupport();"/> +#ifndef XP_MACOSX + <menuseparator id="aboutSeparator"/> +#endif + <menuitem id="aboutName" + data-l10n-id="menu-help-about-product" + oncommand="openAboutDialog();"/> + </menupopup> + </menu> diff --git a/comm/mail/base/content/hiddenWindowMac.js b/comm/mail/base/content/hiddenWindowMac.js new file mode 100644 index 0000000000..f6a8ffccc8 --- /dev/null +++ b/comm/mail/base/content/hiddenWindowMac.js @@ -0,0 +1,124 @@ +/* -*- Mode: Javascript; 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/. */ + +function hiddenWindowStartup() { + // Disable menus which are not appropriate + let disabledItems = [ + "menu_newFolder", + "newMailAccountMenuItem", + "newNewsgroupAccountMenuItem", + "menu_close", + "menu_saveAs", + "menu_saveAsFile", + "menu_newVirtualFolder", + "menu_find", + "menu_findCmd", + "menu_findAgainCmd", + "menu_sendunsentmsgs", + "menu_subscribe", + "menu_deleteFolder", + "menu_renameFolder", + "menu_select", + "menu_selectAll", + "menu_selectThread", + "menu_favoriteFolder", + "menu_properties", + "menu_Toolbars", + "menu_MessagePaneLayout", + "menu_showMessage", + "menu_toggleThreadPaneHeader", + "menu_showFolderPane", + "menu_FolderViews", + "viewSortMenu", + "groupBySort", + "viewMessageViewMenu", + "viewMessagesMenu", + "menu_expandAllThreads", + "collapseAllThreads", + "viewheadersmenu", + "viewBodyMenu", + "viewAttachmentsInlineMenuitem", + "viewFullZoomMenu", + "goNextMenu", + "menu_nextMsg", + "menu_nextUnreadMsg", + "menu_nextUnreadThread", + "goPreviousMenu", + "menu_prevMsg", + "menu_prevUnreadMsg", + "menu_goForward", + "menu_goBack", + "goStartPage", + "newMsgCmd", + "replyMainMenu", + "replySenderMainMenu", + "replyNewsgroupMainMenu", + "menu_replyToAll", + "menu_replyToList", + "menu_forwardMsg", + "forwardAsMenu", + "menu_editMsgAsNew", + "openMessageWindowMenuitem", + "openConversationMenuitem", + "moveMenu", + "copyMenu", + "moveToFolderAgain", + "tagMenu", + "markMenu", + "markReadMenuItem", + "menu_markThreadAsRead", + "menu_markReadByDate", + "menu_markAllRead", + "markFlaggedMenuItem", + "menu_markAsJunk", + "menu_markAsNotJunk", + "createFilter", + "killThread", + "killSubthread", + "watchThread", + "applyFilters", + "runJunkControls", + "deleteJunk", + "menu_import", + "searchMailCmd", + "searchAddressesCmd", + "filtersCmd", + "cmd_close", + "minimizeWindow", + "zoomWindow", + "appmenu_newFolder", + "appmenu_newMailAccountMenuItem", + "appmenu_newNewsgroupAccountMenuItem", + "appmenu_saveAs", + "appmenu_saveAsFile", + "appmenu_newVirtualFolder", + "appmenu_findAgainCmd", + "appmenu_favoriteFolder", + "appmenu_properties", + "appmenu_MessagePaneLayout", + "appmenu_showMessage", + "appmenu_toggleThreadPaneHeader", + "appmenu_showFolderPane", + "appmenu_FolderViews", + "appmenu_groupBySort", + "appmenu_findCmd", + "appmenu_find", + "appmenu_openMessageWindowMenuitem", + ]; + + let element; + for (let id of disabledItems) { + element = document.getElementById(id); + if (element) { + element.setAttribute("disabled", "true"); + } + } + + // Also hide the window-list separator if it exists. + element = document.getElementById("sep-window-list"); + if (element) { + element.setAttribute("hidden", "true"); + } +} diff --git a/comm/mail/base/content/hiddenWindowMac.xhtml b/comm/mail/base/content/hiddenWindowMac.xhtml new file mode 100644 index 0000000000..3f0e493e32 --- /dev/null +++ b/comm/mail/base/content/hiddenWindowMac.xhtml @@ -0,0 +1,101 @@ +<?xml version="1.0"?> + +# 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/. + +<!DOCTYPE window [ +#include messenger-doctype.inc.dtd +]> + +<window id="hidden-window" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml" + onload="hiddenWindowStartup();"> + +<script src="chrome://messenger/content/globalOverlay.js"/> +<script src="chrome://messenger/content/mailWindow.js"/> +<script src="chrome://messenger/content/messenger.js"/> +<script src="chrome://messenger/content/mail3PaneWindowCommands.js"/> +<script src="chrome://messenger/content/searchBar.js"/> +<script src="chrome://messenger/content/hiddenWindowMac.js"/> +<script src="chrome://messenger/content/mailCommands.js"/> +<script src="chrome://messenger/content/mailWindowOverlay.js"/> +<script src="chrome://messenger/content/mailTabs.js"/> +<script src="chrome://messenger-newsblog/content/newsblogOverlay.js"/> +<script src="chrome://messenger/content/accountUtils.js"/> +<script src="chrome://messenger/content/mail-offline.js"/> +<script src="chrome://messenger/content/msgViewPickerOverlay.js"/> +<script src="chrome://messenger/content/viewZoomOverlay.js"/> +<script src="chrome://communicator/content/utilityOverlay.js"/> +<script src="chrome://messenger/content/mailCore.js"/> +<script src="chrome://messenger/content/newmailaccount/uriListener.js"/> +<script src="chrome://global/content/macWindowMenu.js"/> + +<stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/> +<stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/> + +<linkset> + <html:link rel="localization" href="branding/brand.ftl"/> + <html:link rel="localization" href="messenger/messenger.ftl"/> + <html:link rel="localization" href="messenger/menubar.ftl"/> + <html:link rel="localization" href="messenger/appmenu.ftl"/> + <html:link rel="localization" href="toolkit/global/textActions.ftl"/> + <html:link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl"/> +</linkset> + +<!-- keys are appended from the overlay --> +<keyset id="mailKeys"> +#include mainKeySet.inc.xhtml + <keyset id="tasksKeys"> + <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage" + modifiers="accel,shift"/> + <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage" + modifiers="accel"/> + </keyset> +</keyset> + +<commandset id="mailCommands"> +#include mainCommandSet.inc.xhtml + <commandset id="mailSearchMenuItems"/> + <commandset id="globalEditMenuItems" + commandupdater="true" + events="create-menu-edit" + oncommandupdate="goUpdateGlobalEditMenuItems()"/> + <commandset id="selectEditMenuItems" + commandupdater="true" + events="create-menu-edit" + oncommandupdate="goUpdateSelectEditMenuItems()"/> + <commandset id="clipboardEditMenuItems" + commandupdater="true" + events="clipboard" + oncommandupdate="goUpdatePasteMenuItems()"/> + <commandset id="tasksCommands"> + <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/> + <command id="cmd_newCard" oncommand="openNewCardDialog()"/> + </commandset> +</commandset> + + <!-- it's the whole mailWindowOverlay.xhtml menubar! hidden windows need to + have a menubar for situations where they're the only window remaining + on a platform that wants to leave the app running, like the Mac. + --> + <box id="navigation-toolbox-background"> + <toolbox id="navigation-toolbox" flex="1" labelalign="end" defaultlabelalign="end"> + + <vbox id="titlebar"> + <!-- Menu --> + <toolbar id="toolbar-menubar" + class="chromeclass-menubar themeable-full" + type="menubar" + context="toolbar-context-menu"> +# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it +# can be shared with other top level windows. +#include messenger-menubar.inc.xhtml + </toolbar> + </vbox> + </toolbox> + </box> + +<browser id="hiddenBrowser" disablehistory="true"/> + +</window> diff --git a/comm/mail/base/content/macMessengerMenu.js b/comm/mail/base/content/macMessengerMenu.js new file mode 100644 index 0000000000..3ee6ba4872 --- /dev/null +++ b/comm/mail/base/content/macMessengerMenu.js @@ -0,0 +1,99 @@ +/* -*- Mode: Javascript; 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/. */ + +/* import-globals-from mailCore.js */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +// Load and add the menu item to the OS X Dock icon menu. +addEventListener( + "load", + function () { + let dockMenuElement = document.getElementById("menu_mac_dockmenu"); + let nativeMenu = Cc[ + "@mozilla.org/widget/standalonenativemenu;1" + ].createInstance(Ci.nsIStandaloneNativeMenu); + + nativeMenu.init(dockMenuElement); + + let dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService( + Ci.nsIMacDockSupport + ); + dockSupport.dockMenu = nativeMenu; + }, + false +); + +/** + * When the Preferences window is actually loaded, this Listener is called. + * Not doing this way could make DOM elements not available. + */ +function loadListener(event) { + setTimeout(function () { + let prefWin = Services.wm.getMostRecentWindow("Mail:Preferences"); + prefWin.gSubDialog.open( + "chrome://messenger/content/preferences/dockoptions.xhtml" + ); + }); +} + +/** + * When the Preferences window is opened/closed, this observer will be called. + * This is done so subdialog opens as a child of it. + */ +function PrefWindowObserver() { + this.observe = function (aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + aSubject.addEventListener("load", loadListener, { + capture: false, + once: true, + }); + } + Services.ww.unregisterNotification(this); + }; +} + +/** + * Show the Dock Options sub-dialog hanging from the Preferences window. + * If Preference window was already opened, this will select General pane before + * opening Dock Options sub-dialog. + */ +function openDockOptions() { + let win = Services.wm.getMostRecentWindow("Mail:Preferences"); + + if (win) { + openOptionsDialog("paneGeneral"); + win.gSubDialog("chrome://messenger/content/preferences/dockoptions.xhtml"); + } else { + Services.ww.registerNotification(new PrefWindowObserver()); + openOptionsDialog("paneGeneral"); + } +} + +/** + * Open a new window for writing a new message + */ +function writeNewMessageDock() { + // Default identity will be used as sender for the new message. + MailServices.compose.OpenComposeWindow( + null, + null, + null, + Ci.nsIMsgCompType.New, + Ci.nsIMsgCompFormat.Default, + null, + null, + null + ); +} + +/** + * Open the address book window + */ +function openAddressBookDock() { + toAddressBook(); +} diff --git a/comm/mail/base/content/macWindowMenu.inc.xhtml b/comm/mail/base/content/macWindowMenu.inc.xhtml new file mode 100644 index 0000000000..e75a68f51d --- /dev/null +++ b/comm/mail/base/content/macWindowMenu.inc.xhtml @@ -0,0 +1,22 @@ +# 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/. + +<!-- Mac window menu --> + <menu id="windowMenu" + label="&windowMenu.label;"> + <menupopup id="windowPopup"> + <menuseparator/> + <menuitem id="minimizeWindow" + label="&minimizeWindow.label;" + oncommand="window.minimize();" + key="key_minimizeWindow"/> + <menuitem id="zoomWindow" + label="&zoomWindow.label;" + oncommand="zoomWindow();"/> + <!-- decomment when "BringAllToFront" is implemented + <menuseparator/> + <menuitem label="&bringAllToFront.label;" disabled="true"/> --> + <menuseparator id="sep-window-list"/> + </menupopup> + </menu> diff --git a/comm/mail/base/content/mail-offline.js b/comm/mail/base/content/mail-offline.js new file mode 100644 index 0000000000..13024b874a --- /dev/null +++ b/comm/mail/base/content/mail-offline.js @@ -0,0 +1,276 @@ +/* -*- Mode: Javascript; 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/. */ + +/* globals msgWindow */ // From mailWindow.js + +var MailOfflineMgr = { + offlineManager: null, + offlineBundle: null, + + init() { + Services.obs.addObserver(this, "network:offline-status-changed"); + + this.offlineManager = Cc[ + "@mozilla.org/messenger/offline-manager;1" + ].getService(Ci.nsIMsgOfflineManager); + this.offlineBundle = Services.strings.createBundle( + "chrome://messenger/locale/offline.properties" + ); + + // initialize our offline state UI + this.updateOfflineUI(!this.isOnline()); + }, + + uninit() { + Services.obs.removeObserver(this, "network:offline-status-changed"); + }, + + /** + * @returns true if we are online + */ + isOnline() { + return !Services.io.offline; + }, + + /** + * Toggles the online / offline state, initiated by the user. Depending on user settings + * we may prompt the user to send unsent messages when going online or to download messages for + * offline use when going offline. + */ + toggleOfflineStatus() { + // the offline manager(goOnline and synchronizeForOffline) actually does the dirty work of + // changing the offline state with the networking service. + if (!this.isOnline()) { + // We do the go online stuff in our listener for the online state change. + Services.io.offline = false; + // resume managing offline status now that we are going back online. + Services.io.manageOfflineStatus = + Services.prefs.getBoolPref("offline.autoDetect"); + } else { + // going offline + // Stop automatic management of the offline status since the user has + // decided to go offline. + Services.io.manageOfflineStatus = false; + var prefDownloadMessages = Services.prefs.getIntPref( + "offline.download.download_messages" + ); + // 0 == Ask, 1 == Always Download, 2 == Never Download + var downloadForOfflineUse = + (prefDownloadMessages == 0 && + this.confirmDownloadMessagesForOfflineUse()) || + prefDownloadMessages == 1; + this.offlineManager.synchronizeForOffline( + downloadForOfflineUse, + downloadForOfflineUse, + false, + true, + msgWindow + ); + } + }, + + observe(aSubject, aTopic, aState) { + if (aTopic == "network:offline-status-changed") { + this.mailOfflineStateChanged(aState == "offline"); + } + }, + + /** + * @returns true if there are unsent messages + */ + haveUnsentMessages() { + return Cc["@mozilla.org/messengercompose/sendlater;1"] + .getService(Ci.nsIMsgSendLater) + .hasUnsentMessages(); + }, + + /** + * open the offline panel in the account manager for the currently loaded + * account. + */ + openOfflineAccountSettings() { + window.parent.MsgAccountManager("am-offline.xhtml"); + }, + + /** + * Prompt the user about going online to send unsent messages, and then send them + * if appropriate. Puts the app back into online mode. + * + * @param aMsgWindow the msg window to be used when going online + */ + goOnlineToSendMessages(aMsgWindow) { + let goOnlineToSendMsgs = Services.prompt.confirm( + window, + this.offlineBundle.GetStringFromName("sendMessagesOfflineWindowTitle1"), + this.offlineBundle.GetStringFromName("sendMessagesOfflineLabel1") + ); + + if (goOnlineToSendMsgs) { + this.offlineManager.goOnline( + true /* send unsent messages*/, + false, + aMsgWindow + ); + } + }, + + /** + * Prompts the user to confirm sending of unsent messages. This is different from + * goOnlineToSendMessages which involves going online to send unsent messages. + * + * @returns true if the user wants to send unsent messages + */ + confirmSendUnsentMessages() { + let alwaysAsk = { value: true }; + let sendUnsentMessages = + Services.prompt.confirmEx( + window, + this.offlineBundle.GetStringFromName("sendMessagesWindowTitle1"), + this.offlineBundle.GetStringFromName("sendMessagesLabel2"), + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1, + this.offlineBundle.GetStringFromName("sendMessagesNow2"), + this.offlineBundle.GetStringFromName("processMessagesLater2"), + null, + this.offlineBundle.GetStringFromName("sendMessagesCheckboxLabel1"), + alwaysAsk + ) == 0; + + // if the user changed the ask me setting then update the global pref based on their yes / no answer + if (!alwaysAsk.value) { + Services.prefs.setIntPref( + "offline.send.unsent_messages", + sendUnsentMessages ? 1 : 2 + ); + } + + return sendUnsentMessages; + }, + + /** + * Should we send unsent messages? Based on the value of + * offline.send.unsent_messages, this method may prompt the user. + * + * @returns true if we should send unsent messages + */ + shouldSendUnsentMessages() { + var sendUnsentWhenGoingOnlinePref = Services.prefs.getIntPref( + "offline.send.unsent_messages" + ); + if (sendUnsentWhenGoingOnlinePref == 2) { + // never send + return false; + } else if (this.haveUnsentMessages()) { + // if we we have unsent messages, then honor the offline.send.unsent_messages pref. + if ( + (sendUnsentWhenGoingOnlinePref == 0 && + this.confirmSendUnsentMessages()) || + sendUnsentWhenGoingOnlinePref == 1 + ) { + return true; + } + } + return false; + }, + + /** + * Prompts the user to download messages for offline use before going offline. + * May update the value of offline.download.download_messages + * + * @returns true if the user wants to download messages for offline use. + */ + confirmDownloadMessagesForOfflineUse() { + let alwaysAsk = { value: true }; + let downloadMessages = + Services.prompt.confirmEx( + window, + this.offlineBundle.GetStringFromName("downloadMessagesWindowTitle1"), + this.offlineBundle.GetStringFromName("downloadMessagesLabel1"), + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_1, + this.offlineBundle.GetStringFromName("downloadMessagesNow2"), + this.offlineBundle.GetStringFromName("processMessagesLater2"), + null, + this.offlineBundle.GetStringFromName("downloadMessagesCheckboxLabel1"), + alwaysAsk + ) == 0; + + // if the user changed the ask me setting then update the global pref based on their yes / no answer + if (!alwaysAsk.value) { + Services.prefs.setIntPref( + "offline.download.download_messages", + downloadMessages ? 1 : 2 + ); + } + return downloadMessages; + }, + + /** + * Get New Mail When Offline + * Prompts the user about going online in order to download new messages. + * Based on the response, will move us back to online mode. + * + * @returns true if the user confirms going online. + */ + getNewMail() { + let goOnline = Services.prompt.confirm( + window, + this.offlineBundle.GetStringFromName("getMessagesOfflineWindowTitle1"), + this.offlineBundle.GetStringFromName("getMessagesOfflineLabel1") + ); + + if (goOnline) { + this.offlineManager.goOnline( + this.shouldSendUnsentMessages(), + false /* playbackOfflineImapOperations */, + msgWindow + ); + } + return goOnline; + }, + + /** + * Private helper method to update the state of the Offline menu item + * and the offline status bar indicator + */ + updateOfflineUI(aIsOffline) { + document + .getElementById("goOfflineMenuItem") + .setAttribute("checked", aIsOffline); + var statusBarPanel = document.getElementById("offline-status"); + if (aIsOffline) { + statusBarPanel.setAttribute("offline", "true"); + statusBarPanel.setAttribute( + "tooltiptext", + this.offlineBundle.GetStringFromName("offlineTooltip") + ); + } else { + statusBarPanel.removeAttribute("offline"); + statusBarPanel.setAttribute( + "tooltiptext", + this.offlineBundle.GetStringFromName("onlineTooltip") + ); + } + }, + + /** + * private helper method called whenever we detect a change to the offline state + */ + mailOfflineStateChanged(aGoingOffline) { + this.updateOfflineUI(aGoingOffline); + if (!aGoingOffline) { + let prefSendUnsentMessages = Services.prefs.getIntPref( + "offline.send.unsent_messages" + ); + // 0 == Ask, 1 == Always Send, 2 == Never Send + let sendUnsentMessages = + (prefSendUnsentMessages == 0 && + this.haveUnsentMessages() && + this.confirmSendUnsentMessages()) || + prefSendUnsentMessages == 1; + this.offlineManager.goOnline(sendUnsentMessages, true, msgWindow); + } + }, +}; diff --git a/comm/mail/base/content/mail3PaneWindowCommands.js b/comm/mail/base/content/mail3PaneWindowCommands.js new file mode 100644 index 0000000000..8042022dcb --- /dev/null +++ b/comm/mail/base/content/mail3PaneWindowCommands.js @@ -0,0 +1,456 @@ +/* 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/. */ + +/** + * Functionality for the main application window (aka the 3pane) usually + * consisting of folder pane, thread pane and message pane. + */ + +/* global MozElements */ + +/* import-globals-from ../../components/im/content/chat-messenger.js */ +/* import-globals-from mailCore.js */ +/* import-globals-from mailWindow.js */ // msgWindow and a loooot more +/* import-globals-from utilityOverlay.js */ + +/* globals MailOfflineMgr */ // From mail-offline.js + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" +); +ChromeUtils.defineModuleGetter( + this, + "MailUtils", + "resource:///modules/MailUtils.jsm" +); + +// DefaultController object (handles commands when one of the trees does not have focus) +var DefaultController = { + supportsCommand(command) { + switch (command) { + case "cmd_newMessage": + case "cmd_undoCloseTab": + case "cmd_undo": + case "cmd_redo": + case "cmd_sendUnsentMsgs": + case "cmd_subscribe": + case "cmd_getNewMessages": + case "cmd_getMsgsForAuthAccounts": + case "cmd_getNextNMessages": + case "cmd_settingsOffline": + case "cmd_viewAllHeader": + case "cmd_viewNormalHeader": + case "cmd_stop": + case "cmd_chat": + case "cmd_goFolder": + return true; + case "cmd_synchronizeOffline": + return MailOfflineMgr.isOnline(); + case "cmd_joinChat": + case "cmd_addChatBuddy": + case "cmd_chatStatus": + return !!chatHandler; + + default: + return false; + } + }, + + isCommandEnabled(command) { + if (document.getElementById("tabmail").globalOverlay) { + return false; + } + switch (command) { + case "cmd_newMessage": + return MailServices.accounts.allIdentities.length > 0; + case "cmd_viewAllHeader": + case "cmd_viewNormalHeader": + return true; + case "cmd_undoCloseTab": + return document.getElementById("tabmail").recentlyClosedTabs.length > 0; + case "cmd_stop": + return window.MsgStatusFeedback?._meteorsSpinning; + case "cmd_undo": + case "cmd_redo": + return SetupUndoRedoCommand(command); + case "cmd_sendUnsentMsgs": + return IsSendUnsentMsgsEnabled(null); + case "cmd_subscribe": + return IsSubscribeEnabled(); + case "cmd_getNewMessages": + case "cmd_getMsgsForAuthAccounts": + return IsGetNewMessagesEnabled(); + case "cmd_getNextNMessages": + return IsGetNextNMessagesEnabled(); + case "cmd_synchronizeOffline": + return MailOfflineMgr.isOnline(); + case "cmd_settingsOffline": + return IsAccountOfflineEnabled(); + case "cmd_goFolder": + return isFolderPaneInitialized(); + case "cmd_chat": + return true; + case "cmd_joinChat": + case "cmd_addChatBuddy": + case "cmd_chatStatus": + return !!chatHandler; + } + return false; + }, + + doCommand(command, event) { + // If the user invoked a key short cut then it is possible that we got here + // for a command which is really disabled. Kick out if the command should be disabled. + if (!this.isCommandEnabled(command)) { + return; + } + + switch (command) { + case "cmd_getNewMessages": + MsgGetMessage(); + break; + case "cmd_getMsgsForAuthAccounts": + MsgGetMessagesForAllAuthenticatedAccounts(); + break; + case "cmd_getNextNMessages": + MsgGetNextNMessages(); + break; + case "cmd_newMessage": + MsgNewMessage(event); + break; + case "cmd_undoCloseTab": + document.getElementById("tabmail").undoCloseTab(); + break; + case "cmd_undo": + messenger.undo(msgWindow); + break; + case "cmd_redo": + messenger.redo(msgWindow); + break; + case "cmd_sendUnsentMsgs": + // if offline, prompt for sendUnsentMessages + if (MailOfflineMgr.isOnline()) { + SendUnsentMessages(); + } else { + MailOfflineMgr.goOnlineToSendMessages(msgWindow); + } + return; + case "cmd_subscribe": + MsgSubscribe(); + return; + case "cmd_stop": + msgWindow.StopUrls(); + return; + case "cmd_viewAllHeader": + MsgViewAllHeaders(); + return; + case "cmd_viewNormalHeader": + MsgViewNormalHeaders(); + return; + case "cmd_synchronizeOffline": + MsgSynchronizeOffline(); + break; + case "cmd_settingsOffline": + MailOfflineMgr.openOfflineAccountSettings(); + break; + case "cmd_goFolder": + document + .getElementById("tabmail") + .currentAbout3Pane.displayFolder(event.target._folder); + break; + case "cmd_chat": + showChatTab(); + break; + } + }, + + onEvent(event) { + // on blur events set the menu item texts back to the normal values + if (event == "blur") { + goSetMenuValue("cmd_undo", "valueDefault"); + goSetMenuValue("cmd_redo", "valueDefault"); + } + }, +}; +// This is the highest priority controller. It's followed by +// tabmail.tabController and calendarController, then whatever Gecko adds. +window.controllers.insertControllerAt(0, DefaultController); + +function CloseTabOrWindow() { + let tabmail = document.getElementById("tabmail"); + if (tabmail.globalOverlay) { + return; + } + if (tabmail.tabInfo.length == 1) { + if (Services.prefs.getBoolPref("mail.tabs.closeWindowWithLastTab")) { + window.close(); + } + } else { + tabmail.removeCurrentTab(); + } +} + +function IsSendUnsentMsgsEnabled(unsentMsgsFolder) { + // If no account has been configured, there are no messages for sending. + if (MailServices.accounts.accounts.length == 0) { + return false; + } + + let msgSendlater; + try { + msgSendlater = Cc["@mozilla.org/messengercompose/sendlater;1"].getService( + Ci.nsIMsgSendLater + ); + } catch (error) {} + + // If we're currently sending unsent msgs, disable this cmd. + if (msgSendlater?.sendingMessages) { + return false; + } + + if (unsentMsgsFolder) { + // If unsentMsgsFolder is non-null, it is the "Unsent Messages" folder. + // We're here because we've done a right click on the "Unsent Messages" + // folder (context menu), so we can use the folder and return true/false + // straight away. + return unsentMsgsFolder.getTotalMessages(false) > 0; + } + + // Otherwise, we don't know where we are, so use the current identity and + // find out if we have messages or not via that. + let identity; + let folders = GetSelectedMsgFolders(); + if (folders.length > 0) { + [identity] = MailUtils.getIdentityForServer(folders[0].server); + } + + if (!identity) { + let defaultAccount = MailServices.accounts.defaultAccount; + if (defaultAccount) { + identity = defaultAccount.defaultIdentity; + } + + if (!identity) { + return false; + } + } + + let hasUnsentMessages = false; + try { + hasUnsentMessages = msgSendlater?.hasUnsentMessages(identity); + } catch (error) {} + return hasUnsentMessages; +} + +/** + * Determine whether there exists any server for which to show the Subscribe dialog. + */ +function IsSubscribeEnabled() { + // If there are any IMAP or News servers, we can show the dialog any time and + // it will properly show those. + for (let server of MailServices.accounts.allServers) { + if (server.type == "imap" || server.type == "nntp") { + return true; + } + } + + // RSS accounts use a separate Subscribe dialog that we can only show when + // such an account is selected. + let preselectedFolder = GetFirstSelectedMsgFolder(); + if (preselectedFolder && preselectedFolder.server.type == "rss") { + return true; + } + + return false; +} + +/** + * Cycle through the various panes in the 3pane window. + * + * @param {Event} event - The keypress DOMEvent. + */ +function SwitchPaneFocus(event) { + let tabmail = document.getElementById("tabmail"); + // Should not move the focus around when the entire window is covered with + // something else. + if (tabmail.globalOverlay) { + return; + } + // First, build an array of panes to cycle through based on our current state. + // This will usually be something like [folderTree, threadTree, messageBrowser]. + let panes = []; + // The logically focused element. If the actually focused element is not one + // of the panes, the code below can change this variable to point to one of + // the panes. + let focusedElement = document.activeElement; + // If the actually focused element is between two of the panes, set this to + // -1, 0, or 1 (depending on the direction and where the focus is relative to + // `focusedElement`) so that the element to focus is correctly chosen. + let adjustment = 0; + + let spacesElement = !gSpacesToolbar.isHidden + ? gSpacesToolbar.focusButton + : document.getElementById("spacesPinnedButton"); + panes.push(spacesElement); + + let toolbar = document.getElementById("unifiedToolbar"); + if (!toolbar.hidden) { + // Prioritise the search bar, otherwise use the first available button. + let toolbarElement = + toolbar.querySelector("global-search-bar") || + toolbar.querySelector("li:not([hidden]) button, #button-appmenu"); + if (toolbarElement) { + panes.push(toolbarElement); + if (toolbar.matches(":focus-within") && focusedElement != spacesElement) { + focusedElement = toolbarElement; + } + } + } + + let { currentTabInfo } = tabmail; + switch (currentTabInfo.mode.name) { + case "mail3PaneTab": { + let { contentWindow, contentDocument } = currentTabInfo.chromeBrowser; + let { + paneLayout, + folderTree, + threadTree, + webBrowser, + messageBrowser, + multiMessageBrowser, + accountCentralBrowser, + } = contentWindow; + + if (paneLayout.folderPaneVisible) { + panes.push(folderTree); + } + + if (accountCentralBrowser.hidden) { + panes.push(threadTree.table.body); + } else { + panes.push(accountCentralBrowser); + } + + if (paneLayout.messagePaneVisible) { + if (!webBrowser.hidden) { + panes.push(webBrowser); + } else if (!messageBrowser.hidden) { + panes.push(messageBrowser.contentWindow.getMessagePaneBrowser()); + } else if (!multiMessageBrowser.hidden) { + panes.push(multiMessageBrowser); + } + } + + if (focusedElement == currentTabInfo.chromeBrowser) { + focusedElement = contentDocument.activeElement; + if ( + focusedElement != folderTree && + contentDocument.getElementById("folderPane").contains(focusedElement) + ) { + focusedElement = folderTree; + adjustment = event.shiftKey ? 0 : -1; + } else if ( + contentDocument + .getElementById("threadPaneNotificationBox") + .contains(focusedElement) + ) { + focusedElement = threadTree.table.body; + adjustment = event.shiftKey ? 1 : 0; + } else if ( + focusedElement != threadTree.table.body && + contentDocument.getElementById("threadPane").contains(focusedElement) + ) { + focusedElement = threadTree.table.body; + adjustment = event.shiftKey ? 0 : -1; + } else if (focusedElement == messageBrowser) { + focusedElement = messageBrowser.contentWindow.getMessagePaneBrowser(); + } + } + break; + } + case "mailMessageTab": { + let { content } = currentTabInfo.chromeBrowser.contentWindow; + panes.push(content); + if (focusedElement == currentTabInfo.chromeBrowser) { + focusedElement = content; + } + break; + } + case "addressBookTab": { + let { booksList, cardsPane, detailsPane } = + currentTabInfo.browser.contentWindow; + + if (detailsPane.isEditing) { + panes.push(currentTabInfo.browser); + } else { + let targets = [ + booksList, + cardsPane.searchInput, + cardsPane.cardsList.table.body, + ]; + if (!detailsPane.node.hidden && !detailsPane.editButton.hidden) { + targets.push(detailsPane.editButton); + } + + if (focusedElement == currentTabInfo.browser) { + focusedElement = targets.find(t => t.matches(":focus-within")); + } + panes.push(...targets); + } + break; + } + default: + if (currentTabInfo.browser) { + panes.push(currentTabInfo.browser); + } + break; + } + + // Find our focused element in the array. + let focusedElementIndex = panes.indexOf(focusedElement) + adjustment; + if (event.shiftKey) { + focusedElementIndex--; + if (focusedElementIndex < 0) { + focusedElementIndex = panes.length - 1; + } + } else if (focusedElementIndex == -1) { + focusedElementIndex = 0; + } else { + focusedElementIndex++; + if (focusedElementIndex == panes.length) { + focusedElementIndex = 0; + } + } + + if (panes[focusedElementIndex]) { + panes[focusedElementIndex].focus(); + } +} + +// Override F6 handling for remote browsers, and use our own logic to +// determine the element to focus. +addEventListener( + "keypress", + function (event) { + if (event.key == "F6" && Services.focus.focusedElement?.isRemoteBrowser) { + event.preventDefault(); + SwitchPaneFocus(event); + } + }, + true +); + +/** + * Check the status of the folder pane, if available. + * + * @returns {boolean|undefined} The initialization state of the folder pane, + * or undefined if we can't access the document. + */ +function isFolderPaneInitialized() { + return document.getElementById("tabmail")?.currentAbout3Pane?.folderPane + .isInitialized; +} diff --git a/comm/mail/base/content/mailCommands.js b/comm/mail/base/content/mailCommands.js new file mode 100644 index 0000000000..9c974202e5 --- /dev/null +++ b/comm/mail/base/content/mailCommands.js @@ -0,0 +1,667 @@ +/* -*- Mode: Javascript; 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/. */ + +/* import-globals-from utilityOverlay.js */ + +/* globals msgWindow, messenger */ // From mailWindow.js +/* globals openComposeWindowForRSSArticle */ // From newsblogOverlay.js + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FeedUtils", + "resource:///modules/FeedUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "MailUtils", + "resource:///modules/MailUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "MsgHdrToMimeMessage", + "resource:///modules/gloda/MimeMessage.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "EnigmailMime", + "chrome://openpgp/content/modules/mime.jsm" +); + +function GetNextNMessages(folder) { + if (folder) { + var newsFolder = folder.QueryInterface(Ci.nsIMsgNewsFolder); + if (newsFolder) { + newsFolder.getNextNMessages(msgWindow); + } + } +} + +/** + * Figure out the message key from the message uri. + * + * @param uri string defining internal storage + */ +function GetMsgKeyFromURI(uri) { + // Format of 'uri' : protocol://email/folder#key?params + // '?params' are optional + // ex : mailbox-message://john%2Edoe@pop.isp.invalid/Drafts#12345 + // We keep only the part after '#' and before an optional '?'. + // The regexp expects 'key' to be an integer (a series of digits) : '\d+'. + let match = /.+#(\d+)/.exec(uri); + return match ? match[1] : null; +} + +/* eslint-disable complexity */ +/** + * Compose a message. + * + * @param {nsIMsgCompType} type - Type of composition (new message, reply, draft, etc.) + * @param {nsIMsgCompFormat} format - Requested format (plain text, html, default) + * @param {nsIMsgFolder} folder - Folder where the original message is stored + * @param {string[]} messageArray - Array of message URIs to process, often only + * holding one element. + * @param {Selection} [selection=null] - A DOM selection to be quoted, or null + * to quote the whole message, if quoting is appropriate (e.g. in a reply). + * @param {boolean} [autodetectCharset=false] - If quoting the whole message, + * whether automatic character set detection should be used. + */ +async function ComposeMessage( + type, + format, + folder, + messageArray, + selection = null, + autodetectCharset = false +) { + let aboutMessage = + document.getElementById("tabmail")?.currentAboutMessage || + document.getElementById("messageBrowser")?.contentWindow; + let currentHeaderData = aboutMessage?.currentHeaderData; + + function isCurrentlyDisplayed(hdr) { + return ( + currentHeaderData && // ignoring enclosing brackets: + currentHeaderData["message-id"]?.headerValue.includes(hdr.messageId) + ); + } + + function findDeliveredToIdentityEmail(hdr) { + // This function reads from currentHeaderData, which is only useful if we're + // looking at the currently-displayed message. Otherwise, just return + // immediately so we don't waste time. + if (!isCurrentlyDisplayed(hdr)) { + return ""; + } + + // Get the delivered-to headers. + let key = "delivered-to"; + let deliveredTos = []; + let index = 0; + let header = ""; + while ((header = currentHeaderData[key])) { + deliveredTos.push(header.headerValue.toLowerCase().trim()); + key = "delivered-to" + index++; + } + + // Reverse the array so that the last delivered-to header will show at front. + deliveredTos.reverse(); + + for (let i = 0; i < deliveredTos.length; i++) { + for (let identity of MailServices.accounts.allIdentities) { + if (!identity.email) { + continue; + } + // If the deliver-to header contains the defined identity, that's it. + if ( + deliveredTos[i] == identity.email.toLowerCase() || + deliveredTos[i].includes("<" + identity.email.toLowerCase() + ">") + ) { + return identity.email; + } + } + } + return ""; + } + + let msgKey; + if (messageArray && messageArray.length == 1) { + msgKey = GetMsgKeyFromURI(messageArray[0]); + } + + // Check if the draft is already open in another window. If it is, just focus the window. + if (type == Ci.nsIMsgCompType.Draft && messageArray.length == 1) { + // We'll search this uri in the opened windows. + for (let win of Services.wm.getEnumerator("")) { + // Check if it is a compose window. + if ( + win.document.defaultView.gMsgCompose && + win.document.defaultView.gMsgCompose.compFields.draftId + ) { + let wKey = GetMsgKeyFromURI( + win.document.defaultView.gMsgCompose.compFields.draftId + ); + if (wKey == msgKey) { + // Found ! just focus it... + win.focus(); + // ...and nothing to do anymore. + return; + } + } + } + } + var identity = null; + var newsgroup = null; + var hdr; + + // dump("ComposeMessage folder=" + folder + "\n"); + try { + if (folder) { + // Get the incoming server associated with this uri. + var server = folder.server; + + // If they hit new or reply and they are reading a newsgroup, + // turn this into a new post or a reply to group. + if ( + !folder.isServer && + server.type == "nntp" && + type == Ci.nsIMsgCompType.New + ) { + type = Ci.nsIMsgCompType.NewsPost; + newsgroup = folder.folderURL; + } + + identity = folder.customIdentity; + if (!identity) { + [identity] = MailUtils.getIdentityForServer(server); + } + // dump("identity = " + identity + "\n"); + } + } catch (ex) { + dump("failed to get an identity to pre-select: " + ex + "\n"); + } + + // dump("\nComposeMessage from XUL: " + identity + "\n"); + + switch (type) { + case Ci.nsIMsgCompType.New: // new message + // dump("OpenComposeWindow with " + identity + "\n"); + + MailServices.compose.OpenComposeWindow( + null, + null, + null, + type, + format, + identity, + null, + msgWindow + ); + return; + case Ci.nsIMsgCompType.NewsPost: + // dump("OpenComposeWindow with " + identity + " and " + newsgroup + "\n"); + MailServices.compose.OpenComposeWindow( + null, + null, + newsgroup, + type, + format, + identity, + null, + msgWindow + ); + return; + case Ci.nsIMsgCompType.ForwardAsAttachment: + if (messageArray && messageArray.length) { + // If we have more than one ForwardAsAttachment then pass null instead + // of the header to tell the compose service to work out the attachment + // subjects from the URIs. + hdr = + messageArray.length > 1 + ? null + : messenger.msgHdrFromURI(messageArray[0]); + MailServices.compose.OpenComposeWindow( + null, + hdr, + messageArray.join(","), + type, + format, + identity, + null, + msgWindow + ); + } + return; + default: + if (!messageArray) { + return; + } + + // Limit the number of new compose windows to 8. Why 8 ? + // I like that number :-) + if (messageArray.length > 8) { + messageArray.length = 8; + } + + for (var i = 0; i < messageArray.length; ++i) { + var messageUri = messageArray[i]; + hdr = messenger.msgHdrFromURI(messageUri); + + if ( + [ + Ci.nsIMsgCompType.Reply, + Ci.nsIMsgCompType.ReplyAll, + Ci.nsIMsgCompType.ReplyToSender, + // Author's address doesn't matter for followup to a newsgroup. + // Ci.nsIMsgCompType.ReplyToGroup, + Ci.nsIMsgCompType.ReplyToSenderAndGroup, + Ci.nsIMsgCompType.ReplyWithTemplate, + Ci.nsIMsgCompType.ReplyToList, + ].includes(type) + ) { + let replyTo = hdr.getStringProperty("replyTo"); + let from = replyTo || hdr.author; + let fromAddrs = MailServices.headerParser.parseEncodedHeader( + from, + null + ); + let email = fromAddrs[0]?.email; + if ( + type == Ci.nsIMsgCompType.ReplyToList && + isCurrentlyDisplayed(hdr) + ) { + // ReplyToList is only enabled for current message (if at all), so + // using currentHeaderData is ok. + // List-Post value is of the format <mailto:list@example.com> + let listPost = currentHeaderData["list-post"]?.headerValue; + if (listPost) { + email = listPost.replace(/.*<mailto:(.+)>.*/, "$1"); + } + } + + if ( + /^(.*[._-])?(do[._-]?not|no)[._-]?reply([._-].*)?@/i.test(email) + ) { + let [title, message, replyAnywayButton] = + await document.l10n.formatValues([ + { id: "no-reply-title" }, + { id: "no-reply-message", args: { email } }, + { id: "no-reply-reply-anyway-button" }, + ]); + + let buttonFlags = + Ci.nsIPrompt.BUTTON_TITLE_IS_STRING * Ci.nsIPrompt.BUTTON_POS_0 + + Ci.nsIPrompt.BUTTON_TITLE_CANCEL * Ci.nsIPrompt.BUTTON_POS_1 + + Ci.nsIPrompt.BUTTON_POS_1_DEFAULT; + + if ( + Services.prompt.confirmEx( + window, + title, + message, + buttonFlags, + replyAnywayButton, + null, // cancel + null, + null, + {} + ) + ) { + continue; + } + } + } + + if (FeedUtils.isFeedMessage(hdr)) { + // Do not use the header derived identity for feeds, pass on only a + // possible server identity from above. + openComposeWindowForRSSArticle( + null, + hdr, + messageUri, + type, + format, + identity, + msgWindow + ); + } else { + // Replies come here. + + let useCatchAll = false; + // Check if we are using catchAll on any identity. If current + // folder has some customIdentity set, ignore catchAll settings. + // CatchAll is not applicable to news (and doesn't work, bug 545365). + if ( + hdr.folder && + hdr.folder.server.type != "nntp" && + !hdr.folder.customIdentity + ) { + useCatchAll = MailServices.accounts.allIdentities.some( + identity => identity.catchAll + ); + } + + if (useCatchAll) { + // If we use catchAll, we need to get all headers. + // MsgHdr retrieval is asynchronous, do everything in the callback. + MsgHdrToMimeMessage( + hdr, + null, + function (hdr, mimeMsg) { + let catchAllHeaders = Services.prefs + .getStringPref("mail.compose.catchAllHeaders") + .split(",") + .map(header => header.toLowerCase().trim()); + // Collect catchAll hints from given headers. + let collectedHeaderAddresses = ""; + for (let header of catchAllHeaders) { + if (mimeMsg.has(header)) { + for (let mimeMsgHeader of mimeMsg.headers[header]) { + collectedHeaderAddresses += + MailServices.headerParser + .parseEncodedHeaderW(mimeMsgHeader) + .toString() + ","; + } + } + } + + let [identity, matchingHint] = MailUtils.getIdentityForHeader( + hdr, + type, + collectedHeaderAddresses + ); + + // The found identity might have no catchAll enabled. + if (identity.catchAll && matchingHint) { + // If name is not set in matchingHint, search trough other hints. + if (matchingHint.email && !matchingHint.name) { + let hints = + MailServices.headerParser.makeFromDisplayAddress( + hdr.recipients + + "," + + hdr.ccList + + "," + + collectedHeaderAddresses + ); + for (let hint of hints) { + if ( + hint.name && + hint.email.toLowerCase() == + matchingHint.email.toLowerCase() + ) { + matchingHint = + MailServices.headerParser.makeMailboxObject( + hint.name, + matchingHint.email + ); + break; + } + } + } + } else { + matchingHint = MailServices.headerParser.makeMailboxObject( + "", + "" + ); + } + + // Now open compose window and use matching hint as reply sender. + MailServices.compose.OpenComposeWindow( + null, + hdr, + messageUri, + type, + format, + identity, + matchingHint.toString(), + msgWindow, + selection, + autodetectCharset + ); + }, + true, + { saneBodySize: true } + ); + } else { + // Fall back to traditional behavior. + let [hdrIdentity] = MailUtils.getIdentityForHeader( + hdr, + type, + findDeliveredToIdentityEmail(hdr) + ); + MailServices.compose.OpenComposeWindow( + null, + hdr, + messageUri, + type, + format, + hdrIdentity, + null, + msgWindow, + selection, + autodetectCharset + ); + } + } + } + } +} +/* eslint-enable complexity */ + +function Subscribe(preselectedMsgFolder) { + window.openDialog( + "chrome://messenger/content/subscribe.xhtml", + "subscribe", + "chrome,modal,titlebar,resizable=yes", + { + folder: preselectedMsgFolder, + okCallback: SubscribeOKCallback, + } + ); +} + +function SubscribeOKCallback(changeTable) { + for (var serverURI in changeTable) { + var folder = MailUtils.getExistingFolder(serverURI); + var server = folder.server; + var subscribableServer = server.QueryInterface(Ci.nsISubscribableServer); + + for (var name in changeTable[serverURI]) { + if (changeTable[serverURI][name]) { + try { + subscribableServer.subscribe(name); + } catch (ex) { + dump("failed to subscribe to " + name + ": " + ex + "\n"); + } + } else if (!changeTable[serverURI][name]) { + try { + subscribableServer.unsubscribe(name); + } catch (ex) { + dump("failed to unsubscribe to " + name + ": " + ex + "\n"); + } + } + } + + try { + subscribableServer.commitSubscribeChanges(); + } catch (ex) { + dump("failed to commit the changes: " + ex + "\n"); + } + } +} + +function SaveAsFile(uris) { + let filenames = []; + + for (let uri of uris) { + let msgHdr = + MailServices.messageServiceFromURI(uri).messageURIToMsgHdr(uri); + let nameBase = GenerateFilenameFromMsgHdr(msgHdr); + let name = GenerateValidFilename(nameBase, ".eml"); + + let number = 2; + while (filenames.includes(name)) { + // should be unlikely + name = GenerateValidFilename(nameBase + "-" + number, ".eml"); + number++; + } + filenames.push(name); + } + + if (uris.length == 1) { + messenger.saveAs(uris[0], true, null, filenames[0]); + } else { + messenger.saveMessages(filenames, uris); + } +} + +function GenerateFilenameFromMsgHdr(msgHdr) { + function MakeIS8601ODateString(date) { + function pad(n) { + return n < 10 ? "0" + n : n; + } + return ( + date.getFullYear() + + "-" + + pad(date.getMonth() + 1) + + "-" + + pad(date.getDate()) + + " " + + pad(date.getHours()) + + "" + + pad(date.getMinutes()) + + "" + ); + } + + let filename; + if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) { + filename = msgHdr.mime2DecodedSubject + ? "Re: " + msgHdr.mime2DecodedSubject + : "Re: "; + } else { + filename = msgHdr.mime2DecodedSubject; + } + + filename += " - "; + filename += msgHdr.mime2DecodedAuthor + " - "; + filename += MakeIS8601ODateString(new Date(msgHdr.date / 1000)); + + return filename; +} + +function saveAsUrlListener(aUri, aIdentity) { + this.uri = aUri; + this.identity = aIdentity; +} + +saveAsUrlListener.prototype = { + OnStartRunningUrl(aUrl) {}, + OnStopRunningUrl(aUrl, aExitCode) { + messenger.saveAs(this.uri, false, this.identity, null); + }, +}; + +function SaveAsTemplate(uri) { + if (uri) { + let hdr = messenger.msgHdrFromURI(uri); + let [identity] = MailUtils.getIdentityForHeader( + hdr, + Ci.nsIMsgCompType.Template + ); + let templates = MailUtils.getOrCreateFolder(identity.stationeryFolder); + if (!templates.parent) { + templates.setFlag(Ci.nsMsgFolderFlags.Templates); + let isAsync = templates.server.protocolInfo.foldersCreatedAsync; + templates.createStorageIfMissing(new saveAsUrlListener(uri, identity)); + if (isAsync) { + return; + } + } + messenger.saveAs(uri, false, identity, null); + } +} + +function viewEncryptedPart(message) { + let url; + try { + url = MailServices.mailSession.ConvertMsgURIToMsgURL(message, msgWindow); + } catch (e) { + console.debug(e); + // Couldn't get mail session + return false; + } + + // Strip out the message-display parameter to ensure that attached emails + // display the message source, not the processed HTML. + url = url.replace(/type=application\/x-message-display&/, ""); + + /** + * Save the given string to a file, then open it as an .eml file. + * + * @param {string} data - The message data. + */ + let msgOpenMessageFromString = function (data) { + let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempFile.append("subPart.eml"); + tempFile.createUnique(0, 0o600); + + let outputStream = Cc[ + "@mozilla.org/network/file-output-stream;1" + ].createInstance(Ci.nsIFileOutputStream); + outputStream.init(tempFile, 2, 0x200, false); // open as "write only" + outputStream.write(data, data.length); + outputStream.close(); + + // Delete file on exit, because Windows locks the file + let extAppLauncher = Cc[ + "@mozilla.org/uriloader/external-helper-app-service;1" + ].getService(Ci.nsPIExternalAppLauncher); + extAppLauncher.deleteTemporaryFileOnExit(tempFile); + + let url = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler) + .newFileURI(tempFile); + + MailUtils.openEMLFile(window, tempFile, url); + }; + + function recursiveEmitEncryptedParts(mimeTree) { + for (let part of mimeTree.subParts) { + const ct = part.headers.contentType.type; + if (ct == "multipart/encrypted") { + const boundary = part.headers.contentType.get("boundary"); + let full = `${part.headers.rawHeaderText}\n\n`; + for (let subPart of part.subParts) { + full += `${boundary}\n${subPart.headers.rawHeaderText}\n\n${subPart.body}\n`; + } + full += `${boundary}--\n`; + msgOpenMessageFromString(full); + continue; + } + recursiveEmitEncryptedParts(part); + } + } + + EnigmailMime.getMimeTreeFromUrl(url, true, recursiveEmitEncryptedParts); + return true; +} + +function viewEncryptedParts(messages) { + if (!messages?.length) { + dump("viewEncryptedParts(): No messages selected.\n"); + return false; + } + + if (messages.length > 1) { + dump("viewEncryptedParts(): Too many messages selected.\n"); + return false; + } + + return viewEncryptedPart(messages[0]); +} diff --git a/comm/mail/base/content/mailCommon.js b/comm/mail/base/content/mailCommon.js new file mode 100644 index 0000000000..b74a3d8fba --- /dev/null +++ b/comm/mail/base/content/mailCommon.js @@ -0,0 +1,1126 @@ +/* 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/. */ + +// mailContext.js +/* globals mailContextMenu */ + +// msgViewNavigation.js +/* globals CrossFolderNavigation */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + TreeSelection: "chrome://messenger/content/tree-selection.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + ConversationOpener: "resource:///modules/ConversationOpener.jsm", + DBViewWrapper: "resource:///modules/DBViewWrapper.jsm", + EnigmailPersistentCrypto: + "chrome://openpgp/content/modules/persistentCrypto.jsm", + EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + MessageArchiver: "resource:///modules/MessageArchiver.jsm", + VirtualFolderHelper: "resource:///modules/VirtualFolderWrapper.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gEncryptedURIService", + "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1", + "nsIEncryptedSMIMEURIsService" +); + +const nsMsgViewIndex_None = 0xffffffff; +const nsMsgKey_None = 0xffffffff; + +var gDBView, gFolder, gViewWrapper; + +var commandController = { + _composeCommands: { + cmd_editDraftMsg: Ci.nsIMsgCompType.Draft, + cmd_newMsgFromTemplate: Ci.nsIMsgCompType.Template, + cmd_editTemplateMsg: Ci.nsIMsgCompType.EditTemplate, + cmd_newMessage: Ci.nsIMsgCompType.New, + cmd_replyGroup: Ci.nsIMsgCompType.ReplyToGroup, + cmd_replySender: Ci.nsIMsgCompType.ReplyToSender, + cmd_replyall: Ci.nsIMsgCompType.ReplyAll, + cmd_replylist: Ci.nsIMsgCompType.ReplyToList, + cmd_forwardInline: Ci.nsIMsgCompType.ForwardInline, + cmd_forwardAttachment: Ci.nsIMsgCompType.ForwardAsAttachment, + cmd_redirect: Ci.nsIMsgCompType.Redirect, + cmd_editAsNew: Ci.nsIMsgCompType.EditAsNew, + }, + _navigationCommands: { + cmd_goForward: Ci.nsMsgNavigationType.forward, + cmd_goBack: Ci.nsMsgNavigationType.back, + cmd_nextUnreadMsg: Ci.nsMsgNavigationType.nextUnreadMessage, + cmd_nextUnreadThread: Ci.nsMsgNavigationType.nextUnreadThread, + cmd_nextMsg: Ci.nsMsgNavigationType.nextMessage, + cmd_nextFlaggedMsg: Ci.nsMsgNavigationType.nextFlagged, + cmd_previousMsg: Ci.nsMsgNavigationType.previousMessage, + cmd_previousUnreadMsg: Ci.nsMsgNavigationType.previousUnreadMessage, + cmd_previousFlaggedMsg: Ci.nsMsgNavigationType.previousFlagged, + }, + _viewCommands: { + cmd_toggleRead: Ci.nsMsgViewCommandType.toggleMessageRead, + cmd_markAsRead: Ci.nsMsgViewCommandType.markMessagesRead, + cmd_markAsUnread: Ci.nsMsgViewCommandType.markMessagesUnread, + cmd_markThreadAsRead: Ci.nsMsgViewCommandType.markThreadRead, + cmd_markAsNotJunk: Ci.nsMsgViewCommandType.unjunk, + cmd_watchThread: Ci.nsMsgViewCommandType.toggleThreadWatched, + }, + _callbackCommands: { + cmd_cancel() { + gFolder + .QueryInterface(Ci.nsIMsgNewsFolder) + .cancelMessage(gDBView.hdrForFirstSelectedMessage, top.msgWindow); + }, + cmd_openConversation() { + new ConversationOpener(window).openConversationForMessages( + gDBView.getSelectedMsgHdrs() + ); + }, + cmd_reply(event) { + if (gFolder?.flags & Ci.nsMsgFolderFlags.Newsgroup) { + commandController.doCommand("cmd_replyGroup", event); + } else { + commandController.doCommand("cmd_replySender", event); + } + }, + cmd_forward(event) { + if (Services.prefs.getIntPref("mail.forward_message_mode", 0) == 0) { + commandController.doCommand("cmd_forwardAttachment", event); + } else { + commandController.doCommand("cmd_forwardInline", event); + } + }, + cmd_openMessage(event) { + MailUtils.displayMessages( + gDBView.getSelectedMsgHdrs(), + gViewWrapper, + top.document.getElementById("tabmail"), + event?.type == "auxclick" && !event?.shiftKey + ); + }, + cmd_tag() { + // Does nothing, just here to enable/disable the tags sub-menu. + }, + cmd_tag1: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 1), + cmd_tag2: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 2), + cmd_tag3: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 3), + cmd_tag4: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 4), + cmd_tag5: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 5), + cmd_tag6: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 6), + cmd_tag7: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 7), + cmd_tag8: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 8), + cmd_tag9: mailContextMenu._toggleMessageTagKey.bind(mailContextMenu, 9), + cmd_addTag() { + mailContextMenu.addTag(); + }, + cmd_manageTags() { + window.browsingContext.topChromeWindow.openOptionsDialog( + "paneGeneral", + "tagsCategory" + ); + }, + cmd_removeTags() { + mailContextMenu.removeAllMessageTags(); + }, + cmd_toggleTag(event) { + mailContextMenu._toggleMessageTag( + event.target.value, + event.target.getAttribute("checked") == "true" + ); + }, + cmd_markReadByDate() { + window.browsingContext.topChromeWindow.openDialog( + "chrome://messenger/content/markByDate.xhtml", + "", + "chrome,modal,titlebar,centerscreen", + gFolder + ); + }, + cmd_markAsFlagged() { + gViewWrapper.dbView.doCommand( + gDBView.hdrForFirstSelectedMessage.isFlagged + ? Ci.nsMsgViewCommandType.unflagMessages + : Ci.nsMsgViewCommandType.flagMessages + ); + }, + cmd_markAsJunk() { + if ( + Services.prefs.getBoolPref("mailnews.ui.junk.manualMarkAsJunkMarksRead") + ) { + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.markMessagesRead); + } + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.junk); + }, + cmd_markAllRead() { + if (gFolder.flags & Ci.nsMsgFolderFlags.Virtual) { + top.MsgMarkAllRead( + VirtualFolderHelper.wrapVirtualFolder(gFolder).searchFolders + ); + } else { + top.MsgMarkAllRead([gFolder]); + } + }, + /** + * Moves the selected messages to the destination folder. + * + * @param {nsIMsgFolder} destFolder - the destination folder + */ + cmd_moveMessage(destFolder) { + if (parent.location.href == "about:3pane") { + // If we're in about:message inside about:3pane, it's the parent + // window that needs to advance to the next message. + parent.commandController.doCommand("cmd_moveMessage", destFolder); + return; + } + dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete(); + gViewWrapper.dbView.doCommandWithFolder( + Ci.nsMsgViewCommandType.moveMessages, + destFolder + ); + Services.prefs.setStringPref( + "mail.last_msg_movecopy_target_uri", + destFolder.URI + ); + Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", true); + }, + async cmd_copyDecryptedTo(destFolder) { + let msgHdrs = gDBView.getSelectedMsgHdrs(); + if (!msgHdrs || msgHdrs.length === 0) { + return; + } + + let total = msgHdrs.length; + let failures = 0; + for (let msgHdr of msgHdrs) { + await EnigmailPersistentCrypto.cryptMessage( + msgHdr, + destFolder.URI, + false, // not moving + false + ).catch(err => { + failures++; + }); + } + + if (failures) { + let info = await document.l10n.formatValue( + "decrypt-and-copy-failures-multiple", + { + failures, + total, + } + ); + Services.prompt.alert(null, document.title, info); + } + }, + /** + * Copies the selected messages to the destination folder. + * + * @param {nsIMsgFolder} destFolder - the destination folder + */ + cmd_copyMessage(destFolder) { + if (window.gMessageURI?.startsWith("file:")) { + let file = Services.io + .newURI(window.gMessageURI) + .QueryInterface(Ci.nsIFileURL).file; + MailServices.copy.copyFileMessage( + file, + destFolder, + null, + false, + Ci.nsMsgMessageFlags.Read, + "", + null, + top.msgWindow + ); + } else { + gViewWrapper.dbView.doCommandWithFolder( + Ci.nsMsgViewCommandType.copyMessages, + destFolder + ); + } + Services.prefs.setStringPref( + "mail.last_msg_movecopy_target_uri", + destFolder.URI + ); + Services.prefs.setBoolPref("mail.last_msg_movecopy_was_move", false); + }, + cmd_archive() { + if (parent.location.href == "about:3pane") { + // If we're in about:message inside about:3pane, it's the parent + // window that needs to advance to the next message. + parent.commandController.doCommand("cmd_archive"); + return; + } + dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete(); + let archiver = new MessageArchiver(); + // The instance of nsITransactionManager to use here is tied to msgWindow. Set + // this property so the operation can be undone if requested. + archiver.msgWindow = top.msgWindow; + // Archive the selected message(s). + archiver.archiveMessages(gViewWrapper.dbView.getSelectedMsgHdrs()); + }, + cmd_moveToFolderAgain() { + if (parent.location.href == "about:3pane") { + // If we're in about:message inside about:3pane, it's the parent + // window that needs to advance to the next message. + parent.commandController.doCommand("cmd_moveToFolderAgain"); + return; + } + let folder = MailUtils.getOrCreateFolder( + Services.prefs.getStringPref("mail.last_msg_movecopy_target_uri") + ); + if (Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move")) { + dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete(); + commandController.doCommand("cmd_moveMessage", folder); + } else { + commandController.doCommand("cmd_copyMessage", folder); + } + }, + cmd_deleteMessage() { + if (parent.location.href == "about:3pane") { + // If we're in about:message inside about:3pane, it's the parent + // window that needs to advance to the next message. + parent.commandController.doCommand("cmd_deleteMessage"); + return; + } + dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.deleteMsg); + }, + cmd_shiftDeleteMessage() { + if (parent.location.href == "about:3pane") { + // If we're in about:message inside about:3pane, it's the parent + // window that needs to advance to the next message. + parent.commandController.doCommand("cmd_shiftDeleteMessage"); + return; + } + dbViewWrapperListener.threadPaneCommandUpdater.updateNextMessageAfterDelete(); + gViewWrapper.dbView.doCommand(Ci.nsMsgViewCommandType.deleteNoTrash); + }, + cmd_createFilterFromMenu() { + let msgHdr = gDBView.hdrForFirstSelectedMessage; + let emailAddress = + MailServices.headerParser.extractHeaderAddressMailboxes(msgHdr.author); + if (emailAddress) { + top.MsgFilters(emailAddress, msgHdr.folder); + } + }, + cmd_viewPageSource() { + let uris = window.gMessageURI + ? [window.gMessageURI] + : gDBView.getURIsForSelection(); + for (let uri of uris) { + // Now, we need to get a URL from a URI + let url = MailServices.mailSession.ConvertMsgURIToMsgURL( + uri, + top.msgWindow + ); + + // Strip out the message-display parameter to ensure that attached emails + // display the message source, not the processed HTML. + url = url.replace(/type=application\/x-message-display&/, ""); + window.openDialog( + "chrome://messenger/content/viewSource.xhtml", + "_blank", + "all,dialog=no", + { URL: url } + ); + } + }, + cmd_saveAsFile() { + let uris = window.gMessageURI + ? [window.gMessageURI] + : gDBView.getURIsForSelection(); + top.SaveAsFile(uris); + }, + cmd_saveAsTemplate() { + top.SaveAsTemplate(gDBView.getURIsForSelection()[0]); + }, + cmd_applyFilters() { + let curFilterList = gFolder.getFilterList(top.msgWindow); + // Create a new filter list and copy over the enabled filters to it. + // We do this instead of having the filter after the fact code ignore + // disabled filters because the Filter Dialog filter after the fact + // code would have to clone filters to allow disabled filters to run, + // and we don't support cloning filters currently. + let tempFilterList = MailServices.filters.getTempFilterList(gFolder); + let numFilters = curFilterList.filterCount; + // Make sure the temp filter list uses the same log stream. + tempFilterList.loggingEnabled = curFilterList.loggingEnabled; + tempFilterList.logStream = curFilterList.logStream; + let newFilterIndex = 0; + for (let i = 0; i < numFilters; i++) { + let curFilter = curFilterList.getFilterAt(i); + // Only add enabled, UI visible filters that are in the manual context. + if ( + curFilter.enabled && + !curFilter.temporary && + curFilter.filterType & Ci.nsMsgFilterType.Manual + ) { + tempFilterList.insertFilterAt(newFilterIndex, curFilter); + newFilterIndex++; + } + } + MailServices.filters.applyFiltersToFolders( + tempFilterList, + [gFolder], + top.msgWindow + ); + }, + cmd_applyFiltersToSelection() { + let selectedMessages = gDBView.getSelectedMsgHdrs(); + if (selectedMessages.length) { + MailServices.filters.applyFilters( + Ci.nsMsgFilterType.Manual, + selectedMessages, + gFolder, + top.msgWindow + ); + } + }, + cmd_space(event) { + let messagePaneBrowser; + if (window.messageBrowser) { + messagePaneBrowser = + window.messageBrowser.contentWindow.getMessagePaneBrowser(); + } else { + messagePaneBrowser = window.getMessagePaneBrowser(); + } + let contentWindow = messagePaneBrowser.contentWindow; + + if (event?.shiftKey) { + // If at the start of the message, go to the previous one. + if (contentWindow?.scrollY > 0) { + contentWindow.scrollByPages(-1); + } else if (Services.prefs.getBoolPref("mail.advance_on_spacebar")) { + top.goDoCommand("cmd_previousUnreadMsg"); + } + } else if ( + Math.ceil(contentWindow?.scrollY) < contentWindow?.scrollMaxY + ) { + // If at the end of the message, go to the next one. + contentWindow.scrollByPages(1); + } else if (Services.prefs.getBoolPref("mail.advance_on_spacebar")) { + top.goDoCommand("cmd_nextUnreadMsg"); + } + }, + cmd_searchMessages(folder = gFolder) { + // We always open a new search dialog for each search command. + top.openDialog( + "chrome://messenger/content/SearchDialog.xhtml", + "_blank", + "chrome,resizable,status,centerscreen,dialog=no", + { folder } + ); + }, + }, + _isCallbackEnabled: {}, + + registerCallback(name, callback, isEnabled = true) { + this._callbackCommands[name] = callback; + this._isCallbackEnabled[name] = isEnabled; + }, + + supportsCommand(command) { + return ( + command in this._composeCommands || + command in this._navigationCommands || + command in this._viewCommands || + command in this._callbackCommands + ); + }, + // eslint-disable-next-line complexity + isCommandEnabled(command) { + let type = typeof this._isCallbackEnabled[command]; + if (type == "function") { + return this._isCallbackEnabled[command](); + } else if (type == "boolean") { + return this._isCallbackEnabled[command]; + } + + const hasIdentities = MailServices.accounts.allIdentities.length; + switch (command) { + case "cmd_newMessage": + return hasIdentities; + case "cmd_searchMessages": + // TODO: This shouldn't be here, or should return false if there are no accounts. + return true; + case "cmd_space": + case "cmd_manageTags": + return true; + } + + if (!gViewWrapper?.dbView) { + return false; + } + + let isDummyMessage = !gViewWrapper.isSynthetic && !gFolder; + + if (["cmd_goBack", "cmd_goForward"].includes(command)) { + let activeMessageHistory = ( + window.messageBrowser?.contentWindow ?? window + ).messageHistory; + let relPos = command === "cmd_goBack" ? -1 : 1; + if (relPos === -1 && activeMessageHistory.canPop(0)) { + return !isDummyMessage; + } + return !isDummyMessage && activeMessageHistory.canPop(relPos); + } + + if (command in this._navigationCommands) { + return !isDummyMessage; + } + + let numSelectedMessages = isDummyMessage ? 1 : gDBView.numSelected; + + // Evaluate these properties only if needed, not once for each command. + let folder = () => { + if (gFolder) { + return gFolder; + } + if (gDBView.numSelected >= 1) { + return gDBView.hdrForFirstSelectedMessage?.folder; + } + return null; + }; + let isNewsgroup = () => + folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, true); + let canMove = () => + numSelectedMessages >= 1 && + (folder()?.canDeleteMessages || gViewWrapper.isSynthetic); + + switch (command) { + case "cmd_cancel": + if (numSelectedMessages == 1 && isNewsgroup()) { + // Ensure author of message matches own identity + let author = gDBView.hdrForFirstSelectedMessage.mime2DecodedAuthor; + return MailServices.accounts + .getIdentitiesForServer(folder().server) + .some(id => id.fullAddress == author); + } + return false; + case "cmd_openConversation": + return ( + // This (instead of numSelectedMessages) is necessary to be able to + // also open a collapsed thread in conversation. + gDBView.selection.count == 1 && + ConversationOpener.isMessageIndexed( + gDBView.hdrForFirstSelectedMessage + ) + ); + case "cmd_replylist": + if ( + !mailContextMenu.selectionIsOverridden && + hasIdentities && + numSelectedMessages == 1 + ) { + return (window.messageBrowser?.contentWindow ?? window) + .currentHeaderData?.["list-post"]; + } + return false; + case "cmd_viewPageSource": + case "cmd_saveAsTemplate": + return numSelectedMessages == 1; + case "cmd_reply": + case "cmd_replySender": + case "cmd_replyall": + case "cmd_forward": + case "cmd_forwardInline": + case "cmd_forwardAttachment": + case "cmd_redirect": + case "cmd_editAsNew": + return ( + hasIdentities && + (numSelectedMessages == 1 || + (numSelectedMessages > 1 && + // Exclude collapsed threads. + numSelectedMessages == gDBView.selection.count)) + ); + case "cmd_copyMessage": + case "cmd_saveAsFile": + return numSelectedMessages >= 1; + case "cmd_openMessage": + return ( + (location.href == "about:3pane" || + parent.location.href == "about:3pane") && + numSelectedMessages >= 1 && + !isDummyMessage + ); + case "cmd_tag": + case "cmd_tag1": + case "cmd_tag2": + case "cmd_tag3": + case "cmd_tag4": + case "cmd_tag5": + case "cmd_tag6": + case "cmd_tag7": + case "cmd_tag8": + case "cmd_tag9": + case "cmd_addTag": + case "cmd_removeTags": + case "cmd_toggleTag": + case "cmd_toggleRead": + case "cmd_markReadByDate": + case "cmd_markAsFlagged": + case "cmd_applyFiltersToSelection": + return numSelectedMessages >= 1 && !isDummyMessage; + case "cmd_copyDecryptedTo": { + let showDecrypt = numSelectedMessages > 1; + if (numSelectedMessages == 1 && !isDummyMessage) { + let msgURI = gDBView.URIForFirstSelectedMessage; + if (msgURI) { + showDecrypt = + EnigmailURIs.isEncryptedUri(msgURI) || + gEncryptedURIService.isEncrypted(msgURI); + } + } + return showDecrypt; + } + case "cmd_editDraftMsg": + return ( + numSelectedMessages >= 1 && + folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true) + ); + case "cmd_newMsgFromTemplate": + case "cmd_editTemplateMsg": + return ( + numSelectedMessages >= 1 && + folder()?.isSpecialFolder(Ci.nsMsgFolderFlags.Templates, true) + ); + case "cmd_replyGroup": + return isNewsgroup(); + case "cmd_markAsRead": + return ( + numSelectedMessages >= 1 && + !isDummyMessage && + gViewWrapper.dbView.getSelectedMsgHdrs().some(msg => !msg.isRead) + ); + case "cmd_markAsUnread": + return ( + numSelectedMessages >= 1 && + !isDummyMessage && + gViewWrapper.dbView.getSelectedMsgHdrs().some(msg => msg.isRead) + ); + case "cmd_markThreadAsRead": { + if (numSelectedMessages == 0 || isDummyMessage) { + return false; + } + let sel = gViewWrapper.dbView.selection; + for (let i = 0; i < sel.getRangeCount(); i++) { + let start = {}; + let end = {}; + sel.getRangeAt(i, start, end); + for (let j = start.value; j <= end.value; j++) { + if ( + gViewWrapper.dbView.getThreadContainingIndex(j) + .numUnreadChildren > 0 + ) { + return true; + } + } + } + return false; + } + case "cmd_markAllRead": + return gDBView?.msgFolder?.getNumUnread(false) > 0; + case "cmd_markAsJunk": + case "cmd_markAsNotJunk": + return this._getViewCommandStatus(Ci.nsMsgViewCommandType.junk); + case "cmd_archive": + return ( + !isDummyMessage && + MessageArchiver.canArchive( + gDBView.getSelectedMsgHdrs(), + gViewWrapper.isSingleFolder + ) + ); + case "cmd_moveMessage": { + return canMove(); + } + case "cmd_moveToFolderAgain": { + // Disable "Move to <folder> Again" for news and other read only + // folders since we can't really move messages from there - only copy. + let canMoveAgain = numSelectedMessages >= 1; + if (Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move")) { + canMoveAgain = canMove() && !isNewsgroup(); + } + if (canMoveAgain) { + let targetURI = Services.prefs.getStringPref( + "mail.last_msg_movecopy_target_uri" + ); + canMoveAgain = targetURI && MailUtils.getExistingFolder(targetURI); + } + return !!canMoveAgain; + } + case "cmd_deleteMessage": + return canMove(); + case "cmd_shiftDeleteMessage": + return this._getViewCommandStatus( + Ci.nsMsgViewCommandType.deleteNoTrash + ); + case "cmd_createFilterFromMenu": + return ( + numSelectedMessages == 1 && + !isDummyMessage && + folder()?.server.canHaveFilters + ); + case "cmd_watchThread": { + let enabledObj = {}; + let checkStatusObj = {}; + gViewWrapper.dbView.getCommandStatus( + Ci.nsMsgViewCommandType.toggleThreadWatched, + enabledObj, + checkStatusObj + ); + return enabledObj.value; + } + case "cmd_applyFilters": { + return this._getViewCommandStatus(Ci.nsMsgViewCommandType.applyFilters); + } + } + + return false; + }, + doCommand(command, ...args) { + if (!this.isCommandEnabled(command)) { + return; + } + + if (command in this._composeCommands) { + this._composeMsgByType(this._composeCommands[command], ...args); + return; + } + + if (command in this._navigationCommands) { + if (parent.location.href == "about:3pane") { + // If we're in about:message inside about:3pane, it's the parent + // window that needs to advance to the next message. + parent.commandController.doCommand(command, ...args); + } else { + this._navigate(this._navigationCommands[command]); + } + return; + } + + if (command in this._viewCommands) { + if (command.endsWith("Read") || command.endsWith("Unread")) { + if (window.ClearPendingReadTimer) { + window.ClearPendingReadTimer(); + } else { + window.messageBrowser.contentWindow.ClearPendingReadTimer(); + } + } + gViewWrapper.dbView.doCommand(this._viewCommands[command]); + return; + } + + if (command in this._callbackCommands) { + this._callbackCommands[command](...args); + } + }, + + _getViewCommandStatus(commandType) { + if (!gViewWrapper?.dbView) { + return false; + } + + let enabledObj = {}; + let checkStatusObj = {}; + gViewWrapper.dbView.getCommandStatus( + commandType, + enabledObj, + checkStatusObj + ); + return enabledObj.value; + }, + + /** + * Calls the ComposeMessage function with the desired type, and proper default + * based on the event that fired it. + * + * @param composeType the nsIMsgCompType to pass to the function + * @param event (optional) the event that triggered the call + */ + _composeMsgByType(composeType, event) { + // If we're the hidden window, then we're not going to have a gFolderDisplay + // to work out existing folders, so just use null. + let msgFolder = gFolder; + let msgUris = + gFolder || gViewWrapper.isSynthetic + ? gDBView?.getURIsForSelection() + : [window.gMessageURI]; + + let messagePaneBrowser; + let autodetectCharset; + let selection; + if (!mailContextMenu.selectionIsOverridden) { + if (window.messageBrowser) { + if (!window.messageBrowser.hidden) { + messagePaneBrowser = + window.messageBrowser.contentWindow.getMessagePaneBrowser(); + autodetectCharset = + window.messageBrowser.contentWindow.autodetectCharset; + } + } else { + messagePaneBrowser = window.getMessagePaneBrowser(); + autodetectCharset = window.autodetectCharset; + } + selection = messagePaneBrowser?.contentWindow?.getSelection(); + } + + if (event && event.shiftKey) { + window.browsingContext.topChromeWindow.ComposeMessage( + composeType, + Ci.nsIMsgCompFormat.OppositeOfDefault, + msgFolder, + msgUris, + selection, + autodetectCharset + ); + } else { + window.browsingContext.topChromeWindow.ComposeMessage( + composeType, + Ci.nsIMsgCompFormat.Default, + msgFolder, + msgUris, + selection, + autodetectCharset + ); + } + }, + + _navigate(navigationType) { + if ( + [Ci.nsMsgNavigationType.back, Ci.nsMsgNavigationType.forward].includes( + navigationType + ) + ) { + const { messageHistory } = window.messageBrowser?.contentWindow ?? window; + const noCurrentMessage = messageHistory.canPop(0); + let relativePosition = -1; + if (navigationType === Ci.nsMsgNavigationType.forward) { + relativePosition = 1; + } else if (noCurrentMessage) { + relativePosition = 0; + } + let newMessageURI = messageHistory.pop(relativePosition)?.messageURI; + if (!newMessageURI) { + return; + } + let msgHdr = + MailServices.messageServiceFromURI(newMessageURI).messageURIToMsgHdr( + newMessageURI + ); + if (msgHdr) { + if (window.threadPane) { + window.selectMessage(msgHdr); + } else { + window.displayMessage(newMessageURI); + } + } + return; + } + + let resultKey = { value: nsMsgKey_None }; + let resultIndex = { value: nsMsgViewIndex_None }; + let threadIndex = {}; + + let expandCurrentThread = false; + let currentIndex = window.threadTree ? window.threadTree.currentIndex : -1; + + // If we're doing next unread, and a collapsed thread is selected, and + // the top level message is unread, just set the result manually to + // the top level message, without using viewNavigate. + if ( + navigationType == Ci.nsMsgNavigationType.nextUnreadMessage && + currentIndex != -1 && + gViewWrapper.isCollapsedThreadAtIndex(currentIndex) && + !( + gViewWrapper.dbView.getFlagsAt(currentIndex) & Ci.nsMsgMessageFlags.Read + ) + ) { + expandCurrentThread = true; + resultIndex.value = currentIndex; + resultKey.value = gViewWrapper.dbView.getKeyAt(currentIndex); + } else { + gViewWrapper.dbView.viewNavigate( + navigationType, + resultKey, + resultIndex, + threadIndex, + true + ); + if (resultIndex.value == nsMsgViewIndex_None) { + if (CrossFolderNavigation(navigationType)) { + this._navigate(navigationType); + } + return; + } + if (resultKey.value == nsMsgKey_None) { + return; + } + } + + if (window.threadTree) { + if ( + gDBView.selection.count == 1 && + window.threadTree.selectedIndex == resultIndex.value && + !expandCurrentThread + ) { + return; + } + + window.threadTree.expandRowAtIndex(resultIndex.value); + // Do an instant scroll before setting the index to avoid animation. + window.threadTree.scrollToIndex(resultIndex.value, true); + window.threadTree.selectedIndex = resultIndex.value; + // Focus the thread tree, unless the message pane has focus. + if ( + Services.focus.focusedWindow != + window.messageBrowser.contentWindow?.getMessagePaneBrowser() + .contentWindow + ) { + // There's something strange going on here – calling `focus` + // immediately can cause the scroll position to return to where it was + // before changing folders, which starts a cascade of "scroll" events + // until the tree scrolls to the top. + setTimeout(() => window.threadTree.table.body.focus()); + } + } else { + if (window.gMessage.messageKey == resultKey.value) { + return; + } + + gViewWrapper.dbView.selection.select(resultIndex.value); + window.displayMessage( + gViewWrapper.dbView.URIForFirstSelectedMessage, + gViewWrapper + ); + } + }, +}; +// Add the controller to this window's controllers, so that built-in commands +// such as cmd_selectAll run our code instead of the default code. +window.controllers.insertControllerAt(0, commandController); + +var dbViewWrapperListener = { + _nextViewIndexAfterDelete: null, + + messenger: null, + msgWindow: top.msgWindow, + threadPaneCommandUpdater: { + QueryInterface: ChromeUtils.generateQI([ + "nsIMsgDBViewCommandUpdater", + "nsISupportsWeakReference", + ]), + updateCommandStatus() {}, + displayMessageChanged(folder, subject, keywords) {}, + updateNextMessageAfterDelete() { + dbViewWrapperListener._nextViewIndexAfterDelete = gDBView + ? gDBView.msgToSelectAfterDelete + : null; + }, + summarizeSelection() { + return true; + }, + selectedMessageRemoved() { + // We need to invalidate the tree, but this method could get called + // multiple times, so we won't invalidate until we get to the end of the + // event loop. + if (this._timeout) { + return; + } + this._timeout = setTimeout(() => { + dbViewWrapperListener.onMessagesRemoved(); + window.threadTree?.invalidate(); + delete this._timeout; + }); + }, + }, + + get shouldUseMailViews() { + return !!top.ViewPickerBinding?.isVisible; + }, + get shouldDeferMessageDisplayUntilAfterServerConnect() { + return false; + }, + shouldMarkMessagesReadOnLeavingFolder(msgFolder) { + return false; + }, + onFolderLoading(isFolderLoading) {}, + onSearching(isSearching) {}, + onCreatedView() { + if (window.threadTree) { + window.threadPane.setTreeView(gViewWrapper.dbView); + // There is no persisted thread last expanded state for synthetic views. + if (!gViewWrapper.isSynthetic) { + window.threadPane.restoreThreadState(); + } + window.threadPane.isFirstScroll = true; + window.threadPane.scrollDetected = false; + window.threadPane.scrollToLatestRowIfNoSelection(); + } + }, + onDestroyingView(folderIsComingBack) { + if (!window.threadTree) { + return; + } + + if (folderIsComingBack) { + // We'll get a new view of the same folder (e.g. with a quick filter) - + // try to preserve the selection. + window.threadPane.saveSelection(); + } else { + if (gDBView) { + gDBView.setJSTree(null); + } + window.threadTree.view = gDBView = null; + } + }, + onLoadingFolder(dbFolderInfo) { + window.quickFilterBar?.onFolderChanged(); + }, + onDisplayingFolder() {}, + onLeavingFolder() {}, + onMessagesLoaded(all) { + if (!window.threadPane) { + return; + } + // Try to restore what was selected. Keep the saved selection (if there is + // one) until we have all of the messages. This will also reveal selected + // messages in collapsed threads. + window.threadPane.restoreSelection({ discard: all }); + + if (all || gViewWrapper.search.hasSearchTerms) { + window.threadPane.ensureThreadStateForQuickSearchView(); + let newMessageFound = false; + if (window.threadPane.scrollToNewMessage) { + try { + let index = gDBView.findIndexOfMsgHdr(gFolder.firstNewMessage, true); + if (index != nsMsgViewIndex_None) { + window.threadTree.scrollToIndex(index, true); + newMessageFound = true; + } + } catch (ex) { + console.error(ex); + } + window.threadPane.scrollToNewMessage = false; + } + window.threadTree.reset(); + if (!newMessageFound && !window.threadPane.scrollDetected) { + window.threadPane.scrollToLatestRowIfNoSelection(); + } + } + window.quickFilterBar?.onMessagesChanged(); + }, + onMailViewChanged() { + window.dispatchEvent(new CustomEvent("MailViewChanged")); + }, + onSortChanged() { + // If there is no selection, scroll to the most relevant end. + window.threadPane?.scrollToLatestRowIfNoSelection(); + }, + onMessagesRemoved() { + window.quickFilterBar?.onMessagesChanged(); + + if (!gDBView || (!gFolder && !gViewWrapper?.isSynthetic)) { + // This can't be a notification about the message currently displayed. + return; + } + + let rowCount = gDBView.rowCount; + + // There's no messages left. + if (rowCount == 0) { + if (location.href == "about:3pane") { + // In a 3-pane tab, clear the message pane and selection. + window.threadTree.selectedIndex = -1; + } else if (parent?.location != "about:3pane") { + // In a standalone message tab or window, close the tab or window. + let tabmail = top.document.getElementById("tabmail"); + if (tabmail) { + tabmail.closeTab(window.tabOrWindow); + } else { + top.close(); + } + } + this._nextViewIndexAfterDelete = null; + return; + } + + if ( + this._nextViewIndexAfterDelete != null && + this._nextViewIndexAfterDelete != nsMsgViewIndex_None + ) { + // Select the next message in the view, based on what we were told in + // updateNextMessageAfterDelete. + if (this._nextViewIndexAfterDelete >= rowCount) { + this._nextViewIndexAfterDelete = rowCount - 1; + } + if ( + this._nextViewIndexAfterDelete > -1 && + !mailContextMenu.selectionIsOverridden + ) { + if (location.href == "about:3pane") { + // A "select" event should fire here, but setting the selected index + // might not fire it. OTOH, we want it to fire only once, so see if + // the event is fired, and if not, fire it. + let eventFired = false; + let onSelect = () => (eventFired = true); + + window.threadTree.addEventListener("select", onSelect, { + once: true, + }); + window.threadTree.selectedIndex = this._nextViewIndexAfterDelete; + window.threadTree.removeEventListener("select", onSelect); + + if (!eventFired) { + window.threadTree.dispatchEvent(new CustomEvent("select")); + } + } else if (parent?.location != "about:3pane") { + if ( + Services.prefs.getBoolPref("mail.close_message_window.on_delete") + ) { + // Bail out early if this is about a partial POP3 message that has + // just been completed and reloaded. + if (document.body.classList.contains("completed-message")) { + document.body.classList.remove("completed-message"); + return; + } + // Close the tab or window if the displayed message is deleted. + let tabmail = top.document.getElementById("tabmail"); + if (tabmail) { + tabmail.closeTab(window.tabOrWindow); + } else { + top.close(); + } + return; + } + gDBView.selection.select(this._nextViewIndexAfterDelete); + window.displayMessage( + gDBView.getURIForViewIndex(this._nextViewIndexAfterDelete), + gViewWrapper + ); + } + } + this._nextViewIndexAfterDelete = null; + } + }, + onMessageRemovalFailed() { + this._nextViewIndexAfterDelete = null; + }, + onMessageCountsChanged() { + window.quickFilterBar?.onMessagesChanged(); + }, +}; diff --git a/comm/mail/base/content/mailContext.inc.xhtml b/comm/mail/base/content/mailContext.inc.xhtml new file mode 100644 index 0000000000..989cdd710e --- /dev/null +++ b/comm/mail/base/content/mailContext.inc.xhtml @@ -0,0 +1,324 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at http://mozilla.org/MPL/2.0/. + + <menupopup id="mailContext" needsgutter="true"> + <!-- Links --> + <menuitem id="mailContext-openInBrowser" + class="menuitem-iconic" + label="&openInBrowser.label;" + accesskey="&openInBrowser.accesskey;"/> + <menuitem id="mailContext-openLinkInBrowser" + class="menuitem-iconic" + label="&openLinkInBrowser.label;" + accesskey="&openLinkInBrowser.accesskey;"/> + <menuitem id="mailContext-copylink" + class="menuitem-iconic" + label="©LinkCmd.label;" + accesskey="©LinkCmd.accesskey;"/> + <menuitem id="mailContext-savelink" + class="menuitem-iconic" + label="&saveLinkAsCmd.label;" + accesskey="&saveLinkAsCmd.accesskey;"/> + <menuitem id="mailContext-reportPhishingURL" + class="menuitem-iconic" + label="&reportPhishingURL.label;" + accesskey="&reportPhishingURL.accesskey;"/> + <menuitem id="mailContext-addemail" + class="menuitem-iconic" + label="&AddToAddressBook.label;" + accesskey="&AddToAddressBook.accesskey;"/> + <menuitem id="mailContext-composeemailto" + class="menuitem-iconic" + label="&SendMessageTo.label;" + accesskey="&SendMessageTo.accesskey;"/> + <menuitem id="mailContext-copyemail" + class="menuitem-iconic" + label="©EmailCmd.label;" + accesskey="©EmailCmd.accesskey;"/> + <menuseparator/> + + <!-- Images --> + <menuitem id="mailContext-copyimage" + class="menuitem-iconic" + label="©ImageAllCmd.label;" + accesskey="©ImageAllCmd.accesskey;"/> + <menuitem id="mailContext-saveimage" + class="menuitem-iconic" + label="&saveImageAsCmd.label;" + accesskey="&saveImageAsCmd.accesskey;"/> + <menuseparator/> + + <!-- Edit --> + <menuitem id="mailContext-copy" + class="menuitem-iconic" + data-l10n-id="text-action-copy"/> + <menuitem id="mailContext-selectall" + class="menuitem-iconic" + data-l10n-id="text-action-select-all"/> + <menuseparator/> + + <!-- Search --> + <menuitem id="mailContext-searchTheWeb" + class="menuitem-iconic"/> + <menuseparator/> + + <!-- Drafts/templates --> + <menuitem id="mailContext-editDraftMsg" + class="menuitem-iconic" + label="&contextEditDraftMsg.label;" + default="true"/> + <menuitem id="mailContext-newMsgFromTemplate" + class="menuitem-iconic" + label="&contextNewMsgFromTemplate.label;" + default="true"/> + <menuitem id="mailContext-editTemplateMsg" + class="menuitem-iconic" + label="&contextEditTemplate.label;" + accesskey="&contextEditTemplate.accesskey;"/> + <menuseparator/> + + <!-- Open messages --> + <menuitem id="mailContext-openNewTab" + class="menuitem-iconic" + label="&contextOpenNewTab.label;" + accesskey="&contextOpenNewTab.accesskey;"/> + <menuitem id="mailContext-openNewWindow" + class="menuitem-iconic" + label="&contextOpenNewWindow.label;" + accesskey="&contextOpenNewWindow.accesskey;"/> + <menuitem id="mailContext-openConversation" + class="menuitem-iconic" + label="&contextOpenConversation.label;" + accesskey="&contextOpenConversation.accesskey;"/> + <menuitem id="mailContext-openContainingFolder" + class="menuitem-iconic" + label="&contextOpenContainingFolder.label;" + accesskey="&contextOpenContainingFolder.accesskey;"/> + <menuseparator/> + + <!-- Reply/forward/redirect --> + <menuitem id="mailContext-replyNewsgroup" + class="menuitem-iconic" + label="&contextReplyNewsgroup2.label;" + accesskey="&contextReplyNewsgroup2.accesskey;"/> + <menuitem id="mailContext-replySender" + class="menuitem-iconic" + label="&contextReplySender.label;" + accesskey="&contextReplySender.accesskey;"/> + <menuitem id="mailContext-replyAll" + class="menuitem-iconic" + label="&contextReplyAll.label;" + accesskey="&contextReplyAll.accesskey;"/> + <menuitem id="mailContext-replyList" + class="menuitem-iconic" + label="&contextReplyList.label;" + accesskey="&contextReplyList.accesskey;"/> + <menuitem id="mailContext-forward" + class="menuitem-iconic" + label="&contextForward.label;" + accesskey="&contextForward.accesskey;"/> + <menu id="mailContext-forwardAsMenu" + class="menu-iconic" + label="&contextForwardAsMenu.label;" + accesskey="&contextForwardAsMenu.accesskey;"> + <menupopup id="mailContext-forwardAsPopup"> + <menuitem id="mailContext-forwardAsInline" + label="&contextForwardAsInline.label;" + accesskey="&contextForwardAsInline.accesskey;"/> + <menuitem id="mailContext-forwardAsAttachment" + label="&contextForwardAsAttachmentItem.label;" + accesskey="&contextForwardAsAttachmentItem.accesskey;"/> + </menupopup> + </menu> + + <menuitem id="mailContext-multiForwardAsAttachment" + class="menuitem-iconic" + label="&contextMultiForwardAsAttachment.label;" + accesskey="&contextMultiForwardAsAttachment.accesskey;"/> + <menuitem id="mailContext-redirect" + class="menuitem-iconic" + data-l10n-id="context-menu-redirect-msg"/> + <menuitem id="mailContext-cancel" + class="menuitem-iconic" + data-l10n-id="context-menu-cancel-msg"/> + <menuitem id="mailContext-editAsNew" + class="menuitem-iconic" + label="&contextEditMsgAsNew.label;" + accesskey="&contextEditMsgAsNew.accesskey;"/> + <menuseparator/> + + <!-- Tags/mark sub-menus --> + <menu id="mailContext-tags" + class="menu-iconic" + label="&tagMenu.label;" + accesskey="&tagMenu.accesskey;"> + <menupopup id="mailContext-tagpopup" needsgutter="true"> + <menuitem id="mailContext-addNewTag" + class="menuitem-iconic" + label="&addNewTag.label;" + accesskey="&addNewTag.accesskey;"/> + <menuitem id="mailContext-manageTags" + class="menuitem-iconic" + label="&manageTags.label;" + accesskey="&manageTags.accesskey;"/> + <menuseparator/> + <menuitem id="mailContext-tagRemoveAll" + class="menuitem-iconic" + accesskey="&tagCmd0.key;"/> + <menuseparator/> + </menupopup> + </menu> + <menu id="mailContext-mark" + class="menu-iconic" + label="&markMenu.label;" + accesskey="&markMenu.accesskey;"> + <menupopup id="mailContext-markPopup"> + <menuitem id="mailContext-markRead" + class="menuitem-iconic" + label="&markAsReadCmd.label;" + accesskey="&markAsReadCmd.accesskey;"/> + <menuitem id="mailContext-markUnread" + class="menuitem-iconic" + label="&markAsUnreadCmd.label;" + accesskey="&markAsUnreadCmd.accesskey;"/> + <menuitem id="mailContext-markThreadAsRead" + class="menuitem-iconic" + label="&markThreadAsReadCmd.label;" + accesskey="&markThreadAsReadCmd.accesskey;"/> + <menuitem id="mailContext-markReadByDate" + class="menuitem-iconic" + label="&markReadByDateCmd.label;" + accesskey="&markReadByDateCmd.accesskey;"/> + <menuitem id="mailContext-markAllRead" + class="menuitem-iconic" + label="&markAllReadCmd.label;" + accesskey="&markAllReadCmd.accesskey;"/> + <menuseparator/> + <menuitem id="mailContext-markFlagged" + type="checkbox" + label="&markStarredCmd.label;" + accesskey="&markStarredCmd.accesskey;"/> + <menuseparator/> + <menuitem id="mailContext-markAsJunk" + class="menuitem-iconic" + label="&markAsJunkCmd.label;" + accesskey="&markAsJunkCmd.accesskey;"/> + <menuitem id="mailContext-markAsNotJunk" + class="menuitem-iconic" + label="&markAsNotJunkCmd.label;" + accesskey="&markAsNotJunkCmd.accesskey;"/> + <menuitem id="mailContext-recalculateJunkScore" + class="menuitem-iconic" + label="&recalculateJunkScoreCmd.label;" + accesskey="&recalculateJunkScoreCmd.accesskey;"/> + </menupopup> + </menu> + <menuseparator/> + + <!-- Move/copy/archive/convert/delete --> + <menuitem id="mailContext-copyMessageUrl" + class="menuitem-iconic" + label="©MessageLocation.label;" + accesskey="©MessageLocation.accesskey;"/> + <menuitem id="mailContext-archive" + class="menuitem-iconic" + label="&contextArchive.label;" + accesskey="&contextArchive.accesskey;"/> + <menu id="mailContext-moveMenu" + class="menu-iconic" + label="&contextMoveMsgMenu.label;" + accesskey="&contextMoveMsgMenu.accesskey;"> + <menupopup is="folder-menupopup" id="mailContext-fileHereMenu" + mode="filing" + showFileHereLabel="true" + showRecent="true" + recentLabel="&contextMoveCopyMsgRecentMenu.label;" + recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;" + showFavorites="true" + favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;" + favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/> + </menu> + <menu id="mailContext-copyMenu" + class="menu-iconic" + label="&contextCopyMsgMenu.label;" + accesskey="&contextCopyMsgMenu.accesskey;"> + <menupopup is="folder-menupopup" id="mailContext-copyHereMenu" + mode="filing" + showFileHereLabel="true" + showRecent="true" + recentLabel="&contextMoveCopyMsgRecentMenu.label;" + recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;" + showFavorites="true" + favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;" + favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/> + </menu> + <menuitem id="mailContext-moveToFolderAgain" + class="menuitem-iconic" + label="&moveToFolderAgain.label;" + accesskey="&moveToFolderAgain.accesskey;"/> + + <menu id="mailContext-decryptToFolder" + class="menu-iconic" + data-l10n-id="context-menu-decrypt-to-folder2" + data-l10n-attrs="accesskey"> + <menupopup is="folder-menupopup" + id="mailContext-decryptToTargetFolder" + mode="filing" + showFileHereLabel="true" + showRecent="true" + recentLabel="&contextMoveCopyMsgRecentMenu.label;" + recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;" + showFavorites="true" + favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;" + favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;" + hasbeenopened="false" /> + </menu> + + <menu id="mailContext-calendar-convert-menu" + class="menu-iconic hide-when-calendar-deactivated" + label="&calendar.context.convertmenu.label;" + accesskey="&calendar.context.convertmenu.accesskey.mail;"> + <menupopup id="mailContext-calendar-convert-menupopup"> + <menuitem id="mailContext-calendar-convert-event-menuitem" + label="&calendar.context.convertmenu.event.label;" + accesskey="&calendar.context.convertmenu.event.accesskey;"/> + <menuitem id="mailContext-calendar-convert-task-menuitem" + label="&calendar.context.convertmenu.task.label;" + accesskey="&calendar.context.convertmenu.task.accesskey;"/> + </menupopup> + </menu> + <menuitem id="mailContext-delete" + class="menuitem-iconic"/> + <menuseparator/> + + <!-- Threads --> + <menuitem id="mailContext-ignoreThread" + type="checkbox" + label="&contextKillThreadMenu.label;" + accesskey="&contextKillThreadMenu.accesskey;"/> + <menuitem id="mailContext-ignoreSubthread" + type="checkbox" + label="&contextKillSubthreadMenu.label;" + accesskey="&contextKillSubthreadMenu.accesskey;"/> + <menuitem id="mailContext-watchThread" + type="checkbox" + label="&contextWatchThreadMenu.label;" + accesskey="&contextWatchThreadMenu.accesskey;"/> + <menuseparator/> + + <!-- Save/print/download --> + <menuitem id="mailContext-saveAs" + class="menuitem-iconic" + label="&contextSaveAs.label;" + accesskey="&contextSaveAs.accesskey;"/> + <menuitem id="mailContext-print" + class="menuitem-iconic" + label="&contextPrint.label;" + accesskey="&contextPrint.accesskey;" + observes="cmd_print"/> + <menuitem id="mailContext-downloadSelected" + class="menuitem-iconic" + label="&downloadSelectedCmd.label;" + accesskey="&downloadSelectedCmd.accesskey;"/> + </menupopup> diff --git a/comm/mail/base/content/mailContext.js b/comm/mail/base/content/mailContext.js new file mode 100644 index 0000000000..7b2a050b1e --- /dev/null +++ b/comm/mail/base/content/mailContext.js @@ -0,0 +1,822 @@ +/* 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/. */ + +// mailCommon.js +/* globals commandController */ + +// about:3pane and about:message must BOTH provide these: + +/* globals goDoCommand */ // globalOverlay.js +/* globals gDBView, gFolder, gViewWrapper, messengerBundle */ + +/* globals gEncryptedURIService */ // mailCommon.js + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + calendarDeactivator: + "resource:///modules/calendar/calCalendarDeactivator.jsm", + EnigmailURIs: "chrome://openpgp/content/modules/uris.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + PhishingDetector: "resource:///modules/PhishingDetector.jsm", + TagUtils: "resource:///modules/TagUtils.jsm", +}); + +/** + * Called by ContextMenuParent if this window is about:3pane, or is + * about:message but not contained by about:3pane. + * + * @returns {boolean} true if this function opened the context menu + */ +function openContextMenu({ data, target }, browser) { + if (window.browsingContext.parent != window.browsingContext.top) { + // Not sure how we'd get here, but let's not continue if we do. + return false; + } + + if (browser.getAttribute("context") != "mailContext") { + return false; + } + + mailContextMenu.setAsMessagePaneContextMenu(data, target.browsingContext); + let screenX = data.context.screenXDevPx / window.devicePixelRatio; + let screenY = data.context.screenYDevPx / window.devicePixelRatio; + let popup = document.getElementById("mailContext"); + popup.openPopupAtScreen(screenX, screenY, true); + + return true; +} + +var mailContextMenu = { + /** + * @type {XULPopupElement} + */ + _menupopup: null, + + // Commands handled by commandController. + _commands: { + "mailContext-editDraftMsg": "cmd_editDraftMsg", + "mailContext-newMsgFromTemplate": "cmd_newMsgFromTemplate", + "mailContext-editTemplateMsg": "cmd_editTemplateMsg", + "mailContext-openConversation": "cmd_openConversation", + "mailContext-replyNewsgroup": "cmd_replyGroup", + "mailContext-replySender": "cmd_replySender", + "mailContext-replyAll": "cmd_replyall", + "mailContext-replyList": "cmd_replylist", + "mailContext-forward": "cmd_forward", + "mailContext-forwardAsInline": "cmd_forwardInline", + "mailContext-forwardAsAttachment": "cmd_forwardAttachment", + "mailContext-multiForwardAsAttachment": "cmd_forwardAttachment", + "mailContext-redirect": "cmd_redirect", + "mailContext-cancel": "cmd_cancel", + "mailContext-editAsNew": "cmd_editAsNew", + "mailContext-addNewTag": "cmd_addTag", + "mailContext-manageTags": "cmd_manageTags", + "mailContext-tagRemoveAll": "cmd_removeTags", + "mailContext-markReadByDate": "cmd_markReadByDate", + "mailContext-markFlagged": "cmd_markAsFlagged", + "mailContext-archive": "cmd_archive", + "mailContext-moveToFolderAgain": "cmd_moveToFolderAgain", + "mailContext-decryptToFolder": "cmd_copyDecryptedTo", + "mailContext-delete": "cmd_deleteMessage", + "mailContext-ignoreThread": "cmd_killThread", + "mailContext-ignoreSubthread": "cmd_killSubthread", + "mailContext-watchThread": "cmd_watchThread", + "mailContext-saveAs": "cmd_saveAsFile", + "mailContext-print": "cmd_print", + "mailContext-downloadSelected": "cmd_downloadSelected", + }, + + // More commands handled by commandController, except these ones get + // disabled instead of hidden. + _alwaysVisibleCommands: { + "mailContext-markRead": "cmd_markAsRead", + "mailContext-markUnread": "cmd_markAsUnread", + "mailContext-markThreadAsRead": "cmd_markThreadAsRead", + "mailContext-markAllRead": "cmd_markAllRead", + "mailContext-markAsJunk": "cmd_markAsJunk", + "mailContext-markAsNotJunk": "cmd_markAsNotJunk", + "mailContext-recalculateJunkScore": "cmd_recalculateJunkScore", + }, + + /** + * If we have overridden the selection for the context menu. + * + * @see `setOverrideSelection` + * @type {boolean} + */ + _selectionIsOverridden: false, + + init() { + this._menupopup = document.getElementById("mailContext"); + this._menupopup.addEventListener("popupshowing", this); + this._menupopup.addEventListener("popuphidden", this); + this._menupopup.addEventListener("command", this); + }, + + handleEvent(event) { + switch (event.type) { + case "popupshowing": + this.onPopupShowing(event); + break; + case "popuphidden": + this.onPopupHidden(event); + break; + case "command": + this.onCommand(event); + break; + } + }, + + onPopupShowing(event) { + if (event.target == this._menupopup) { + this.fillMailContextMenu(event); + } + }, + + onPopupHidden(event) { + if (event.target == this._menupopup) { + this.clearOverrideSelection(); + } + }, + + onCommand(event) { + this.onMailContextMenuCommand(event); + }, + + /** + * Override the selection that this context menu should operate on. The + * effect lasts until `clearOverrideSelection` is called by `onPopupHidden`. + * + * @param {integer} index - The index of the row to use as selection. + */ + setOverrideSelection(index) { + this._selectionIsOverridden = true; + window.threadPane.saveSelection(); + window.threadTree._selection.selectEventsSuppressed = true; + window.threadTree._selection.select(index); + }, + + /** + * Has the real selection been overridden by a right-click on a message that + * wasn't selected? + * + * @type {boolean} + */ + get selectionIsOverridden() { + return this._selectionIsOverridden; + }, + + /** + * Clear the overriding selection, and go back to the previous selection. + */ + clearOverrideSelection() { + if (!window.threadTree) { + return; + } + if (this._selectionIsOverridden) { + window.threadTree._selection.selectEventsSuppressed = true; + window.threadPane.restoreSelection({ notify: false }); + this._selectionIsOverridden = false; + window.threadTree.invalidate(); + } + window.threadTree + .querySelector(".context-menu-target") + ?.classList.remove("context-menu-target"); + window.threadTree._selection.selectEventsSuppressed = false; + window.threadTree.table.body.focus(); + }, + + setAsThreadPaneContextMenu() { + delete this.browsingContext; + delete this.context; + delete this.selectionInfo; + this.inThreadTree = true; + + for (let id of [ + "mailContext-openInBrowser", + "mailContext-openLinkInBrowser", + "mailContext-copylink", + "mailContext-savelink", + "mailContext-reportPhishingURL", + "mailContext-addemail", + "mailContext-composeemailto", + "mailContext-copyemail", + "mailContext-copyimage", + "mailContext-saveimage", + "mailContext-copy", + "mailContext-selectall", + "mailContext-searchTheWeb", + ]) { + document.getElementById(id).hidden = true; + } + }, + + setAsMessagePaneContextMenu({ context, selectionInfo }, browsingContext) { + function showItem(id, show) { + let item = document.getElementById(id); + if (item) { + item.hidden = !show; + } + } + + delete this.inThreadTree; + this.browsingContext = browsingContext; + this.context = context; + this.selectionInfo = selectionInfo; + + // showItem("mailContext-openInBrowser", false); + showItem( + "mailContext-openLinkInBrowser", + context.onLink && !context.onMailtoLink + ); + showItem("mailContext-copylink", context.onLink && !context.onMailtoLink); + showItem("mailContext-savelink", context.onLink); + showItem( + "mailContext-reportPhishingURL", + context.onLink && !context.onMailtoLink + ); + showItem("mailContext-addemail", context.onMailtoLink); + showItem("mailContext-composeemailto", context.onMailtoLink); + showItem("mailContext-copyemail", context.onMailtoLink); + showItem("mailContext-copyimage", context.onImage); + showItem("mailContext-saveimage", context.onLoadedImage); + showItem( + "mailContext-copy", + selectionInfo && !selectionInfo.docSelectionIsCollapsed + ); + showItem("mailContext-selectall", true); + showItem( + "mailContext-searchTheWeb", + selectionInfo && !selectionInfo.docSelectionIsCollapsed + ); + + let searchTheWeb = document.getElementById("mailContext-searchTheWeb"); + if (!searchTheWeb.hidden) { + let key = "openSearch.label"; + let abbrSelection; + if (selectionInfo.text.length > 15) { + key += ".truncated"; + abbrSelection = selectionInfo.text.slice(0, 15); + } else { + abbrSelection = selectionInfo.text; + } + + searchTheWeb.label = messengerBundle.formatStringFromName(key, [ + Services.search.defaultEngine.name, + abbrSelection, + ]); + } + }, + + fillMailContextMenu(event) { + function showItem(id, show) { + let item = document.getElementById(id); + if (item) { + item.hidden = !show; + } + } + + function enableItem(id, enabled) { + let item = document.getElementById(id); + item.disabled = !enabled; + } + + function checkItem(id, checked) { + let item = document.getElementById(id); + if (item) { + // Convert truthy/falsy to boolean before string. + item.setAttribute("checked", !!checked); + } + } + + function setSingleSelection(id, show = true) { + showItem(id, numSelectedMessages == 1 && show); + enableItem(id, numSelectedMessages == 1); + } + + // Hide things that don't work yet. + for (let id of [ + "mailContext-openInBrowser", + "mailContext-recalculateJunkScore", + ]) { + showItem(id, false); + } + + let onSpecialItem = + this.context?.isContentSelected || + this.context?.onCanvas || + this.context?.onLink || + this.context?.onImage || + this.context?.onAudio || + this.context?.onVideo || + this.context?.onTextInput; + + for (let id of ["mailContext-tags", "mailContext-mark"]) { + showItem(id, !onSpecialItem); + } + + // Ask commandController about the commands it controls. + for (let [id, command] of Object.entries(this._commands)) { + showItem( + id, + !onSpecialItem && commandController.isCommandEnabled(command) + ); + } + for (let [id, command] of Object.entries(this._alwaysVisibleCommands)) { + showItem(id, !onSpecialItem); + enableItem(id, commandController.isCommandEnabled(command)); + } + + let inAbout3Pane = !!window.threadTree; + let inThreadTree = !!this.inThreadTree; + + let message = + gFolder || gViewWrapper.isSynthetic + ? gDBView?.hdrForFirstSelectedMessage + : top.messenger.msgHdrFromURI(window.gMessageURI); + let folder = message?.folder; + let isDummyMessage = !gViewWrapper.isSynthetic && !folder; + + let numSelectedMessages = isDummyMessage ? 1 : gDBView.numSelected; + let isNewsgroup = folder?.isSpecialFolder( + Ci.nsMsgFolderFlags.Newsgroup, + true + ); + let canMove = + numSelectedMessages >= 1 && !isNewsgroup && folder?.canDeleteMessages; + let canCopy = numSelectedMessages >= 1; + + setSingleSelection("mailContext-openNewTab", inThreadTree); + setSingleSelection("mailContext-openNewWindow", inThreadTree); + setSingleSelection( + "mailContext-openContainingFolder", + (!isDummyMessage && !inAbout3Pane) || gViewWrapper.isSynthetic + ); + setSingleSelection("mailContext-forward", !onSpecialItem); + setSingleSelection("mailContext-forwardAsMenu", !onSpecialItem); + showItem( + "mailContext-multiForwardAsAttachment", + numSelectedMessages > 1 && + commandController.isCommandEnabled("cmd_forwardAttachment") + ); + + if (isDummyMessage) { + showItem("mailContext-tags", false); + } else { + showItem("mailContext-tags", true); + this._initMessageTags(); + } + + showItem("mailContext-mark", !isDummyMessage); + checkItem("mailContext-markFlagged", message?.isFlagged); + + setSingleSelection("mailContext-copyMessageUrl", !!isNewsgroup); + // Disable move if we can't delete message(s) from this folder. + showItem("mailContext-moveMenu", canMove && !onSpecialItem); + showItem("mailContext-copyMenu", canCopy && !onSpecialItem); + + top.initMoveToFolderAgainMenu( + document.getElementById("mailContext-moveToFolderAgain") + ); + + // Show only if a message is actively selected in the DOM. + // extractFromEmail can't work on dummy messages. + showItem( + "mailContext-calendar-convert-menu", + numSelectedMessages == 1 && + !isDummyMessage && + calendarDeactivator.isCalendarActivated + ); + + document.l10n.setAttributes( + document.getElementById("mailContext-delete"), + message.flags & Ci.nsMsgMessageFlags.IMAPDeleted + ? "mail-context-undelete-messages" + : "mail-context-delete-messages", + { + count: numSelectedMessages, + } + ); + + checkItem( + "mailContext-ignoreThread", + folder?.msgDatabase.isIgnored(message?.messageKey) + ); + checkItem( + "mailContext-ignoreSubthread", + folder && message.flags & Ci.nsMsgMessageFlags.Ignored + ); + checkItem( + "mailContext-watchThread", + folder?.msgDatabase.isWatched(message?.messageKey) + ); + + showItem( + "mailContext-downloadSelected", + window.threadTree && numSelectedMessages > 1 + ); + + let lastItem; + for (let child of document.getElementById("mailContext").children) { + if (child.localName == "menuseparator") { + child.hidden = !lastItem || lastItem.localName == "menuseparator"; + } + if (!child.hidden) { + lastItem = child; + } + } + if (lastItem.localName == "menuseparator") { + lastItem.hidden = true; + } + + // The rest of this block sends menu information to WebExtensions. + + let selectionInfo = this.selectionInfo; + let isContentSelected = selectionInfo + ? !selectionInfo.docSelectionIsCollapsed + : false; + let textSelected = selectionInfo ? selectionInfo.text : ""; + let isTextSelected = !!textSelected.length; + + let tabmail = top.document.getElementById("tabmail"); + let subject = { + menu: event.target, + tab: tabmail ? tabmail.currentTabInfo : top, + isContentSelected, + isTextSelected, + onTextInput: this.context?.onTextInput, + onLink: this.context?.onLink, + onImage: this.context?.onImage, + onEditable: this.context?.onEditable, + srcUrl: this.context?.mediaURL, + linkText: this.context?.linkTextStr, + linkUrl: this.context?.linkURL, + selectionText: isTextSelected ? selectionInfo.fullText : undefined, + pageUrl: this.browsingContext?.currentURI?.spec, + }; + + if (inThreadTree) { + subject.displayedFolder = folder; + subject.selectedMessages = gDBView.getSelectedMsgHdrs(); + } + + subject.context = subject; + subject.wrappedJSObject = subject; + + Services.obs.notifyObservers(subject, "on-prepare-contextmenu"); + Services.obs.notifyObservers(subject, "on-build-contextmenu"); + }, + + onMailContextMenuCommand(event) { + // If commandController handles this command, ask it to do so. + if (event.target.id in this._commands) { + commandController.doCommand(this._commands[event.target.id], event); + return; + } + if (event.target.id in this._alwaysVisibleCommands) { + commandController.doCommand( + this._alwaysVisibleCommands[event.target.id], + event + ); + return; + } + + switch (event.target.id) { + // Links + // case "mailContext-openInBrowser": + // this._openInBrowser(); + // break; + case "mailContext-openLinkInBrowser": + // Only called in about:message. + top.openLinkExternally(this.context.linkURL); + break; + case "mailContext-copylink": + goDoCommand("cmd_copyLink"); + break; + case "mailContext-savelink": + top.saveURL( + this.context.linkURL, // URL + null, // originalURL + this.context.linkTextStr, // fileName + null, // filePickerTitleKey + true, // shouldBypassCache + false, // skipPrompt + null, // referrerInfo + null, // cookieJarSettings + this.browsingContext.window.document, // sourceDocument + null, // isContentWindowPrivate, + Services.scriptSecurityManager.getSystemPrincipal() // principal + ); + break; + case "mailContext-reportPhishingURL": + PhishingDetector.reportPhishingURL(this.context.linkURL); + break; + case "mailContext-addemail": + top.addEmail(this.context.linkURL); + break; + case "mailContext-composeemailto": + top.composeEmailTo( + this.context.linkURL, + gFolder + ? MailServices.accounts.getFirstIdentityForServer(gFolder.server) + : null + ); + break; + case "mailContext-copyemail": { + let addresses = top.getEmail(this.context.linkURL); + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(addresses); + break; + } + + // Images + case "mailContext-copyimage": + goDoCommand("cmd_copyImageContents"); + break; + case "mailContext-saveimage": + top.saveURL( + this.context.imageInfo.currentSrc, // URL + null, // originalURL + this.context.linkTextStr, // fileName + "SaveImageTitle", // filePickerTitleKey + true, // shouldBypassCache + false, // skipPrompt + null, // referrerInfo + null, // cookieJarSettings + this.browsingContext.window?.document, // sourceDocument + null, // isContentWindowPrivate, + Services.scriptSecurityManager.getSystemPrincipal() // principal + ); + break; + + // Edit + case "mailContext-copy": + goDoCommand("cmd_copy"); + break; + case "mailContext-selectall": + goDoCommand("cmd_selectAll"); + break; + + // Search + case "mailContext-searchTheWeb": + top.openWebSearch(this.selectionInfo.text); + break; + + // Open messages + case "mailContext-openNewTab": + top.OpenMessageInNewTab(gDBView.hdrForFirstSelectedMessage, { + event, + viewWrapper: gViewWrapper, + }); + break; + case "mailContext-openNewWindow": + top.MsgOpenNewWindowForMessage( + gDBView.hdrForFirstSelectedMessage, + gViewWrapper + ); + break; + case "mailContext-openContainingFolder": + MailUtils.displayMessageInFolderTab(gDBView.hdrForFirstSelectedMessage); + break; + + // Move/copy/archive/convert/delete + // (Move and Copy sub-menus are handled in the default case.) + case "mailContext-copyMessageUrl": { + let message = gDBView.hdrForFirstSelectedMessage; + let server = message?.folder?.server; + + if (!server) { + return; + } + + // TODO let backend construct URL and return as attribute + let url = + server.socketType == Ci.nsMsgSocketType.SSL ? "snews://" : "news://"; + url += server.hostName + ":" + server.port + "/" + message.messageId; + + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(url); + break; + } + + // Calendar Convert sub-menu + case "mailContext-calendar-convert-event-menuitem": + top.calendarExtract.extractFromEmail( + gDBView.hdrForFirstSelectedMessage, + true + ); + break; + case "mailContext-calendar-convert-task-menuitem": + top.calendarExtract.extractFromEmail( + gDBView.hdrForFirstSelectedMessage, + false + ); + break; + + // Save/print/download + default: { + if ( + document.getElementById("mailContext-moveMenu").contains(event.target) + ) { + commandController.doCommand("cmd_moveMessage", event.target._folder); + } else if ( + document.getElementById("mailContext-copyMenu").contains(event.target) + ) { + commandController.doCommand("cmd_copyMessage", event.target._folder); + } else if ( + document + .getElementById("mailContext-decryptToFolder") + .contains(event.target) + ) { + commandController.doCommand( + "cmd_copyDecryptedTo", + event.target._folder + ); + } + break; + } + } + }, + + // Tags sub-menu + + /** + * Refresh the contents of the tag popup menu/panel. + * Used for example for appmenu/Message/Tag panel. + * + * @param {Element} parent - Parent element that will contain the menu items. + * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton". + * @param {string} [classes] - Classes to set on the menu items. + */ + _initMessageTags() { + let parent = document.getElementById("mailContext-tagpopup"); + // Remove any existing non-static items (clear tags list before rebuilding it). + // There is a separator element above the dynamically added tag elements, so + // remove dynamically added elements below the separator. + while (parent.lastElementChild.localName == "menuitem") { + parent.lastElementChild.remove(); + } + + // Create label and accesskey for the static "remove all tags" item. + let removeItem = document.getElementById("mailContext-tagRemoveAll"); + removeItem.label = messengerBundle.GetStringFromName( + "mailnews.tags.remove" + ); + + // Rebuild the list. + let message = gDBView.hdrForFirstSelectedMessage; + let currentTags = message + ? message.getStringProperty("keywords").split(" ") + : []; + let index = 1; + + for (let tagInfo of MailServices.tags.getAllTags()) { + let msgHasTag = currentTags.includes(tagInfo.key); + if (tagInfo.ordinal.includes("~AUTOTAG") && !msgHasTag) { + return; + } + + let item = document.createXULElement("menuitem"); + item.accessKey = index < 10 ? index : ""; + item.label = messengerBundle.formatStringFromName( + "mailnews.tags.format", + [item.accessKey, tagInfo.tag] + ); + item.setAttribute("type", "checkbox"); + if (msgHasTag) { + item.setAttribute("checked", "true"); + } + item.value = tagInfo.key; + item.addEventListener("command", event => + this._toggleMessageTag( + tagInfo.key, + item.getAttribute("checked") == "true" + ) + ); + if (tagInfo.color) { + item.style.color = tagInfo.color; + } + parent.appendChild(item); + + index++; + } + }, + + removeAllMessageTags() { + let selectedMessages = gDBView.getSelectedMsgHdrs(); + if (!selectedMessages.length) { + return; + } + + let messages = []; + let allKeys = MailServices.tags + .getAllTags() + .map(t => t.key) + .join(" "); + let prevHdrFolder = null; + + // This crudely handles cross-folder virtual folders with selected + // messages that spans folders, by coalescing consecutive messages in the + // selection that happen to be in the same folder. nsMsgSearchDBView does + // this better, but nsIMsgDBView doesn't handle commands with arguments, + // and untag takes a key argument. Furthermore, we only delete known tags, + // keeping other keywords like (non)junk intact. + for (let i = 0; i < selectedMessages.length; ++i) { + let msgHdr = selectedMessages[i]; + if (prevHdrFolder != msgHdr.folder) { + if (prevHdrFolder) { + prevHdrFolder.removeKeywordsFromMessages(messages, allKeys); + } + messages = []; + prevHdrFolder = msgHdr.folder; + } + messages.push(msgHdr); + } + if (prevHdrFolder) { + prevHdrFolder.removeKeywordsFromMessages(messages, allKeys); + } + }, + + _toggleMessageTag(key, addKey) { + let messages = []; + let selectedMessages = gDBView.getSelectedMsgHdrs(); + let toggler = addKey + ? "addKeywordsToMessages" + : "removeKeywordsFromMessages"; + let prevHdrFolder = null; + // this crudely handles cross-folder virtual folders with selected messages + // that spans folders, by coalescing consecutive msgs in the selection + // that happen to be in the same folder. nsMsgSearchDBView does this + // better, but nsIMsgDBView doesn't handle commands with arguments, + // and (un)tag takes a key argument. + for (let i = 0; i < selectedMessages.length; ++i) { + let msgHdr = selectedMessages[i]; + if (prevHdrFolder != msgHdr.folder) { + if (prevHdrFolder) { + prevHdrFolder[toggler](messages, key); + } + messages = []; + prevHdrFolder = msgHdr.folder; + } + messages.push(msgHdr); + } + if (prevHdrFolder) { + prevHdrFolder[toggler](messages, key); + } + }, + + /** + * Toggle the state of a message tag on the selected messages (based on the + * state of the first selected message, like for starring). + * + * @param {number} keyNumber - The number (1 through 9) associated with the tag. + */ + _toggleMessageTagKey(keyNumber) { + let msgHdr = gDBView.hdrForFirstSelectedMessage; + if (!msgHdr) { + return; + } + + let tagArray = MailServices.tags.getAllTags(); + if (keyNumber > tagArray.length) { + return; + } + + let key = tagArray[keyNumber - 1].key; + let curKeys = msgHdr.getStringProperty("keywords").split(" "); + if (msgHdr.label) { + curKeys.push("$label" + msgHdr.label); + } + let addKey = !curKeys.includes(key); + + this._toggleMessageTag(key, addKey); + }, + + addTag() { + top.openDialog( + "chrome://messenger/content/newTagDialog.xhtml", + "", + "chrome,titlebar,modal,centerscreen", + { + result: "", + okCallback: (name, color) => { + MailServices.tags.addTag(name, color, ""); + let key = MailServices.tags.getKeyForTag(name); + TagUtils.addTagToAllDocumentSheets(key, color); + + this._toggleMessageTag(key, true); + return true; + }, + } + ); + }, +}; diff --git a/comm/mail/base/content/mailCore.js b/comm/mail/base/content/mailCore.js new file mode 100644 index 0000000000..781d5449d6 --- /dev/null +++ b/comm/mail/base/content/mailCore.js @@ -0,0 +1,1063 @@ +/* -*- Mode: JS; 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/. */ + +/* + * Core mail routines used by all of the major mail windows (address book, + * 3-pane, compose and stand alone message window). + * Routines to support custom toolbars in mail windows, opening up a new window + * of a particular type all live here. + * Before adding to this file, ask yourself, is this a JS routine that is going + * to be used by all of the main mail windows? + */ + +/* import-globals-from ../../extensions/mailviews/content/msgViewPickerOverlay.js */ +/* import-globals-from customizeToolbar.js */ +/* import-globals-from utilityOverlay.js */ + +/* globals gChatTab */ // From globals chat-messenger.js +/* globals currentAttachments */ // From msgHdrView.js + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyGetter(this, "gViewSourceUtils", function () { + let scope = {}; + Services.scriptloader.loadSubScript( + "chrome://global/content/viewSourceUtils.js", + scope + ); + scope.gViewSourceUtils.viewSource = async function (aArgs) { + // Check if external view source is enabled. If so, try it. If it fails, + // fallback to internal view source. + if (Services.prefs.getBoolPref("view_source.editor.external")) { + try { + await this.openInExternalEditor(aArgs); + return; + } catch (ex) {} + } + + window.openDialog( + "chrome://messenger/content/viewSource.xhtml", + "_blank", + "all,dialog=no", + aArgs + ); + }; + return scope.gViewSourceUtils; +}); + +Object.defineProperty(this, "BrowserConsoleManager", { + get() { + let { loader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + return loader.require("devtools/client/webconsole/browser-console-manager") + .BrowserConsoleManager; + }, + configurable: true, + enumerable: true, +}); + +var gCustomizeSheet = false; + +function overlayRestoreDefaultSet() { + let toolbox = null; + if ("arguments" in window && window.arguments[0]) { + toolbox = window.arguments[0]; + } else if (window.frameElement && "toolbox" in window.frameElement) { + toolbox = window.frameElement.toolbox; + } + + let mode = toolbox.getAttribute("defaultmode"); + let align = toolbox.getAttribute("defaultlabelalign"); + let menulist = document.getElementById("modelist"); + + if (mode == "full" && align == "end") { + toolbox.setAttribute("mode", "textbesideicon"); + toolbox.setAttribute("labelalign", align); + overlayUpdateToolbarMode("textbesideicon"); + } else if (mode == "full" && align == "") { + toolbox.setAttribute("mode", "full"); + toolbox.removeAttribute("labelalign"); + overlayUpdateToolbarMode(mode); + } + + restoreDefaultSet(); + + if (mode == "full" && align == "end") { + menulist.value = "textbesideicon"; + } +} + +function overlayUpdateToolbarMode(aModeValue) { + let toolbox = null; + if ("arguments" in window && window.arguments[0]) { + toolbox = window.arguments[0]; + } else if (window.frameElement && "toolbox" in window.frameElement) { + toolbox = window.frameElement.toolbox; + } + + // If they chose a mode of textbesideicon or full, + // then map that to a mode of full, and a labelalign of true or false. + if (aModeValue == "textbesideicon" || aModeValue == "full") { + var align = aModeValue == "textbesideicon" ? "end" : "bottom"; + toolbox.setAttribute("labelalign", align); + Services.xulStore.persist(toolbox, "labelalign"); + aModeValue = "full"; + } + updateToolbarMode(aModeValue); +} + +function overlayOnLoad() { + let restoreButton = document + .getElementById("main-box") + .querySelector("[oncommand*='restore']"); + restoreButton.setAttribute("oncommand", "overlayRestoreDefaultSet();"); + + // Add the textBesideIcon menu item if it's not already there. + let menuitem = document.getElementById("textbesideiconItem"); + if (!menuitem) { + let menulist = document.getElementById("modelist"); + let label = document + .getElementById("iconsBesideText.label") + .getAttribute("value"); + menuitem = menulist.appendItem(label, "textbesideicon"); + menuitem.id = "textbesideiconItem"; + } + + // If they have a mode of full and a labelalign of true, + // then pretend the mode is textbesideicon when populating the popup. + let toolbox = null; + if ("arguments" in window && window.arguments[0]) { + toolbox = window.arguments[0]; + } else if (window.frameElement && "toolbox" in window.frameElement) { + toolbox = window.frameElement.toolbox; + } + + let toolbarWindow = document.getElementById("CustomizeToolbarWindow"); + toolbarWindow.setAttribute("toolboxId", toolbox.id); + toolbox.setAttribute("doCustomization", "true"); + + let mode = toolbox.getAttribute("mode"); + let align = toolbox.getAttribute("labelalign"); + if (mode == "full" && align == "end") { + toolbox.setAttribute("mode", "textbesideicon"); + } + + onLoad(); + overlayRepositionDialog(); + + // Re-set and re-persist the mode, if we changed it above. + if (mode == "full" && align == "end") { + toolbox.setAttribute("mode", mode); + Services.xulStore.persist(toolbox, "mode"); + } +} + +function overlayRepositionDialog() { + // Position the dialog so it is fully visible on the screen + // (if possible) + + // Seems to be necessary to get the correct dialog height/width + window.sizeToContent(); + var wH = window.outerHeight; + var wW = window.outerWidth; + var sH = window.screen.height; + var sW = window.screen.width; + var sX = window.screenX; + var sY = window.screenY; + var sAL = window.screen.availLeft; + var sAT = window.screen.availTop; + + var nX = Math.max(Math.min(sX, sW - wW), sAL); + var nY = Math.max(Math.min(sY, sH - wH), sAT); + window.moveTo(nX, nY); +} + +function CustomizeMailToolbar(toolboxId, customizePopupId) { + if (toolboxId === "mail-toolbox" && window.tabmail) { + // Open the unified toolbar customization panel only for mail. + document.querySelector("unified-toolbar").showCustomization(); + return; + } + + // Disable the toolbar context menu items + var menubar = document.getElementById("mail-menubar"); + for (var i = 0; i < menubar.children.length; ++i) { + menubar.children[i].setAttribute("disabled", true); + } + + var customizePopup = document.getElementById(customizePopupId); + customizePopup.setAttribute("disabled", "true"); + + var toolbox = document.getElementById(toolboxId); + + var customizeURL = "chrome://messenger/content/customizeToolbar.xhtml"; + gCustomizeSheet = Services.prefs.getBoolPref( + "toolbar.customization.usesheet" + ); + + let externalToolbars = []; + if (toolbox.getAttribute("id") == "mail-toolbox") { + if ( + AppConstants.platform != "macosx" && + document.getElementById("toolbar-menubar") + ) { + externalToolbars.push(document.getElementById("toolbar-menubar")); + } + } + + if (gCustomizeSheet) { + var sheetFrame = document.getElementById("customizeToolbarSheetIFrame"); + var panel = document.getElementById("customizeToolbarSheetPopup"); + sheetFrame.hidden = false; + sheetFrame.toolbox = toolbox; + sheetFrame.panel = panel; + if (externalToolbars.length > 0) { + sheetFrame.externalToolbars = externalToolbars; + } + + // The document might not have been loaded yet, if this is the first time. + // If it is already loaded, reload it so that the onload initialization code + // re-runs. + if (sheetFrame.getAttribute("src") == customizeURL) { + sheetFrame.contentWindow.location.reload(); + } else { + sheetFrame.setAttribute("src", customizeURL); + } + + // Open the panel, but make it invisible until the iframe has loaded so + // that the user doesn't see a white flash. + panel.style.visibility = "hidden"; + toolbox.addEventListener( + "beforecustomization", + function () { + panel.style.removeProperty("visibility"); + }, + { capture: false, once: true } + ); + panel.openPopup(toolbox, "after_start", 0, 0); + } else { + var wintype = document.documentElement.getAttribute("windowtype"); + wintype = wintype.replace(/:/g, ""); + + window.openDialog( + customizeURL, + "CustomizeToolbar" + wintype, + "chrome,all,dependent", + toolbox, + externalToolbars + ); + } +} + +function MailToolboxCustomizeDone(aEvent, customizePopupId) { + if (gCustomizeSheet) { + document.getElementById("customizeToolbarSheetIFrame").hidden = true; + document.getElementById("customizeToolbarSheetPopup").hidePopup(); + } + + // Update global UI elements that may have been added or removed + + // Re-enable parts of the UI we disabled during the dialog + var menubar = document.getElementById("mail-menubar"); + for (var i = 0; i < menubar.children.length; ++i) { + menubar.children[i].setAttribute("disabled", false); + } + + var customizePopup = document.getElementById(customizePopupId); + customizePopup.removeAttribute("disabled"); + + let toolbox = document.querySelector('[doCustomization="true"]'); + if (toolbox) { + toolbox.removeAttribute("doCustomization"); + + // The GetMail button is stuck in a strange state right now, since the + // customization wrapping preserves its children, but not its initialized + // state. Fix that here. + // That is also true for the File -> "Get new messages for" menuitems in both + // menus (old and new App menu). And also Go -> Folder. + // TODO bug 904223: try to fix folderWidgets.xml to not do this. + // See Bug 520457 and Bug 534448 and Bug 709733. + // Fix Bug 565045: Only treat "Get Message Button" if it is in our toolbox + for (let popup of [ + toolbox.querySelector("#button-getMsgPopup"), + document.getElementById("menu_getAllNewMsgPopup"), + document.getElementById("appmenu_getAllNewMsgPopup"), + document.getElementById("menu_GoFolderPopup"), + document.getElementById("appmenu_GoFolderPopup"), + ]) { + if (!popup) { + continue; + } + + // .teardown() is only available here if the menu has its frame + // otherwise the folderWidgets.xml::folder-menupopup binding is not + // attached to the popup. So if it is not available, remove the items + // explicitly. Only remove elements that were generated by the binding. + if ("_teardown" in popup) { + popup._teardown(); + } else { + for (let i = popup.children.length - 1; i >= 0; i--) { + let child = popup.children[i]; + if (child.getAttribute("generated") != "true") { + continue; + } + if ("_teardown" in child) { + child._teardown(); + } + child.remove(); + } + } + } + } +} + +/** + * Sets up the menu popup that lets the user hide or display toolbars. For + * example, in the appmenu / Preferences view. Adds toolbar items to the popup + * and sets their attributes. + * + * @param {Event} event - Event causing the menu popup to appear. + * @param {string|string[]} toolboxIds - IDs of toolboxes that contain toolbars. + * @param {Element} insertPoint - Where to insert menu items. + * @param {string} elementName - What kind of menu item element to use. E.g. + * "toolbarbutton" for the appmenu. + * @param {string} classes - Classes to set on menu items. + * @param {boolean} keepOpen - If to force the menu to stay open when clicking + * on this element. + */ +function onViewToolbarsPopupShowing( + event, + toolboxIds, + insertPoint, + elementName = "menuitem", + classes, + keepOpen = false +) { + if (!Array.isArray(toolboxIds)) { + toolboxIds = [toolboxIds]; + } + + let popup = event.target.querySelector(".panel-subview-body") || event.target; + // Limit the toolbar menu entries to the first level of context menus. + if ( + popup != event.currentTarget && + event.currentTarget.tagName == "menupopup" + ) { + return; + } + + // Remove all collapsible nodes from the menu. + for (let i = popup.children.length - 1; i >= 0; --i) { + let deadItem = popup.children[i]; + + if (deadItem.hasAttribute("iscollapsible")) { + deadItem.remove(); + } + } + + // We insert menuitems before the first child if no insert point is given. + let firstMenuItem = insertPoint || popup.firstElementChild; + + for (let toolboxId of toolboxIds) { + let toolbars = []; + let toolbox = document.getElementById(toolboxId); + + if (toolbox) { + // We consider child nodes that have a toolbarname attribute. + toolbars = toolbars.concat( + Array.from(toolbox.querySelectorAll("[toolbarname]")) + ); + } + + if ( + toolboxId == "mail-toolbox" && + toolbars.every( + toolbar => toolbar.getAttribute("id") !== "toolbar-menubar" + ) + ) { + if ( + AppConstants.platform != "macosx" && + document.getElementById("toolbar-menubar") + ) { + toolbars.push(document.getElementById("toolbar-menubar")); + } + } + + for (let toolbar of toolbars) { + let toolbarName = toolbar.getAttribute("toolbarname"); + if (!toolbarName) { + continue; + } + + let menuItem = document.createXULElement(elementName); + let hidingAttribute = + toolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed"; + + menuItem.setAttribute("type", "checkbox"); + // Mark this menuitem with an iscollapsible attribute, so we + // know we can wipe it out later on. + menuItem.setAttribute("iscollapsible", true); + menuItem.setAttribute("toolbarid", toolbar.id); + menuItem.setAttribute("label", toolbarName); + menuItem.setAttribute("accesskey", toolbar.getAttribute("accesskey")); + menuItem.setAttribute( + "checked", + toolbar.getAttribute(hidingAttribute) != "true" + ); + if (classes) { + menuItem.setAttribute("class", classes); + } + if (keepOpen) { + menuItem.setAttribute("closemenu", "none"); + } + popup.insertBefore(menuItem, firstMenuItem); + + menuItem.addEventListener("command", () => { + if (toolbar.getAttribute(hidingAttribute) != "true") { + toolbar.setAttribute(hidingAttribute, "true"); + menuItem.removeAttribute("checked"); + } else { + menuItem.setAttribute("checked", true); + toolbar.removeAttribute(hidingAttribute); + } + Services.xulStore.persist(toolbar, hidingAttribute); + }); + } + } +} + +function toJavaScriptConsole() { + BrowserConsoleManager.openBrowserConsoleOrFocus(); +} + +function openAboutDebugging(hash) { + let url = "about:debugging" + (hash ? "#" + hash : ""); + document.getElementById("tabmail").openTab("contentTab", { url }); +} + +function toOpenWindowByType(inType, uri) { + var topWindow = Services.wm.getMostRecentWindow(inType); + if (topWindow) { + topWindow.focus(); + return topWindow; + } + return window.open( + uri, + "_blank", + "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar" + ); +} + +function toMessengerWindow() { + return toOpenWindowByType( + "mail:3pane", + "chrome://messenger/content/messenger.xhtml" + ); +} + +function focusOnMail(tabNo, event) { + // this is invoked by accel-<number> + var topWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (topWindow) { + topWindow.focus(); + const tabmail = document.getElementById("tabmail"); + if (tabmail.globalOverlay) { + return; + } + tabmail.selectTabByIndex(event, tabNo); + } else { + window.open( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar" + ); + } +} + +/** + * Open the address book and optionally display/edit a card. + * + * @param {?object} openArgs - Arguments to pass to the address book. + * See `externalAction` in aboutAddressBook.js for details. + * @returns {?Window} The address book's window global, if the address book was + * opened. + */ +async function toAddressBook(openArgs) { + let messengerWindow = toMessengerWindow(); + if (messengerWindow.document.readyState != "complete") { + await new Promise(resolve => { + Services.obs.addObserver( + { + observe(subject) { + if (subject == messengerWindow) { + Services.obs.removeObserver(this, "mail-tabs-session-restored"); + resolve(); + } + }, + }, + "mail-tabs-session-restored" + ); + }); + } + + if (messengerWindow.tabmail.globalOverlay) { + return null; + } + + return new Promise(resolve => { + messengerWindow.tabmail.openTab("addressBookTab", { + onLoad(event, browser) { + if (openArgs) { + browser.contentWindow.externalAction(openArgs); + } + resolve(browser.contentWindow); + }, + }); + messengerWindow.focus(); + }); +} + +/** + * Open the calendar. + */ +async function toCalendar() { + let messengerWindow = toMessengerWindow(); + if (messengerWindow.document.readyState != "complete") { + await new Promise(resolve => { + Services.obs.addObserver( + { + observe(subject) { + if (subject == messengerWindow) { + Services.obs.removeObserver(this, "mail-tabs-session-restored"); + resolve(); + } + }, + }, + "mail-tabs-session-restored" + ); + }); + } + + return new Promise(resolve => { + messengerWindow.tabmail.openTab("calendar", { + onLoad(event, browser) { + resolve(browser.contentWindow); + }, + }); + messengerWindow.focus(); + }); +} + +function showChatTab() { + let tabmail = document.getElementById("tabmail"); + if (gChatTab) { + tabmail.switchToTab(gChatTab); + } else { + tabmail.openTab("chat", {}); + } +} + +/** + * Open about:import or importDialog.xhtml. + * + * @param {"start"|"app"|"addressBook"|"calendar"|"export"} [tabId] - The tab + * to open in about:import. + */ +function toImport(tabId = "start") { + if (Services.prefs.getBoolPref("mail.import.in_new_tab")) { + let tab = toMessengerWindow().openTab("contentTab", { + url: "about:import", + onLoad(event, browser) { + if (tabId) { + browser.contentWindow.showTab(`tab-${tabId}`, true); + } + }, + }); + // Somehow DOMContentLoaded is called even when about:import is already + // open, which resets the active tab. Use setTimeout here as a workaround. + setTimeout( + () => tab.browser.contentWindow.showTab(`tab-${tabId}`, true), + 100 + ); + return; + } + window.openDialog( + "chrome://messenger/content/importDialog.xhtml", + "importDialog", + "chrome,modal,titlebar,centerscreen" + ); +} + +function toExport() { + if (Services.prefs.getBoolPref("mail.import.in_new_tab")) { + toImport("export"); + return; + } + window.openDialog( + "chrome://messenger/content/exportDialog.xhtml", + "exportDialog", + "chrome,modal,titlebar,centerscreen" + ); +} + +function toSanitize() { + let sanitizerScope = {}; + Services.scriptloader.loadSubScript( + "chrome://messenger/content/sanitize.js", + sanitizerScope + ); + sanitizerScope.Sanitizer.sanitize(window); +} + +/** + * Opens the Preferences (Options) dialog. + * + * @param aPaneID ID of prefpane to select automatically. + * @param aScrollPaneTo ID of the element to scroll into view. + * @param aOtherArgs other prefpane specific arguments + */ +function openOptionsDialog(aPaneID, aScrollPaneTo, aOtherArgs) { + openPreferencesTab(aPaneID, aScrollPaneTo, aOtherArgs); +} + +function openAddonsMgr(aView) { + return new Promise(resolve => { + let emWindow; + let browserWindow; + + let receivePong = function (aSubject, aTopic, aData) { + let browserWin = aSubject.browsingContext.topChromeWindow; + if (!emWindow || browserWin == window /* favor the current window */) { + emWindow = aSubject; + browserWindow = browserWin; + } + }; + Services.obs.addObserver(receivePong, "EM-pong"); + Services.obs.notifyObservers(null, "EM-ping"); + Services.obs.removeObserver(receivePong, "EM-pong"); + + if (emWindow) { + if (aView) { + emWindow.loadView(aView); + } + let tabmail = browserWindow.document.getElementById("tabmail"); + tabmail.switchToTab(tabmail.getBrowserForDocument(emWindow)); + emWindow.focus(); + resolve(emWindow); + return; + } + + // This must be a new load, else the ping/pong would have + // found the window above. + let tab = openContentTab("about:addons"); + // Also in `contentTabType.restoreTab` in specialTabs.js. + tab.browser.droppedLinkHandler = event => + tab.browser.contentWindow.gDragDrop.onDrop(event); + + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + Services.obs.removeObserver(observer, aTopic); + if (aView) { + aSubject.loadView(aView); + } + aSubject.focus(); + resolve(aSubject); + }, "EM-loaded"); + }); +} + +function openActivityMgr() { + Cc["@mozilla.org/activity-manager-ui;1"] + .getService(Ci.nsIActivityManagerUI) + .show(window); +} + +/** + * Open the folder properties of current folder with the quota tab selected. + */ +function openFolderQuota() { + document + .getElementById("tabmail") + .currentAbout3Pane?.folderPane.editFolder("QuotaTab"); +} + +function openIMAccountMgr() { + var win = Services.wm.getMostRecentWindow("Messenger:Accounts"); + if (win) { + win.focus(); + } else { + win = Services.ww.openWindow( + null, + "chrome://messenger/content/chat/imAccounts.xhtml", + "Accounts", + "chrome,resizable,centerscreen", + null + ); + } + return win; +} + +function openIMAccountWizard() { + const kFeatures = "chrome,centerscreen,modal,titlebar"; + const kUrl = "chrome://messenger/content/chat/imAccountWizard.xhtml"; + const kName = "IMAccountWizard"; + + if (AppConstants.platform == "macosx") { + // On Mac, avoid using the hidden window as a parent as that would + // make it visible. + let hiddenWindowUrl = Services.prefs.getCharPref( + "browser.hiddenWindowChromeURL" + ); + if (window.location.href == hiddenWindowUrl) { + Services.ww.openWindow(null, kUrl, kName, kFeatures, null); + return; + } + } + + window.openDialog(kUrl, kName, kFeatures); +} + +function openSavedFilesWnd() { + if (window.tabmail?.globalOverlay) { + return Promise.resolve(); + } + return openContentTab("about:downloads"); +} + +function SetBusyCursor(window, enable) { + // setCursor() is only available for chrome windows. + // However one of our frames is the start page which + // is a non-chrome window, so check if this window has a + // setCursor method + if ("setCursor" in window) { + if (enable) { + window.setCursor("progress"); + } else { + window.setCursor("auto"); + } + } + + var numFrames = window.frames.length; + for (var i = 0; i < numFrames; i++) { + SetBusyCursor(window.frames[i], enable); + } +} + +function openAboutDialog() { + for (let win of Services.wm.getEnumerator("Mail:About")) { + // Only open one about window + win.focus(); + return; + } + + let features = "chrome,centerscreen,"; + if (AppConstants.platform == "win") { + features += "dependent"; + } else if (AppConstants.platform == "macosx") { + features += "resizable=no,minimizable=no"; + } else { + features += "dependent,dialog=no"; + } + + window.openDialog( + "chrome://messenger/content/aboutDialog.xhtml", + "About", + features + ); +} + +/** + * Opens the support page based on the app.support.baseURL pref. + */ +function openSupportURL() { + openFormattedURL("app.support.baseURL"); +} + +/** + * Fetches the url for the passed in pref name, formats it and then loads it in the default + * browser. + * + * @param aPrefName - name of the pref that holds the url we want to format and open + */ +function openFormattedURL(aPrefName) { + var urlToOpen = Services.urlFormatter.formatURLPref(aPrefName); + + var uri = Services.io.newURI(urlToOpen); + + var protocolSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + protocolSvc.loadURI(uri); +} + +/** + * Opens the Troubleshooting page in a new tab. + */ +function openAboutSupport() { + let mailWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (mailWindow) { + mailWindow.focus(); + mailWindow.document.getElementById("tabmail").openTab("contentTab", { + url: "about:support", + }); + return; + } + + window.openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,dialog=no,all", + null, + { + tabType: "contentTab", + tabParams: { url: "about:support" }, + } + ); +} + +/** + * Prompt the user to restart the browser in safe mode. + */ +function safeModeRestart() { + // Is TB in safe mode? + if (Services.appinfo.inSafeMode) { + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + + if (cancelQuit.data) { + return; + } + + Services.startup.quit( + Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit + ); + return; + } + // prompt the user to confirm + let bundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + let promptTitle = bundle.GetStringFromName( + "troubleshootModeRestartPromptTitle" + ); + let promptMessage = bundle.GetStringFromName( + "troubleshootModeRestartPromptMessage" + ); + let restartText = bundle.GetStringFromName("troubleshootModeRestartButton"); + let buttonFlags = + Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL + + Services.prompt.BUTTON_POS_0_DEFAULT; + + let rv = Services.prompt.confirmEx( + window, + promptTitle, + promptMessage, + buttonFlags, + restartText, + null, + null, + null, + {} + ); + if (rv == 0) { + Services.env.set("MOZ_SAFE_MODE_RESTART", "1"); + let { MailUtils } = ChromeUtils.import("resource:///modules/MailUtils.jsm"); + MailUtils.restartApplication(); + } +} + +function getMostRecentMailWindow() { + let win = null; + + win = Services.wm.getMostRecentWindow("mail:3pane", true); + + // If we're lucky, this isn't a popup, and we can just return this. + if (win && win.document.documentElement.getAttribute("chromehidden")) { + win = null; + // This is oldest to newest, so this gets a bit ugly. + for (let nextWin of Services.wm.getEnumerator("mail:3pane", true)) { + if (!nextWin.document.documentElement.getAttribute("chromehidden")) { + win = nextWin; + } + } + } + + return win; +} + +/** + * Create a sanitized display name for an attachment in order to help prevent + * people from hiding malicious extensions behind a run of spaces, etc. To do + * this, we strip leading/trailing whitespace and collapse long runs of either + * whitespace or identical characters. Windows especially will drop trailing + * dots and whitespace from filename extensions. + * + * @param aAttachment the AttachmentInfo object + * @returns a sanitized display name for the attachment + */ +function SanitizeAttachmentDisplayName(aAttachment) { + let displayName = aAttachment.name.trim().replace(/\s+/g, " "); + if (AppConstants.platform == "win") { + displayName = displayName.replace(/[ \.]+$/, ""); + } + return displayName.replace(/(.)\1{9,}/g, "$1…$1"); +} + +/** + * Appends a dataTransferItem to the associated event for message attachments, + * either from the message reader or the composer. + * + * @param {Event} event - The associated event. + * @param {nsIMsgAttachment[]} attachments - The attachments to setup + */ +function setupDataTransfer(event, attachments) { + let index = 0; + for (let attachment of attachments) { + if (attachment.contentType == "text/x-moz-deleted") { + return; + } + + let name = attachment.name || attachment.displayName; + + if (!attachment.url || !name) { + continue; + } + + // Only add type/filename info for non-file URLs that don't already + // have it. + let info = []; + if (/(^file:|&filename=)/.test(attachment.url)) { + info.push(attachment.url); + } else { + info.push( + attachment.url + + "&type=" + + attachment.contentType + + "&filename=" + + encodeURIComponent(name) + ); + } + info.push(name, attachment.size, attachment.contentType, attachment.uri); + if (attachment.sendViaCloud) { + info.push(attachment.cloudFileAccountKey, attachment.cloudPartHeaderData); + } + + event.dataTransfer.mozSetDataAt("text/x-moz-url", info.join("\n"), index); + event.dataTransfer.mozSetDataAt( + "text/x-moz-url-data", + attachment.url, + index + ); + event.dataTransfer.mozSetDataAt("text/x-moz-url-desc", name, index); + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-url", + attachment.url, + index + ); + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise", + new nsFlavorDataProvider(), + index + ); + event.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-dest-filename", + name.replace(/(.{74}).*(.{10})$/u, "$1...$2"), + index + ); + index++; + } +} + +/** + * Checks if Thunderbird was launched in safe mode and updates the menu items. + */ +function updateTroubleshootMenuItem() { + if (Services.appinfo.inSafeMode) { + let safeMode = document.getElementById("helpTroubleshootMode"); + document.l10n.setAttributes(safeMode, "menu-help-exit-troubleshoot-mode"); + + let appSafeMode = document.getElementById("appmenu_troubleshootMode"); + if (appSafeMode) { + document.l10n.setAttributes( + appSafeMode, + "appmenu-help-exit-troubleshoot-mode2" + ); + } + } +} + +function nsFlavorDataProvider() {} + +nsFlavorDataProvider.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]), + + getFlavorData(aTransferable, aFlavor, aData) { + // get the url for the attachment + if (aFlavor == "application/x-moz-file-promise") { + var urlPrimitive = {}; + aTransferable.getTransferData( + "application/x-moz-file-promise-url", + urlPrimitive + ); + + var srcUrlPrimitive = urlPrimitive.value.QueryInterface( + Ci.nsISupportsString + ); + + // now get the destination file location from kFilePromiseDirectoryMime + var dirPrimitive = {}; + aTransferable.getTransferData( + "application/x-moz-file-promise-dir", + dirPrimitive + ); + var destDirectory = dirPrimitive.value.QueryInterface(Ci.nsIFile); + + // now save the attachment to the specified location + // XXX: we need more information than just the attachment url to save it, + // fortunately, we have an array of all the current attachments so we can + // cheat and scan through them + + var attachment = null; + for (let index of currentAttachments.keys()) { + attachment = currentAttachments[index]; + if (attachment.url == srcUrlPrimitive) { + break; + } + } + + // call our code for saving attachments + if (attachment) { + let messenger = Cc["@mozilla.org/messenger;1"].createInstance( + Ci.nsIMessenger + ); + let name = attachment.name || attachment.displayName; + let destFilePath = messenger.saveAttachmentToFolder( + attachment.contentType, + attachment.url, + name.replace(/(.{74}).*(.{10})$/u, "$1...$2"), + attachment.uri, + destDirectory + ); + aData.value = destFilePath.QueryInterface(Ci.nsISupports); + } + if (AppConstants.platform == "macosx") { + // Workaround dnd of multiple attachments creating duplicates. See bug 1494588. + aTransferable.removeDataFlavor("application/x-moz-file-promise"); + } + } + }, +}; diff --git a/comm/mail/base/content/mailTabs.js b/comm/mail/base/content/mailTabs.js new file mode 100644 index 0000000000..e805eb8afb --- /dev/null +++ b/comm/mail/base/content/mailTabs.js @@ -0,0 +1,390 @@ +/* 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 mail3PaneWindowCommands.js */ +/* import-globals-from mailWindowOverlay.js */ +/* import-globals-from messenger.js */ + +/* globals contentProgress, statusFeedback */ // From mailWindow.js + +XPCOMUtils.defineLazyModuleGetters(this, { + FolderUtils: "resource:///modules/FolderUtils.jsm", + GlodaSyntheticView: "resource:///modules/gloda/GlodaSyntheticView.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + MsgHdrSyntheticView: "resource:///modules/MsgHdrSyntheticView.jsm", + MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm", +}); + +/** + * Tabs for displaying mail folders and messages. + */ +var mailTabType = { + name: "mailTab", + perTabPanel: "vbox", + _cloneTemplate(template, tab, onDOMContentLoaded, onLoad) { + let tabmail = document.getElementById("tabmail"); + + let clone = document.getElementById(template).content.cloneNode(true); + let browser = clone.querySelector("browser"); + browser.id = `${tab.mode.name}Browser${tab.mode._nextId}`; + browser.addEventListener( + "DOMTitleChanged", + () => { + tab.title = browser.contentTitle; + tabmail.setTabTitle(tab); + }, + true + ); + let linkRelIconHandler = event => { + if (event.target.rel != "icon") { + return; + } + // Allow 3pane and message tab to set a tab favicon. Mail content should + // not be allowed to do that. + if (event.target.ownerGlobal.frameElement == browser) { + tabmail.setTabFavIcon(tab, event.target.href); + } + }; + browser.addEventListener("DOMLinkAdded", linkRelIconHandler); + browser.addEventListener("DOMLinkChanged", linkRelIconHandler); + if (onDOMContentLoaded) { + browser.addEventListener( + "DOMContentLoaded", + event => { + if (!tab.closed) { + onDOMContentLoaded(event.target.ownerGlobal); + } + }, + { capture: true, once: true } + ); + } + browser.addEventListener( + "load", + event => { + if (!tab.closed) { + onLoad(event.target.ownerGlobal); + } + }, + { capture: true, once: true } + ); + + tab.title = ""; + tab.panel.id = `${tab.mode.name}${tab.mode._nextId}`; + tab.panel.appendChild(clone); + // `chromeBrowser` refers to the outermost browser in the tab, i.e. the + // browser displaying about:3pane or about:message. + tab.chromeBrowser = browser; + tab.mode._nextId++; + }, + + closeTab(tab) {}, + saveTabState(tab) {}, + + modes: { + mail3PaneTab: { + _nextId: 1, + isDefault: true, + + openTab(tab, args = {}) { + mailTabType._cloneTemplate( + "mail3PaneTabTemplate", + tab, + win => { + // Send the state to the page so it can restore immediately. + win.openingState = args; + }, + async win => { + win.tabOrWindow = tab; + // onLoad has happened. async activities of scripts running of + // that may not have finished. Let's go back to the end of the + // event queue giving win.messageBrowser time to get defined. + await new Promise(resolve => win.setTimeout(resolve)); + win.messageBrowser.contentWindow.tabOrWindow = tab; + if (!args.background) { + // Update telemetry once the tab has loaded and decided if the + // panes are visible. + Services.telemetry.keyedScalarSet( + "tb.ui.configuration.pane_visibility", + "folderPane", + win.paneLayout.folderPaneVisible + ); + Services.telemetry.keyedScalarSet( + "tb.ui.configuration.pane_visibility", + "messagePane", + win.paneLayout.messagePaneVisible + ); + } + + // The first tab has loaded and ready for the user to interact with + // it. We can let the rest of the start-up happen now without + // appearing to slow the program down. + if (tab.first) { + Services.obs.notifyObservers(window, "mail-startup-done"); + requestIdleCallback(function () { + if (!window.closed) { + Services.obs.notifyObservers( + window, + "mail-idle-startup-tasks-finished" + ); + } + }); + } + } + ); + + // `browser` and `linkedBrowser` refer to the message display browser + // within this tab. They may be null if the browser isn't visible. + // Extension APIs refer to these properties. + Object.defineProperty(tab, "browser", { + get() { + if (!tab.chromeBrowser.contentWindow) { + return null; + } + + const { messageBrowser, webBrowser } = + tab.chromeBrowser.contentWindow; + if (messageBrowser && !messageBrowser.hidden) { + return messageBrowser.contentDocument.getElementById( + "messagepane" + ); + } + if (webBrowser && !webBrowser.hidden) { + return webBrowser; + } + + return null; + }, + }); + Object.defineProperty(tab, "linkedBrowser", { + get() { + return tab.browser; + }, + }); + + // Content properties. + Object.defineProperty(tab, "message", { + get() { + let dbView = tab.chromeBrowser.contentWindow.gDBView; + if (dbView?.selection?.count) { + return dbView.hdrForFirstSelectedMessage; + } + return null; + }, + }); + Object.defineProperty(tab, "folder", { + get() { + return tab.chromeBrowser.contentWindow.gFolder; + }, + set(folder) { + tab.chromeBrowser.contentWindow.displayFolder(folder.URI); + }, + }); + + tab.canClose = !tab.first; + return tab; + }, + persistTab(tab) { + if (!tab.folder) { + return null; + } + return { + firstTab: tab.first, + folderPaneVisible: + tab.chromeBrowser.contentWindow.paneLayout.folderPaneVisible, + folderURI: tab.folder.URI, + messagePaneVisible: + tab.chromeBrowser.contentWindow.paneLayout.messagePaneVisible, + }; + }, + restoreTab(tabmail, persistedState) { + if (!persistedState.firstTab) { + tabmail.openTab("mail3PaneTab", persistedState); + return; + } + + // Manually call onTabRestored, since it is usually called by openTab(), + // which is skipped for the first tab. + let restoreState = tabmail._restoringTabState; + if (restoreState) { + for (let tabMonitor of tabmail.tabMonitors) { + try { + if ( + "onTabRestored" in tabMonitor && + restoreState && + tabMonitor.monitorName in restoreState.ext + ) { + tabMonitor.onTabRestored( + tabmail.tabInfo[0], + restoreState.ext[tabMonitor.monitorName], + false + ); + } + } catch (ex) { + console.error(ex); + } + } + } + + let { chromeBrowser, closed } = tabmail.tabInfo[0]; + if ( + chromeBrowser.contentDocument.readyState == "complete" && + chromeBrowser.currentURI.spec == "about:3pane" + ) { + chromeBrowser.contentWindow.restoreState(persistedState); + return; + } + + // Send the state to the page so it can restore immediately. Don't + // overwrite any existing state properties from `openTab` (especially + // `first`), unless there is a newer value. + let sawDOMContentLoaded = false; + chromeBrowser.addEventListener( + "DOMContentLoaded", + event => { + if (!closed && event.target == chromeBrowser.contentDocument) { + let about3Pane = event.target.ownerGlobal; + about3Pane.openingState = { + ...about3Pane.openingState, + ...persistedState, + }; + sawDOMContentLoaded = true; + } + }, + { capture: true, once: true } + ); + // Didn't see DOMContentLoaded? Restore the state on load. The state + // from `openTab` has been used by now. + chromeBrowser.addEventListener( + "load", + event => { + if ( + !closed && + !sawDOMContentLoaded && + event.target == chromeBrowser.contentDocument + ) { + chromeBrowser.contentWindow.restoreState(persistedState); + } + }, + { capture: true, once: true } + ); + }, + showTab(tab) { + if ( + tab.chromeBrowser.currentURI.spec != "about:3pane" || + tab.chromeBrowser.contentDocument.readyState != "complete" + ) { + return; + } + + // Update telemetry when switching to a 3-pane tab. The telemetry + // reflects the state of the last 3-pane tab that was shown, but not + // if the state changed since it was shown. + Services.telemetry.keyedScalarSet( + "tb.ui.configuration.pane_visibility", + "folderPane", + tab.chromeBrowser.contentWindow.paneLayout.folderPaneVisible + ); + Services.telemetry.keyedScalarSet( + "tb.ui.configuration.pane_visibility", + "messagePane", + tab.chromeBrowser.contentWindow.paneLayout.messagePaneVisible + ); + }, + supportsCommand(command, tab) { + return tab.chromeBrowser?.contentWindow.commandController?.supportsCommand( + command + ); + }, + isCommandEnabled(command, tab) { + return tab.chromeBrowser?.contentWindow.commandController?.isCommandEnabled( + command + ); + }, + doCommand(command, tab, ...args) { + tab.chromeBrowser?.contentWindow.commandController?.doCommand( + command, + ...args + ); + }, + getBrowser(tab) { + return tab.browser; + }, + }, + mailMessageTab: { + _nextId: 1, + openTab(tab, { messageURI, viewWrapper } = {}) { + mailTabType._cloneTemplate( + "mailMessageTabTemplate", + tab, + win => { + // Make tabmail give the message pane focus when this tab becomes + // the active tab. + tab.lastActiveElement = tab.browser; + }, + win => { + win.tabOrWindow = tab; + win.displayMessage(messageURI, viewWrapper); + } + ); + + // `browser` and `linkedBrowser` refer to the message display browser + // within this tab. They may be null if the browser isn't visible. + // Extension APIs refer to these properties. + Object.defineProperty(tab, "browser", { + get() { + return tab.chromeBrowser.contentDocument?.getElementById( + "messagepane" + ); + }, + }); + Object.defineProperty(tab, "linkedBrowser", { + get() { + return tab.browser; + }, + }); + + // Content properties. + Object.defineProperty(tab, "message", { + get() { + return tab.chromeBrowser.contentWindow.gMessage; + }, + }); + Object.defineProperty(tab, "folder", { + get() { + return tab.chromeBrowser.contentWindow.gViewWrapper + ?.displayedFolder; + }, + }); + + return tab; + }, + persistTab(tab) { + return { messageURI: tab.chromeBrowser.contentWindow.gMessageURI }; + }, + restoreTab(tabmail, persistedState) { + tabmail.openTab("mailMessageTab", persistedState); + }, + showTab(tab) {}, + supportsCommand(command, tab) { + return tab.chromeBrowser?.contentWindow.commandController?.supportsCommand( + command + ); + }, + isCommandEnabled(command, tab) { + return tab.chromeBrowser.contentWindow.commandController?.isCommandEnabled( + command + ); + }, + doCommand(command, tab, ...args) { + tab.chromeBrowser?.contentWindow.commandController?.doCommand( + command, + ...args + ); + }, + getBrowser(tab) { + return tab.browser; + }, + }, + }, +}; diff --git a/comm/mail/base/content/mailWindow.js b/comm/mail/base/content/mailWindow.js new file mode 100644 index 0000000000..106678ec56 --- /dev/null +++ b/comm/mail/base/content/mailWindow.js @@ -0,0 +1,1153 @@ +/** + * 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 ../../../../toolkit/content/contentAreaUtils.js */ +/* import-globals-from ../../../../toolkit/content/viewZoomOverlay.js */ +/* import-globals-from globalOverlay.js */ +/* import-globals-from mail-offline.js */ +/* import-globals-from mailCore.js */ +/* import-globals-from mailWindowOverlay.js */ +/* import-globals-from messenger.js */ +/* import-globals-from utilityOverlay.js */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +XPCOMUtils.defineLazyModuleGetters(this, { + appIdleManager: "resource:///modules/AppIdleManager.jsm", + Gloda: "resource:///modules/gloda/GlodaPublic.jsm", + UIDensity: "resource:///modules/UIDensity.jsm", +}); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PrintUtils", + "chrome://messenger/content/printUtils.js" +); + +// This file stores variables common to mail windows +var messenger; +var statusFeedback; +var msgWindow; + +UIDensity.registerWindow(window); + +/** + * Called by messageWindow.xhtml:onunload, the 'single message display window'. + * + * Also called by messenger.xhtml:onunload's (the 3-pane window inside of tabs + * window) unload function, OnUnloadMessenger. + */ +function OnMailWindowUnload() { + MailOfflineMgr.uninit(); + + // all dbview closing is handled by OnUnloadMessenger for the 3-pane (it closes + // the tabs which close their views) and OnUnloadMessageWindow for the + // standalone message window. + + MailServices.mailSession.RemoveMsgWindow(msgWindow); + // the tabs have the FolderDisplayWidget close their 'messenger' instances for us + + window.browserDOMWindow = null; + + msgWindow.closeWindow(); + + msgWindow.notificationCallbacks = null; + window.MsgStatusFeedback.unload(); + Cc["@mozilla.org/activity-manager;1"] + .getService(Ci.nsIActivityManager) + .removeListener(window.MsgStatusFeedback); +} + +/** + * When copying/dragging, convert imap/mailbox URLs of images into data URLs so + * that the images can be accessed in a paste elsewhere. + */ +function onCopyOrDragStart(e) { + let browser = getBrowser(); + if (!browser) { + return; + } + + // We're only interested if this is in the message content. + let sourceDoc = browser.contentDocument; + if (e.target.ownerDocument != sourceDoc) { + return; + } + let sourceURL = sourceDoc.URL; + let protocol = sourceURL.substr(0, sourceURL.indexOf(":")).toLowerCase(); + if ( + !( + Services.io.getProtocolHandler(protocol) instanceof + Ci.nsIMsgMessageFetchPartService + ) + ) { + // Can't fetch parts, not a message protocol, don't process. + return; + } + + let imgMap = new Map(); // Mapping img.src -> dataURL. + + // For copy, the data of what is to be copied is not accessible at this point. + // Figure out what images are a) part of the selection and b) visible in + // the current document. If their source isn't http or data already, convert + // them to data URLs. + + let selection = sourceDoc.getSelection(); + let draggedImg = selection.isCollapsed ? e.target : null; + for (let img of sourceDoc.images) { + if (/^(https?|data):/.test(img.src)) { + continue; + } + + if (img.naturalWidth == 0) { + // Broken/inaccessible image then... + continue; + } + + if (!draggedImg && !selection.containsNode(img, true)) { + continue; + } + + let style = window.getComputedStyle(img); + if (style.display == "none" || style.visibility == "hidden") { + continue; + } + + // Do not convert if the image is specifically flagged to not snarf. + if (img.getAttribute("moz-do-not-send") == "true") { + continue; + } + + // We don't need to wait for the image to load. If it isn't already loaded + // in the source document, we wouldn't want it anyway. + let canvas = sourceDoc.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext("2d").drawImage(img, 0, 0, img.width, img.height); + + let type = /\.jpe?g$/i.test(img.src) ? "image/jpg" : "image/png"; + imgMap.set(img.src, canvas.toDataURL(type)); + } + + if (imgMap.size == 0) { + // Nothing that needs converting! + return; + } + + let clonedSelection = draggedImg + ? draggedImg.cloneNode(false) + : selection.getRangeAt(0).cloneContents(); + let div = sourceDoc.createElement("div"); + div.appendChild(clonedSelection); + + let images = div.querySelectorAll("img"); + for (let img of images) { + if (!imgMap.has(img.src)) { + continue; + } + img.src = imgMap.get(img.src); + } + + let html = div.innerHTML; + let parserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils + ); + let plain = parserUtils.convertToPlainText( + html, + Ci.nsIDocumentEncoder.OutputForPlainTextClipboardCopy, + 0 + ); + if ("clipboardData" in e) { + // copy + e.clipboardData.setData("text/html", html); + e.clipboardData.setData("text/plain", plain); + e.preventDefault(); + } else if ("dataTransfer" in e) { + // drag + e.dataTransfer.setData("text/html", html); + e.dataTransfer.setData("text/plain", plain); + } +} + +function CreateMailWindowGlobals() { + // Create message window object + // eslint-disable-next-line no-global-assign + msgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance( + Ci.nsIMsgWindow + ); + // get the messenger instance + // eslint-disable-next-line no-global-assign + messenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger); + messenger.setWindow(window, msgWindow); + + window.addEventListener("blur", appIdleManager.onBlur); + window.addEventListener("focus", appIdleManager.onFocus); + + // Create windows status feedback + // set the JS implementation of status feedback before creating the c++ one.. + window.MsgStatusFeedback = new nsMsgStatusFeedback(); + // double register the status feedback object as the xul browser window implementation + window + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .treeOwner.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.MsgStatusFeedback; + + window.browserDOMWindow = new nsBrowserAccess(); + + // eslint-disable-next-line no-global-assign + statusFeedback = Cc["@mozilla.org/messenger/statusfeedback;1"].createInstance( + Ci.nsIMsgStatusFeedback + ); + statusFeedback.setWrappedStatusFeedback(window.MsgStatusFeedback); + + Cc["@mozilla.org/activity-manager;1"] + .getService(Ci.nsIActivityManager) + .addListener(window.MsgStatusFeedback); +} + +function toggleCaretBrowsing() { + const enabledPref = "accessibility.browsewithcaret_shortcut.enabled"; + const warnPref = "accessibility.warn_on_browsewithcaret"; + const caretPref = "accessibility.browsewithcaret"; + + if (!Services.prefs.getBoolPref(enabledPref)) { + return; + } + + let useCaret = Services.prefs.getBoolPref(caretPref, false); + let warn = Services.prefs.getBoolPref(warnPref, true); + if (!warn || useCaret) { + // Toggle immediately. + try { + Services.prefs.setBoolPref(caretPref, !useCaret); + } catch (ex) {} + return; + } + + // Async prompt. + document.l10n + .formatValues([ + { id: "caret-browsing-prompt-title" }, + { id: "caret-browsing-prompt-text" }, + { id: "caret-browsing-prompt-check-text" }, + ]) + .then(([title, promptText, checkText]) => { + let checkValue = { value: false }; + + useCaret = + 0 === + Services.prompt.confirmEx( + window, + title, + promptText, + Services.prompt.STD_YES_NO_BUTTONS | + Services.prompt.BUTTON_POS_1_DEFAULT, + null, + null, + null, + checkText, + checkValue + ); + + if (checkValue.value) { + if (useCaret) { + try { + Services.prefs.setBoolPref(warnPref, false); + } catch (ex) {} + } else { + try { + Services.prefs.setBoolPref(enabledPref, false); + } catch (ex) {} + } + } + try { + Services.prefs.setBoolPref(caretPref, useCaret); + } catch (ex) {} + }); +} + +function InitMsgWindow() { + // Set the domWindow before setting the status feedback object. + msgWindow.domWindow = window; + msgWindow.statusFeedback = statusFeedback; + MailServices.mailSession.AddMsgWindow(msgWindow); + msgWindow.rootDocShell.allowAuth = true; + msgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL; + // Ensure we don't load xul error pages into the main window + msgWindow.rootDocShell.useErrorPages = false; + + document.addEventListener("dragstart", onCopyOrDragStart, true); + + let keypressListener = { + handleEvent: event => { + if (event.defaultPrevented) { + return; + } + + switch (event.code) { + case "F7": + // shift + F7 is the default DevTools shortcut for the Style Editor. + if (!event.shiftKey) { + toggleCaretBrowsing(); + } + break; + } + }, + }; + Services.els.addSystemEventListener( + document, + "keypress", + keypressListener, + false + ); +} + +// We're going to implement our status feedback for the mail window in JS now. +// the following contains the implementation of our status feedback object + +function nsMsgStatusFeedback() { + this._statusText = document.getElementById("statusText"); + this._statusPanel = document.getElementById("statusbar-display"); + this._progressBar = document.getElementById("statusbar-icon"); + this._progressBarContainer = document.getElementById( + "statusbar-progresspanel" + ); + this._throbber = document.getElementById("throbber-box"); + this._activeProcesses = []; + + // make sure the stop button is accurate from the get-go + goUpdateCommand("cmd_stop"); +} + +/** + * @implements {nsIMsgStatusFeedback} + * @implements {nsIXULBrowserWindow} + * @implements {nsIActivityMgrListener} + * @implements {nsIActivityListener} + * @implements {nsISupportsWeakReference} + */ +nsMsgStatusFeedback.prototype = { + // Document elements. + _statusText: null, + _statusPanel: null, + _progressBar: null, + _progressBarContainer: null, + _throbber: null, + + // Member variables. + _startTimeoutID: null, + _stopTimeoutID: null, + // How many start meteors have been requested. + _startRequests: 0, + _meteorsSpinning: false, + _defaultStatusText: "", + _progressBarVisible: false, + _activeProcesses: null, + _statusFeedbackProgress: -1, + _statusLastShown: 0, + _lastStatusText: null, + + // unload - call to remove links to listeners etc. + unload() { + // Remove listeners for any active processes we have hooked ourselves into. + this._activeProcesses.forEach(function (element) { + element.removeListener(this); + }, this); + }, + + // nsIXULBrowserWindow implementation. + setJSStatus(status) { + if (status.length > 0) { + this.showStatusString(status); + } + }, + + /* + * Set the statusbar display for hovered links, from browser.js. + * + * @param {String} url - The href to display. + * @param {Element} anchorElt - Element. + */ + setOverLink(url, anchorElt) { + if (url) { + url = Services.textToSubURI.unEscapeURIForUI(url); + + // Encode bidirectional formatting characters. + // (RFC 3987 sections 3.2 and 4.1 paragraph 6) + url = url.replace( + /[\u200e\u200f\u202a\u202b\u202c\u202d\u202e]/g, + encodeURIComponent + ); + } + + if (!document.getElementById("status-bar").hidden) { + this._statusText.value = url; + } else { + // Statusbar invisible: Show link in statuspanel instead. + // TODO: consider porting the Firefox implementation of LinkTargetDisplay. + this._statusPanel.label = url; + } + }, + + // Called before links are navigated to to allow us to retarget them if needed. + onBeforeLinkTraversal(originalTarget, linkURI, linkNode, isAppTab) { + return originalTarget; + }, + + // Called by BrowserParent::RecvShowTooltip, needed for tooltips in content tabs. + showTooltip(xDevPix, yDevPix, tooltip, direction, browser) { + if ( + Cc["@mozilla.org/widget/dragservice;1"] + .getService(Ci.nsIDragService) + .getCurrentSession() + ) { + return; + } + + let elt = document.getElementById("remoteBrowserTooltip"); + elt.label = tooltip; + elt.style.direction = direction; + elt.openPopupAtScreen( + xDevPix / window.devicePixelRatio, + yDevPix / window.devicePixelRatio, + false, + null + ); + }, + + // Called by BrowserParent::RecvHideTooltip, needed for tooltips in content tabs. + hideTooltip() { + let elt = document.getElementById("remoteBrowserTooltip"); + elt.hidePopup(); + }, + + getTabCount() { + let tabmail = document.getElementById("tabmail"); + // messageWindow.xhtml does not have multiple tabs. + return tabmail ? tabmail.tabs.length : 1; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIMsgStatusFeedback", + "nsIXULBrowserWindow", + "nsIActivityMgrListener", + "nsIActivityListener", + "nsISupportsWeakReference", + ]), + + // nsIMsgStatusFeedback implementation. + showStatusString(statusText) { + if (!statusText) { + statusText = this._defaultStatusText; + } else { + this._defaultStatusText = ""; + } + // Let's make sure the display doesn't flicker. + const timeBetweenDisplay = 500; + const now = Date.now(); + if (now - this._statusLastShown > timeBetweenDisplay) { + // Cancel any pending status message. The timeout is not guaranteed + // to run within timeBetweenDisplay milliseconds. + this._lastStatusText = null; + + this._statusLastShown = now; + if (this._statusText.value != statusText) { + this._statusText.value = statusText; + } + } else { + if (this._lastStatusText !== null) { + // There's already a pending display. Replace it. + this._lastStatusText = statusText; + return; + } + // Arrange for this to be shown in timeBetweenDisplay milliseconds. + this._lastStatusText = statusText; + setTimeout(() => { + if (this._lastStatusText !== null) { + this._statusLastShown = Date.now(); + if (this._statusText.value != this._lastStatusText) { + this._statusText.value = this._lastStatusText; + } + this._lastStatusText = null; + } + }, timeBetweenDisplay); + } + }, + + setStatusString(status) { + if (status.length > 0) { + this._defaultStatusText = status; + this._statusText.value = status; + } + }, + + _startMeteors() { + this._meteorsSpinning = true; + this._startTimeoutID = null; + + // Turn progress meter on. + this.updateProgress(); + + // Start the throbber. + if (this._throbber) { + this._throbber.setAttribute("busy", true); + } + + document.querySelector(".throbber")?.classList.add("busy"); + + // Update the stop button + goUpdateCommand("cmd_stop"); + }, + + startMeteors() { + this._startRequests++; + // If we don't already have a start meteor timeout pending + // and the meteors aren't spinning, then kick off a start. + if ( + !this._startTimeoutID && + !this._meteorsSpinning && + "MsgStatusFeedback" in window + ) { + this._startTimeoutID = setTimeout( + () => window.MsgStatusFeedback._startMeteors(), + 500 + ); + } + + // Since we are going to start up the throbber no sense in processing + // a stop timeout... + if (this._stopTimeoutID) { + clearTimeout(this._stopTimeoutID); + this._stopTimeoutID = null; + } + }, + + _stopMeteors() { + this.showStatusString(this._defaultStatusText); + + // stop the throbber + if (this._throbber) { + this._throbber.setAttribute("busy", false); + } + + document.querySelector(".throbber")?.classList.remove("busy"); + + this._meteorsSpinning = false; + this._stopTimeoutID = null; + + // Turn progress meter off. + this._statusFeedbackProgress = -1; + this.updateProgress(); + + // Update the stop button + goUpdateCommand("cmd_stop"); + }, + + stopMeteors() { + if (this._startRequests > 0) { + this._startRequests--; + } + + // If we are going to be starting the meteors, cancel the start. + if (this._startRequests == 0 && this._startTimeoutID) { + clearTimeout(this._startTimeoutID); + this._startTimeoutID = null; + } + + // If we have no more pending starts and we don't have a stop timeout + // already in progress AND the meteors are currently running then fire a + // stop timeout to shut them down. + if ( + this._startRequests == 0 && + !this._stopTimeoutID && + this._meteorsSpinning && + "MsgStatusFeedback" in window + ) { + this._stopTimeoutID = setTimeout( + () => window.MsgStatusFeedback._stopMeteors(), + 500 + ); + } + }, + + showProgress(percentage) { + this._statusFeedbackProgress = percentage; + this.updateProgress(); + }, + + updateProgress() { + if (this._meteorsSpinning) { + // In this function, we expect that the maximum for each progress is 100, + // i.e. we are dealing with percentages. Hence we can combine several + // processes running at the same time. + let currentProgress = 0; + let progressCount = 0; + + // For each activity that is in progress, get its status. + + this._activeProcesses.forEach(function (element) { + if ( + element.state == Ci.nsIActivityProcess.STATE_INPROGRESS && + element.percentComplete != -1 + ) { + currentProgress += element.percentComplete; + ++progressCount; + } + }); + + // Add the generic progress that's fed to the status feedback object if + // we've got one. + if (this._statusFeedbackProgress != -1) { + currentProgress += this._statusFeedbackProgress; + ++progressCount; + } + + let percentage = 0; + if (progressCount) { + percentage = currentProgress / progressCount; + } + + if (!percentage) { + this._progressBar.removeAttribute("value"); + } else { + this._progressBar.value = percentage; + this._progressBar.label = Math.round(percentage) + "%"; + } + if (!this._progressBarVisible) { + this._progressBarContainer.removeAttribute("collapsed"); + this._progressBarVisible = true; + } + } else { + // Stop the bar spinning as we're not doing anything now. + this._progressBar.value = 0; + this._progressBar.label = ""; + + if (this._progressBarVisible) { + this._progressBarContainer.collapsed = true; + this._progressBarVisible = false; + } + } + }, + + // nsIActivityMgrListener + onAddedActivity(aID, aActivity) { + // ignore Gloda activity for status bar purposes + if (aActivity.initiator == Gloda) { + return; + } + if (aActivity instanceof Ci.nsIActivityEvent) { + this.showStatusString(aActivity.displayText); + } else if (aActivity instanceof Ci.nsIActivityProcess) { + this._activeProcesses.push(aActivity); + aActivity.addListener(this); + this.startMeteors(); + } + }, + + onRemovedActivity(aID) { + this._activeProcesses = this._activeProcesses.filter(function (element) { + if (element.id == aID) { + element.removeListener(this); + this.stopMeteors(); + return false; + } + return true; + }, this); + }, + + // nsIActivityListener + onStateChanged(aActivity, aOldState) {}, + + onProgressChanged( + aActivity, + aStatusText, + aWorkUnitsCompleted, + aTotalWorkUnits + ) { + let index = this._activeProcesses.indexOf(aActivity); + + // Iterate through the list trying to find the first active process, but + // only go as far as our process. + for (var i = 0; i < index; ++i) { + if ( + this._activeProcesses[i].status == + Ci.nsIActivityProcess.STATE_INPROGRESS + ) { + break; + } + } + + // If the found activity was the same as our activity, update the status + // text. + if (i == index) { + // Use the display text if we haven't got any status text. I'm assuming + // that the status text will be generally what we want to see on the + // status bar. + this.showStatusString(aStatusText ? aStatusText : aActivity.displayText); + } + + this.updateProgress(); + }, + + onHandlerChanged(aActivity) {}, +}; + +/** + * Returns the browser element of the current tab. + * The zoom manager, view source and possibly some other functions still rely + * on the getBrowser function. + */ +function getBrowser() { + let tabmail = document.getElementById("tabmail"); + return tabmail ? tabmail.getBrowserForSelectedTab() : null; +} + +// Given the server, open the twisty and the set the selection +// on inbox of that server. +// prompt if offline. +function OpenInboxForServer(server) { + // TODO: Reimplement this or fix the caller? +} + +/** Update state of zoom type (text vs. full) menu item. */ +function UpdateFullZoomMenu() { + let cmdItem = document.getElementById("cmd_fullZoomToggle"); + cmdItem.setAttribute("checked", !ZoomManager.useFullZoom); +} + +window.addEventListener("DoZoomEnlargeBy10", event => + ZoomManager.scrollZoomEnlarge(event.target) +); + +window.addEventListener("DoZoomReduceBy10", event => + ZoomManager.scrollReduceEnlarge(event.target) +); + +function nsBrowserAccess() {} + +nsBrowserAccess.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIBrowserDOMWindow"]), + + _openURIInNewTab( + aURI, + aReferrerInfo, + aIsExternal, + aOpenWindowInfo = null, + aTriggeringPrincipal = null, + aCsp = null, + aSkipLoad = false, + aMessageManagerGroup = null + ) { + let win, needToFocusWin; + + // Try the current window. If we're in a popup, fall back on the most + // recent browser window. + if (!window.document.documentElement.getAttribute("chromehidden")) { + win = window; + } else { + win = getMostRecentMailWindow(); + needToFocusWin = true; + } + + if (!win) { + // we couldn't find a suitable window, a new one needs to be opened. + return null; + } + + let loadInBackground = Services.prefs.getBoolPref( + "browser.tabs.loadDivertedInBackground" + ); + + let tabmail = win.document.getElementById("tabmail"); + let newTab = tabmail.openTab("contentTab", { + background: loadInBackground, + csp: aCsp, + linkHandler: aMessageManagerGroup, + openWindowInfo: aOpenWindowInfo, + referrerInfo: aReferrerInfo, + skipLoad: aSkipLoad, + triggeringPrincipal: aTriggeringPrincipal, + url: aURI ? aURI.spec : "about:blank", + }); + + if (needToFocusWin || (!loadInBackground && aIsExternal)) { + win.focus(); + } + + return newTab.browser; + }, + + createContentWindow( + aURI, + aOpenWindowInfo, + aWhere, + aFlags, + aTriggeringPrincipal, + aCsp + ) { + return this.getContentWindowOrOpenURI( + null, + aOpenWindowInfo, + aWhere, + aFlags, + aTriggeringPrincipal, + aCsp, + true + ); + }, + + createContentWindowInFrame(aURI, aParams, aWhere, aFlags, aName) { + // Passing a null-URI to only create the content window, + // and pass true for aSkipLoad to prevent loading of + // about:blank + return this.getContentWindowOrOpenURIInFrame( + null, + aParams, + aWhere, + aFlags, + aName, + true + ); + }, + + openURI(aURI, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) { + if (!aURI) { + throw Components.Exception( + "openURI should only be called with a valid URI", + Cr.NS_ERROR_FAILURE + ); + } + return this.getContentWindowOrOpenURI( + aURI, + aOpenWindowInfo, + aWhere, + aFlags, + aTriggeringPrincipal, + aCsp, + false + ); + }, + + openURIInFrame(aURI, aParams, aWhere, aFlags, aName) { + return this.getContentWindowOrOpenURIInFrame( + aURI, + aParams, + aWhere, + aFlags, + aName, + false + ); + }, + + getContentWindowOrOpenURI( + aURI, + aOpenWindowInfo, + aWhere, + aFlags, + aTriggeringPrincipal, + aCsp, + aSkipLoad + ) { + if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) { + let browser = + PrintUtils.handleStaticCloneCreatedForPrint(aOpenWindowInfo); + return browser ? browser.browsingContext : null; + } + + let isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL); + + if (aOpenWindowInfo && isExternal) { + throw Components.Exception( + "nsBrowserAccess.openURI did not expect aOpenWindowInfo to be " + + "passed if the context is OPEN_EXTERNAL.", + Cr.NS_ERROR_FAILURE + ); + } + + if (isExternal && aURI && aURI.schemeIs("chrome")) { + Services.console.logStringMessage( + "use -chrome command-line option to load external chrome urls\n" + ); + return null; + } + + const ReferrerInfo = Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" + ); + + let referrerInfo; + if (aFlags & Ci.nsIBrowserDOMWindow.OPEN_NO_REFERRER) { + referrerInfo = new ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, false, null); + } else if ( + aOpenWindowInfo && + aOpenWindowInfo.parent && + aOpenWindowInfo.parent.window + ) { + referrerInfo = new ReferrerInfo( + aOpenWindowInfo.parent.window.document.referrerInfo.referrerPolicy, + true, + makeURI(aOpenWindowInfo.parent.window.location.href) + ); + } else { + referrerInfo = new ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, true, null); + } + + if (aWhere != Ci.nsIBrowserDOMWindow.OPEN_NEWTAB) { + Services.console.logStringMessage( + "Opening a URI in something other than a new tab is not supported, opening in new tab instead" + ); + } + + let browser = this._openURIInNewTab( + aURI, + referrerInfo, + isExternal, + aOpenWindowInfo, + aTriggeringPrincipal, + aCsp, + aSkipLoad, + aOpenWindowInfo?.openerBrowser?.getAttribute("messagemanagergroup") + ); + + return browser ? browser.browsingContext : null; + }, + + getContentWindowOrOpenURIInFrame( + aURI, + aParams, + aWhere, + aFlags, + aName, + aSkipLoad + ) { + if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) { + return PrintUtils.handleStaticCloneCreatedForPrint( + aParams.openWindowInfo + ); + } + + if (aWhere != Ci.nsIBrowserDOMWindow.OPEN_NEWTAB) { + Services.console.logStringMessage( + "Error: openURIInFrame can only open in new tabs or print" + ); + return null; + } + + let isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL); + + return this._openURIInNewTab( + aURI, + aParams.referrerInfo, + isExternal, + aParams.openWindowInfo, + aParams.triggeringPrincipal, + aParams.csp, + aSkipLoad, + aParams.openerBrowser?.getAttribute("messagemanagergroup") + ); + }, + + canClose() { + return true; + }, + + get tabCount() { + let tabmail = document.getElementById("tabmail"); + // messageWindow.xhtml does not have multiple tabs. + return tabmail ? tabmail.tabInfo.length : 1; + }, +}; + +/** + * Called from the extensions manager to open an add-on options XUL document. + * Only the "open in tab" option is supported, so that's what we'll do here. + */ +function switchToTabHavingURI(aURI, aOpenNew, aOpenParams = {}) { + let tabmail = document.getElementById("tabmail"); + let matchingIndex = -1; + if (tabmail) { + // about:preferences should be opened through openPreferencesTab(). + if (aURI == "about:preferences") { + openPreferencesTab(); + return true; + } + + let openURI = makeURI(aURI); + let tabInfo = tabmail.tabInfo; + + // Check if we already have the same URL open in a content tab. + for (let tabIndex = 0; tabIndex < tabInfo.length; tabIndex++) { + if (tabInfo[tabIndex].mode.name == "contentTab") { + let browserFunc = + tabInfo[tabIndex].mode.getBrowser || + tabInfo[tabIndex].mode.tabType.getBrowser; + if (browserFunc) { + let browser = browserFunc.call( + tabInfo[tabIndex].mode.tabType, + tabInfo[tabIndex] + ); + if (browser.currentURI.equals(openURI)) { + matchingIndex = tabIndex; + break; + } + } + } + } + } + + // Open the found matching tab. + if (tabmail && matchingIndex > -1) { + tabmail.switchToTab(matchingIndex); + return true; + } + + if (aOpenNew) { + tabmail.openTab("contentTab", { ...aOpenParams, url: aURI }); + } + + return false; +} + +/** + * Combines all nsIWebProgress notifications from all content browsers in this + * window and reports them to the registered listeners. + * + * @see WindowTracker (ext-mail.js) + * @see StatusListener, WindowTrackerBase (ext-tabs-base.js) + */ +var contentProgress = { + _listeners: new Set(), + + addListener(listener) { + this._listeners.add(listener); + }, + + removeListener(listener) { + this._listeners.delete(listener); + }, + + callListeners(method, args) { + for (let listener of this._listeners.values()) { + if (method in listener) { + try { + listener[method](...args); + } catch (e) { + console.error(e); + } + } + } + }, + + /** + * Ensure that `browser` has a ProgressListener attached to it. + * + * @param {Browser} browser + */ + addProgressListenerToBrowser(browser) { + if (browser?.webProgress && !browser._progressListener) { + browser._progressListener = new contentProgress.ProgressListener(browser); + browser.webProgress.addProgressListener( + browser._progressListener, + Ci.nsIWebProgress.NOTIFY_ALL + ); + } + }, + + // @implements {nsIWebProgressListener} + // @implements {nsIWebProgressListener2} + ProgressListener: class { + QueryInterface = ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsIWebProgressListener2", + "nsISupportsWeakReference", + ]); + + constructor(browser) { + this.browser = browser; + } + + callListeners(method, args) { + if (this.browser.hidden) { + // Ignore events from hidden browsers. This should avoid confusion in + // about:3pane, where multiple browsers could send events. + return; + } + args.unshift(this.browser); + contentProgress.callListeners(method, args); + } + + onProgressChange(...args) { + this.callListeners("onProgressChange", args); + } + + onProgressChange64(...args) { + this.callListeners("onProgressChange64", args); + } + + onLocationChange(...args) { + this.callListeners("onLocationChange", args); + } + + onStateChange(...args) { + this.callListeners("onStateChange", args); + } + + onStatusChange(...args) { + this.callListeners("onStatusChange", args); + } + + onSecurityChange(...args) { + this.callListeners("onSecurityChange", args); + } + + onContentBlockingEvent(...args) { + this.callListeners("onContentBlockingEvent", args); + } + + onRefreshAttempted(...args) { + return this.callListeners("onRefreshAttempted", args); + } + }, +}; + +window.addEventListener("aboutMessageLoaded", event => { + // Add a progress listener to any about:message content browser that comes + // along. This often happens after the tab is opened so the usual mechanism + // doesn't work. It also works for standalone message windows. + contentProgress.addProgressListenerToBrowser( + event.target.getMessagePaneBrowser() + ); + // Also add a copy listener so we can process images. + event.target.document.addEventListener("copy", onCopyOrDragStart, true); +}); + +// Listener to correctly set the busy flag on the webBrowser in about:3pane. All +// other content tabs are handled by tabmail.js. +contentProgress.addListener({ + onStateChange(browser, webProgress, request, stateFlags, statusCode) { + // Skip if this is not the webBrowser in about:3pane. + if (browser.id != "webBrowser") { + return; + } + let status; + if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + status = "loading"; + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + status = "complete"; + } + } else if ( + stateFlags & Ci.nsIWebProgressListener.STATE_STOP && + statusCode == Cr.NS_BINDING_ABORTED + ) { + status = "complete"; + } + browser.busy = status == "loading"; + }, +}); diff --git a/comm/mail/base/content/mailWindowOverlay.js b/comm/mail/base/content/mailWindowOverlay.js new file mode 100644 index 0000000000..449a92de07 --- /dev/null +++ b/comm/mail/base/content/mailWindowOverlay.js @@ -0,0 +1,2177 @@ +/* -*- 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/. */ + +/* global gSpacesToolbar */ + +/* import-globals-from ../../../mailnews/extensions/newsblog/newsblogOverlay.js */ +/* import-globals-from contentAreaClick.js */ +/* import-globals-from mail3PaneWindowCommands.js */ +/* import-globals-from mailCommands.js */ +/* import-globals-from mailCore.js */ + +/* import-globals-from utilityOverlay.js */ + +/* globals messenger */ // From messageWindow.js +/* globals GetSelectedMsgFolders */ // From messenger.js +/* globals MailOfflineMgr */ // From mail-offline.js + +/* globals OnTagsChange, currentHeaderData */ // TODO: these aren't real. + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + + BrowserToolboxLauncher: + "resource://devtools/client/framework/browser-toolbox/Launcher.sys.mjs", +}); +XPCOMUtils.defineLazyModuleGetters(this, { + MailUtils: "resource:///modules/MailUtils.jsm", + MimeParser: "resource:///modules/mimeParser.jsm", + UIDensity: "resource:///modules/UIDensity.jsm", + UIFontSize: "resource:///modules/UIFontSize.jsm", +}); + +Object.defineProperty(this, "BrowserConsoleManager", { + get() { + let { loader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + return loader.require("devtools/client/webconsole/browser-console-manager") + .BrowserConsoleManager; + }, + configurable: true, + enumerable: true, +}); + +// the user preference, +// if HTML is not allowed. I assume, that the user could have set this to a +// value > 1 in his prefs.js or user.js, but that the value will not +// change during runtime other than through the MsgBody*() functions below. +var gDisallow_classes_no_html = 1; + +/** + * Disable the new account menu item if the account preference is locked. + * The other affected areas are the account central, the account manager + * dialog, and the account provisioner window. + */ +function menu_new_init() { + // If the account provisioner is pref'd off, we shouldn't display the menu + // item. + ShowMenuItem( + "newCreateEmailAccountMenuItem", + Services.prefs.getBoolPref("mail.provider.enabled") + ); + + // If we don't have a folder, just get out of here and leave the menu as it is. + let folder = document.getElementById("tabmail")?.currentTabInfo.folder; + if (!folder) { + return; + } + + if (Services.prefs.prefIsLocked("mail.disable_new_account_addition")) { + document + .getElementById("newNewsgroupAccountMenuItem") + .setAttribute("disabled", "true"); + document + .getElementById("appmenu_newNewsgroupAccountMenuItem") + .setAttribute("disabled", "true"); + } + + var isInbox = folder.isSpecialFolder(Ci.nsMsgFolderFlags.Inbox); + var showNew = + (folder.canCreateSubfolders || + (isInbox && !folder.getFlag(Ci.nsMsgFolderFlags.Virtual))) && + document.getElementById("cmd_newFolder").getAttribute("disabled") != "true"; + ShowMenuItem("menu_newFolder", showNew); + ShowMenuItem("menu_newVirtualFolder", showNew); + ShowMenuItem("newAccountPopupMenuSeparator", showNew); + + EnableMenuItem( + "menu_newFolder", + folder.server.type != "imap" || MailOfflineMgr.isOnline() + ); + if (showNew) { + var bundle = document.getElementById("bundle_messenger"); + // Change "New Folder..." menu according to the context. + SetMenuItemLabel( + "menu_newFolder", + bundle.getString( + folder.isServer || isInbox + ? "newFolderMenuItem" + : "newSubfolderMenuItem" + ) + ); + } + + goUpdateCommand("cmd_newMessage"); +} + +function goUpdateMailMenuItems(commandset) { + for (var i = 0; i < commandset.children.length; i++) { + var commandID = commandset.children[i].getAttribute("id"); + if (commandID) { + goUpdateCommand(commandID); + } + } + + updateCheckedStateForIgnoreAndWatchThreadCmds(); +} + +/** + * Update the ignore (sub)thread, and watch thread commands so the menus + * using them get the checked state set up properly. + */ +function updateCheckedStateForIgnoreAndWatchThreadCmds() { + let message; + + let tab = document.getElementById("tabmail")?.currentTabInfo; + if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) { + message = tab.message; + } + + let folder = message?.folder; + + let killThreadItem = document.getElementById("cmd_killThread"); + if (folder?.msgDatabase.isIgnored(message.messageKey)) { + killThreadItem.setAttribute("checked", "true"); + } else { + killThreadItem.removeAttribute("checked"); + } + let killSubthreadItem = document.getElementById("cmd_killSubthread"); + if (folder && message.flags & Ci.nsMsgMessageFlags.Ignored) { + killSubthreadItem.setAttribute("checked", "true"); + } else { + killSubthreadItem.removeAttribute("checked"); + } + let watchThreadItem = document.getElementById("cmd_watchThread"); + if (folder?.msgDatabase.isWatched(message.messageKey)) { + watchThreadItem.setAttribute("checked", "true"); + } else { + watchThreadItem.removeAttribute("checked"); + } +} + +function file_init() { + document.commandDispatcher.updateCommands("create-menu-file"); +} + +/** + * Update the menu items visibility in the Edit submenu. + */ +function InitEditMessagesMenu() { + document.commandDispatcher.updateCommands("create-menu-edit"); + + let chromeBrowser, folderTreeActive, folder, folderIsNewsgroup; + let tab = document.getElementById("tabmail")?.currentTabInfo; + if (tab?.mode.name == "mail3PaneTab") { + chromeBrowser = tab.chromeBrowser; + folderTreeActive = + chromeBrowser.contentDocument.activeElement.id == "folderTree"; + folder = chromeBrowser.contentWindow.gFolder; + folderIsNewsgroup = folder?.server.type == "nntp"; + } else if (tab?.mode.name == "mailMessageTab") { + chromeBrowser = tab.chromeBrowser; + } else { + chromeBrowser = document.getElementById("messageBrowser"); + } + + let deleteController = getEnabledControllerForCommand("cmd_delete"); + // If the controller is a JS object, it must be one we've implemented, + // not the built-in controller for textboxes. + + let dbView = chromeBrowser?.contentWindow.gDBView; + let numSelected = dbView?.numSelected; + + let deleteMenuItem = document.getElementById("menu_delete"); + if (deleteController?.wrappedJSObject && folderTreeActive) { + let value = folderIsNewsgroup + ? "menu-edit-unsubscribe-newsgroup" + : "menu-edit-delete-folder"; + document.l10n.setAttributes(deleteMenuItem, value); + } else if (deleteController?.wrappedJSObject && numSelected) { + let message = dbView?.hdrForFirstSelectedMessage; + let value; + if (message && message.flags & Ci.nsMsgMessageFlags.IMAPDeleted) { + value = "menu-edit-undelete-messages"; + } else { + value = "menu-edit-delete-messages"; + } + document.l10n.setAttributes(deleteMenuItem, value, { count: numSelected }); + } else { + document.l10n.setAttributes(deleteMenuItem, "text-action-delete"); + } + + // Initialize the Favorite Folder checkbox in the Edit menu. + let favoriteFolderMenu = document.getElementById("menu_favoriteFolder"); + if (folder?.getFlag(Ci.nsMsgFolderFlags.Favorite)) { + favoriteFolderMenu.setAttribute("checked", "true"); + } else { + favoriteFolderMenu.removeAttribute("checked"); + } + + let propertiesController = getEnabledControllerForCommand("cmd_properties"); + let propertiesMenuItem = document.getElementById("menu_properties"); + if (tab?.mode.name == "mail3PaneTab" && propertiesController) { + let value = folderIsNewsgroup + ? "menu-edit-newsgroup-properties" + : "menu-edit-folder-properties"; + document.l10n.setAttributes(propertiesMenuItem, value); + } else { + document.l10n.setAttributes(propertiesMenuItem, "menu-edit-properties"); + } +} + +/** + * Update the menu items visibility in the Find submenu. + */ +function initSearchMessagesMenu() { + // Show 'Global Search' menu item only when global search is enabled. + let glodaEnabled = Services.prefs.getBoolPref( + "mailnews.database.global.indexer.enabled" + ); + document.getElementById("glodaSearchCmd").hidden = !glodaEnabled; +} + +function InitGoMessagesMenu() { + document.commandDispatcher.updateCommands("create-menu-go"); +} + +/** + * This is called every time the view menu popup is displayed (in the main menu + * bar or in the appmenu). It is responsible for updating the menu items' + * state to reflect reality. + */ +function view_init(event) { + if (event && event.target.id != "menu_View_Popup") { + return; + } + + let accountCentralVisible; + let folderPaneVisible; + let message; + let messagePaneVisible; + let quickFilterBarVisible; + let threadPaneHeaderVisible; + + let tab = document.getElementById("tabmail")?.currentTabInfo; + if (tab?.mode.name == "mail3PaneTab") { + let chromeBrowser; + ({ chromeBrowser, message } = tab); + let { paneLayout, quickFilterBar } = chromeBrowser.contentWindow; + ({ accountCentralVisible, folderPaneVisible, messagePaneVisible } = + paneLayout); + quickFilterBarVisible = quickFilterBar.filterer.visible; + threadPaneHeaderVisible = true; + } else if (tab?.mode.name == "mailMessageTab") { + message = tab.message; + messagePaneVisible = true; + threadPaneHeaderVisible = false; + } + + let isFeed = FeedUtils.isFeedMessage(message); + + let qfbMenuItem = document.getElementById( + "view_toolbars_popup_quickFilterBar" + ); + if (qfbMenuItem) { + qfbMenuItem.setAttribute("checked", quickFilterBarVisible); + } + + let qfbAppMenuItem = document.getElementById("appmenu_quickFilterBar"); + if (qfbAppMenuItem) { + if (quickFilterBarVisible) { + qfbAppMenuItem.setAttribute("checked", "true"); + } else { + qfbAppMenuItem.removeAttribute("checked"); + } + } + + let messagePaneMenuItem = document.getElementById("menu_showMessage"); + if (!messagePaneMenuItem.hidden) { + // Hidden in the standalone msg window. + messagePaneMenuItem.setAttribute( + "checked", + accountCentralVisible ? false : messagePaneVisible + ); + messagePaneMenuItem.disabled = accountCentralVisible; + } + + let messagePaneAppMenuItem = document.getElementById("appmenu_showMessage"); + if (messagePaneAppMenuItem && !messagePaneAppMenuItem.hidden) { + // Hidden in the standalone msg window. + messagePaneAppMenuItem.setAttribute( + "checked", + accountCentralVisible ? false : messagePaneVisible + ); + messagePaneAppMenuItem.disabled = accountCentralVisible; + } + + let folderPaneMenuItem = document.getElementById("menu_showFolderPane"); + if (!folderPaneMenuItem.hidden) { + // Hidden in the standalone msg window. + folderPaneMenuItem.setAttribute("checked", folderPaneVisible); + } + + let folderPaneAppMenuItem = document.getElementById("appmenu_showFolderPane"); + if (!folderPaneAppMenuItem.hidden) { + // Hidden in the standalone msg window. + folderPaneAppMenuItem.setAttribute("checked", folderPaneVisible); + } + + let threadPaneMenuItem = document.getElementById( + "menu_toggleThreadPaneHeader" + ); + threadPaneMenuItem.setAttribute("disabled", !threadPaneHeaderVisible); + + let threadPaneAppMenuItem = document.getElementById( + "appmenu_toggleThreadPaneHeader" + ); + threadPaneAppMenuItem.toggleAttribute("disabled", !threadPaneHeaderVisible); + + // Disable some menus if account manager is showing + document.getElementById("viewSortMenu").disabled = accountCentralVisible; + + document.getElementById("viewMessageViewMenu").disabled = + accountCentralVisible; + + document.getElementById("viewMessagesMenu").disabled = accountCentralVisible; + + // Hide the "View > Messages" menu item if the user doesn't have the "Views" + // (aka "Mail Views") toolbar button in the main toolbar. (See bug 1563789.) + var viewsToolbarButton = window.ViewPickerBinding?.isVisible; + document.getElementById("viewMessageViewMenu").hidden = !viewsToolbarButton; + + // Initialize the Message Body menuitem + document.getElementById("viewBodyMenu").hidden = isFeed; + + // Initialize the Show Feed Summary menu + let viewFeedSummary = document.getElementById("viewFeedSummary"); + viewFeedSummary.hidden = !isFeed; + + let viewRssMenuItemIds = [ + "bodyFeedGlobalWebPage", + "bodyFeedGlobalSummary", + "bodyFeedPerFolderPref", + ]; + let checked = FeedMessageHandler.onSelectPref; + for (let [index, id] of viewRssMenuItemIds.entries()) { + document.getElementById(id).setAttribute("checked", index == checked); + } + + // Initialize the View Attachment Inline menu + var viewAttachmentInline = Services.prefs.getBoolPref( + "mail.inline_attachments" + ); + document + .getElementById("viewAttachmentsInlineMenuitem") + .setAttribute("checked", viewAttachmentInline); + + document.commandDispatcher.updateCommands("create-menu-view"); + + // No need to do anything if we don't have a spaces toolbar like in standalone + // windows or another non tabmail window. + let spacesToolbarMenu = document.getElementById("appmenu_spacesToolbar"); + if (spacesToolbarMenu) { + // Update the spaces toolbar menu items. + let isSpacesVisible = !gSpacesToolbar.isHidden; + spacesToolbarMenu.checked = isSpacesVisible; + document + .getElementById("viewToolbarsPopupSpacesToolbar") + .setAttribute("checked", isSpacesVisible); + } +} + +function initUiDensityMenu(event) { + // Prevent submenus from unnecessarily triggering onViewToolbarsPopupShowing + // via bubbling of events. + event.stopImmediatePropagation(); + + // Apply the correct mode attribute to the various items. + document.getElementById("uiDensityCompact").mode = UIDensity.MODE_COMPACT; + document.getElementById("uiDensityNormal").mode = UIDensity.MODE_NORMAL; + document.getElementById("uiDensityTouch").mode = UIDensity.MODE_TOUCH; + + // Fetch the currently active identity. + let currentDensity = UIDensity.prefValue; + + for (let item of event.target.querySelectorAll("menuitem")) { + if (item.mode == currentDensity) { + item.setAttribute("checked", "true"); + break; + } + } +} + +/** + * Assign the proper mode to the UI density controls in the App Menu and set + * the correct checked state based on the current density. + */ +function initUiDensityAppMenu() { + // Apply the correct mode attribute to the various items. + document.getElementById("appmenu_uiDensityCompact").mode = + UIDensity.MODE_COMPACT; + document.getElementById("appmenu_uiDensityNormal").mode = + UIDensity.MODE_NORMAL; + document.getElementById("appmenu_uiDensityTouch").mode = UIDensity.MODE_TOUCH; + + // Fetch the currently active identity. + let currentDensity = UIDensity.prefValue; + + for (let item of document.querySelectorAll( + "#appMenu-uiDensity-controls > toolbarbutton" + )) { + if (item.mode == currentDensity) { + item.setAttribute("checked", "true"); + } else { + item.removeAttribute("checked"); + } + } +} + +function InitViewLayoutStyleMenu(event, appmenu) { + // Prevent submenus from unnecessarily triggering onViewToolbarsPopupShowing + // via bubbling of events. + event.stopImmediatePropagation(); + let paneConfig = Services.prefs.getIntPref("mail.pane_config.dynamic"); + + let parent = appmenu + ? event.target.querySelector(".panel-subview-body") + : event.target; + + let layoutStyleMenuitem = parent.children[paneConfig]; + if (layoutStyleMenuitem) { + layoutStyleMenuitem.setAttribute("checked", "true"); + } + + if ( + Services.xulStore.getValue( + "chrome://messenger/content/messenger.xhtml", + "threadPaneHeader", + "hidden" + ) !== "true" + ) { + parent + .querySelector(`[name="threadheader"]`) + .setAttribute("checked", "true"); + } else { + parent.querySelector(`[name="threadheader"]`).removeAttribute("checked"); + } +} + +/** + * Called when showing the menu_viewSortPopup menupopup, so it should always + * be up-to-date. + */ +function InitViewSortByMenu() { + let tab = document.getElementById("tabmail")?.currentTabInfo; + if (tab?.mode.name != "mail3PaneTab") { + return; + } + + let { gViewWrapper, threadPane } = tab.chromeBrowser.contentWindow; + if (!gViewWrapper?.dbView) { + return; + } + + let { primarySortType, primarySortOrder, showGroupedBySort, showThreaded } = + gViewWrapper; + let hiddenColumns = threadPane.columns + .filter(c => c.hidden) + .map(c => c.sortKey); + + let isSortTypeValidForGrouping = [ + Ci.nsMsgViewSortType.byAccount, + Ci.nsMsgViewSortType.byAttachments, + Ci.nsMsgViewSortType.byAuthor, + Ci.nsMsgViewSortType.byCorrespondent, + Ci.nsMsgViewSortType.byDate, + Ci.nsMsgViewSortType.byFlagged, + Ci.nsMsgViewSortType.byLocation, + Ci.nsMsgViewSortType.byPriority, + Ci.nsMsgViewSortType.byReceived, + Ci.nsMsgViewSortType.byRecipient, + Ci.nsMsgViewSortType.byStatus, + Ci.nsMsgViewSortType.bySubject, + Ci.nsMsgViewSortType.byTags, + Ci.nsMsgViewSortType.byCustom, + ].includes(primarySortType); + + let setSortItemAttrs = function (id, sortKey) { + let menuItem = document.getElementById(id); + menuItem.setAttribute( + "checked", + primarySortType == Ci.nsMsgViewSortType[sortKey] + ); + if (hiddenColumns.includes(sortKey)) { + menuItem.setAttribute("disabled", "true"); + } else { + menuItem.removeAttribute("disabled"); + } + }; + + setSortItemAttrs("sortByDateMenuitem", "byDate"); + setSortItemAttrs("sortByReceivedMenuitem", "byReceived"); + setSortItemAttrs("sortByFlagMenuitem", "byFlagged"); + setSortItemAttrs("sortByOrderReceivedMenuitem", "byId"); + setSortItemAttrs("sortByPriorityMenuitem", "byPriority"); + setSortItemAttrs("sortBySizeMenuitem", "bySize"); + setSortItemAttrs("sortByStatusMenuitem", "byStatus"); + setSortItemAttrs("sortBySubjectMenuitem", "bySubject"); + setSortItemAttrs("sortByUnreadMenuitem", "byUnread"); + setSortItemAttrs("sortByTagsMenuitem", "byTags"); + setSortItemAttrs("sortByJunkStatusMenuitem", "byJunkStatus"); + setSortItemAttrs("sortByFromMenuitem", "byAuthor"); + setSortItemAttrs("sortByRecipientMenuitem", "byRecipient"); + setSortItemAttrs("sortByAttachmentsMenuitem", "byAttachments"); + setSortItemAttrs("sortByCorrespondentMenuitem", "byCorrespondent"); + + document + .getElementById("sortAscending") + .setAttribute( + "checked", + primarySortOrder == Ci.nsMsgViewSortOrder.ascending + ); + document + .getElementById("sortDescending") + .setAttribute( + "checked", + primarySortOrder == Ci.nsMsgViewSortOrder.descending + ); + + document.getElementById("sortThreaded").setAttribute("checked", showThreaded); + document + .getElementById("sortUnthreaded") + .setAttribute("checked", !showThreaded && !showGroupedBySort); + + let groupBySortOrderMenuItem = document.getElementById("groupBySort"); + groupBySortOrderMenuItem.setAttribute( + "disabled", + !isSortTypeValidForGrouping + ); + groupBySortOrderMenuItem.setAttribute("checked", showGroupedBySort); +} + +function InitViewMessagesMenu() { + let tab = document.getElementById("tabmail")?.currentTabInfo; + if (!["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) { + return; + } + + let viewWrapper = tab.chromeBrowser.contentWindow.gViewWrapper; + + document + .getElementById("viewAllMessagesMenuItem") + .setAttribute( + "checked", + !viewWrapper || (!viewWrapper.showUnreadOnly && !viewWrapper.specialView) + ); + + document + .getElementById("viewUnreadMessagesMenuItem") + .setAttribute("checked", !!viewWrapper?.showUnreadOnly); + + document + .getElementById("viewThreadsWithUnreadMenuItem") + .setAttribute("checked", !!viewWrapper?.specialViewThreadsWithUnread); + + document + .getElementById("viewWatchedThreadsWithUnreadMenuItem") + .setAttribute( + "checked", + !!viewWrapper?.specialViewWatchedThreadsWithUnread + ); + + document + .getElementById("viewIgnoredThreadsMenuItem") + .setAttribute("checked", !!viewWrapper?.showIgnored); +} + +function InitMessageMenu() { + let tab = document.getElementById("tabmail")?.currentTabInfo; + let message, folder; + let isDummy; + if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) { + ({ message, folder } = tab); + isDummy = message && !folder; + } else { + message = document.getElementById("messageBrowser")?.contentWindow.gMessage; + isDummy = !message?.folder; + } + + let isNews = message?.folder?.flags & Ci.nsMsgFolderFlags.Newsgroup; + let isFeed = message && FeedUtils.isFeedMessage(message); + + // We show reply to Newsgroups only for news messages. + document.getElementById("replyNewsgroupMainMenu").hidden = !isNews; + + // For mail messages we say reply. For news we say ReplyToSender. + document.getElementById("replyMainMenu").hidden = isNews; + document.getElementById("replySenderMainMenu").hidden = !isNews; + + document.getElementById("menu_cancel").hidden = + !isNews || !getEnabledControllerForCommand("cmd_cancel"); + + // Disable the move menu if there are no messages selected or if + // the message is a dummy - e.g. opening a message in the standalone window. + let messageStoredInternally = message && !isDummy; + // Disable the move menu if we can't delete msgs from the folder. + let canMove = + messageStoredInternally && !isNews && message.folder.canDeleteMessages; + + document.getElementById("moveMenu").disabled = !canMove; + + document.getElementById("copyMenu").disabled = !message; + + initMoveToFolderAgainMenu(document.getElementById("moveToFolderAgain")); + + // Disable the Forward As menu item if no message is selected. + document.getElementById("forwardAsMenu").disabled = !message; + + // Disable the Attachments menu if no message is selected and we don't have + // any attachment. + let aboutMessage = + document.getElementById("tabmail")?.currentAboutMessage || + document.getElementById("messageBrowser")?.contentWindow; + document.getElementById("msgAttachmentMenu").disabled = + !message || !aboutMessage?.currentAttachments.length; + + // Disable the Tag menu item if no message is selected or when we're + // not in a folder. + document.getElementById("tagMenu").disabled = !messageStoredInternally; + + // Show "Edit Draft Message" menus only in a drafts folder; otherwise hide them. + showCommandInSpecialFolder("cmd_editDraftMsg", Ci.nsMsgFolderFlags.Drafts); + // Show "New Message from Template" and "Edit Template" menus only in a + // templates folder; otherwise hide them. + showCommandInSpecialFolder( + ["cmd_newMsgFromTemplate", "cmd_editTemplateMsg"], + Ci.nsMsgFolderFlags.Templates + ); + + // Initialize the Open Message menuitem + var winType = document.documentElement.getAttribute("windowtype"); + if (winType == "mail:3pane") { + document.getElementById("openMessageWindowMenuitem").hidden = isFeed; + } + + // Initialize the Open Feed Message handler menu + let index = FeedMessageHandler.onOpenPref; + document + .getElementById("menu_openFeedMessage") + .children[index].setAttribute("checked", true); + + let openRssMenu = document.getElementById("openFeedMessage"); + openRssMenu.hidden = !isFeed; + if (winType != "mail:3pane") { + openRssMenu.hidden = true; + } + + // Disable mark menu when we're not in a folder. + document.getElementById("markMenu").disabled = !folder || folder.isServer; + + document.commandDispatcher.updateCommands("create-menu-message"); + + for (let id of ["killThread", "killSubthread", "watchThread"]) { + let item = document.getElementById(id); + let command = document.getElementById(item.getAttribute("command")); + if (command.hasAttribute("checked")) { + item.setAttribute("checked", command.getAttribute("checked")); + } else { + item.removeAttribute("checked"); + } + } +} + +/** + * Show folder-specific menu items only for messages in special folders, e.g. + * show 'cmd_editDraftMsg' in Drafts folder, or + * show 'cmd_newMsgFromTemplate' in Templates folder. + * + * aCommandIds single ID string of command or array of IDs of commands + * to be shown in folders having aFolderFlag + * aFolderFlag the nsMsgFolderFlag that the folder must have to show the command + */ +function showCommandInSpecialFolder(aCommandIds, aFolderFlag) { + let folder, message; + + let tab = document.getElementById("tabmail")?.currentTabInfo; + if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) { + ({ message, folder } = tab); + } else if (tab?.mode.tabType.name == "mail") { + ({ displayedFolder: folder, selectedMessage: message } = tab.folderDisplay); + } + + let inSpecialFolder = + message?.folder?.isSpecialFolder(aFolderFlag, true) || + (folder && folder.getFlag(aFolderFlag)); + if (typeof aCommandIds === "string") { + aCommandIds = [aCommandIds]; + } + + aCommandIds.forEach(cmdId => + document.getElementById(cmdId).setAttribute("hidden", !inSpecialFolder) + ); +} + +/** + * Initializes the menu item aMenuItem to show either "Move" or "Copy" to + * folder again, based on the value of mail.last_msg_movecopy_target_uri. + * The menu item label and accesskey are adjusted to include the folder name. + * + * @param aMenuItem the menu item to adjust + */ +function initMoveToFolderAgainMenu(aMenuItem) { + let lastFolderURI = Services.prefs.getStringPref( + "mail.last_msg_movecopy_target_uri" + ); + + if (!lastFolderURI) { + return; + } + let destMsgFolder = MailUtils.getExistingFolder(lastFolderURI); + if (!destMsgFolder) { + return; + } + let bundle = document.getElementById("bundle_messenger"); + let isMove = Services.prefs.getBoolPref("mail.last_msg_movecopy_was_move"); + let stringName = isMove ? "moveToFolderAgain" : "copyToFolderAgain"; + aMenuItem.label = bundle.getFormattedString( + stringName, + [destMsgFolder.prettyName], + 1 + ); + // This gives us moveToFolderAgainAccessKey and copyToFolderAgainAccessKey. + aMenuItem.accesskey = bundle.getString(stringName + "AccessKey"); +} + +/** + * Update the "Show Header" menu items to reflect the current pref. + */ +function InitViewHeadersMenu() { + let dt = Ci.nsMimeHeaderDisplayTypes; + let headerchoice = Services.prefs.getIntPref("mail.show_headers"); + document + .getElementById("cmd_viewAllHeader") + .setAttribute("checked", headerchoice == dt.AllHeaders); + document + .getElementById("cmd_viewNormalHeader") + .setAttribute("checked", headerchoice == dt.NormalHeaders); + document.commandDispatcher.updateCommands("create-menu-mark"); +} + +function InitViewBodyMenu() { + let message; + + let tab = document.getElementById("tabmail")?.currentTabInfo; + if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) { + message = tab.message; + } + + // Separate render prefs not implemented for feeds, bug 458606. Show the + // checked item for feeds as for the regular pref. + // let html_as = Services.prefs.getIntPref("rss.display.html_as"); + // let prefer_plaintext = Services.prefs.getBoolPref("rss.display.prefer_plaintext"); + // let disallow_classes = Services.prefs.getIntPref("rss.display.disallow_mime_handlers"); + let html_as = Services.prefs.getIntPref("mailnews.display.html_as"); + let prefer_plaintext = Services.prefs.getBoolPref( + "mailnews.display.prefer_plaintext" + ); + let disallow_classes = Services.prefs.getIntPref( + "mailnews.display.disallow_mime_handlers" + ); + let isFeed = FeedUtils.isFeedMessage(message); + const defaultIDs = [ + "bodyAllowHTML", + "bodySanitized", + "bodyAsPlaintext", + "bodyAllParts", + ]; + const rssIDs = [ + "bodyFeedSummaryAllowHTML", + "bodyFeedSummarySanitized", + "bodyFeedSummaryAsPlaintext", + ]; + let menuIDs = isFeed ? rssIDs : defaultIDs; + + if (disallow_classes > 0) { + gDisallow_classes_no_html = disallow_classes; + } + // else gDisallow_classes_no_html keeps its initial value (see top) + + let AllowHTML_menuitem = document.getElementById(menuIDs[0]); + let Sanitized_menuitem = document.getElementById(menuIDs[1]); + let AsPlaintext_menuitem = document.getElementById(menuIDs[2]); + let AllBodyParts_menuitem = menuIDs[3] + ? document.getElementById(menuIDs[3]) + : null; + + document.getElementById("bodyAllParts").hidden = !Services.prefs.getBoolPref( + "mailnews.display.show_all_body_parts_menu" + ); + + if ( + !prefer_plaintext && + !html_as && + !disallow_classes && + AllowHTML_menuitem + ) { + AllowHTML_menuitem.setAttribute("checked", true); + } else if ( + !prefer_plaintext && + html_as == 3 && + disallow_classes > 0 && + Sanitized_menuitem + ) { + Sanitized_menuitem.setAttribute("checked", true); + } else if ( + prefer_plaintext && + html_as == 1 && + disallow_classes > 0 && + AsPlaintext_menuitem + ) { + AsPlaintext_menuitem.setAttribute("checked", true); + } else if ( + !prefer_plaintext && + html_as == 4 && + !disallow_classes && + AllBodyParts_menuitem + ) { + AllBodyParts_menuitem.setAttribute("checked", true); + } + // else (the user edited prefs/user.js) check none of the radio menu items + + if (isFeed) { + AllowHTML_menuitem.hidden = !FeedMessageHandler.gShowSummary; + Sanitized_menuitem.hidden = !FeedMessageHandler.gShowSummary; + AsPlaintext_menuitem.hidden = !FeedMessageHandler.gShowSummary; + document.getElementById("viewFeedSummarySeparator").hidden = + !gShowFeedSummary; + } +} + +function ShowMenuItem(id, showItem) { + document.getElementById(id).hidden = !showItem; +} + +function EnableMenuItem(id, enableItem) { + document.getElementById(id).disabled = !enableItem; +} + +function SetMenuItemLabel(menuItemId, customLabel) { + var menuItem = document.getElementById(menuItemId); + if (menuItem) { + menuItem.setAttribute("label", customLabel); + } +} + +/** + * Refresh the contents of the tag popup menu/panel. + * Used for example for appmenu/Message/Tag panel. + * + * @param {Element} parent - Parent element that will contain the menu items. + * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton". + * @param {string} [classes] - Classes to set on the menu items. + */ +function InitMessageTags(parent, elementName = "menuitem", classes) { + function SetMessageTagLabel(menuitem, index, name) { + // if a <key> is defined for this tag, use its key as the accesskey + // (the key for the tag at index n needs to have the id key_tag<n>) + let shortcutkey = document.getElementById("key_tag" + index); + let accesskey = shortcutkey ? shortcutkey.getAttribute("key") : " "; + if (accesskey != " ") { + menuitem.setAttribute("accesskey", accesskey); + menuitem.setAttribute("acceltext", accesskey); + } + let label = document + .getElementById("bundle_messenger") + .getFormattedString("mailnews.tags.format", [accesskey, name]); + menuitem.setAttribute("label", label); + } + + let message; + + let tab = document.getElementById("tabmail")?.currentTabInfo; + if (["mail3PaneTab", "mailMessageTab"].includes(tab?.mode.name)) { + message = tab.message; + } else { + message = document.getElementById("messageBrowser")?.contentWindow.gMessage; + } + + const tagArray = MailServices.tags.getAllTags(); + const elementNameUpperCase = elementName.toUpperCase(); + + // Remove any existing non-static items (clear tags list before rebuilding it). + // There is a separator element above the dynamically added tag elements, so + // remove dynamically added elements below the separator. + while ( + parent.lastElementChild.tagName.toUpperCase() == elementNameUpperCase + ) { + parent.lastChild.remove(); + } + + // Create label and accesskey for the static "remove all tags" item. + const tagRemoveLabel = document + .getElementById("bundle_messenger") + .getString("mailnews.tags.remove"); + SetMessageTagLabel( + parent.lastElementChild.previousElementSibling, + 0, + tagRemoveLabel + ); + + // Rebuild the list. + const curKeys = message.getStringProperty("keywords"); + + tagArray.forEach((tagInfo, index) => { + const removeKey = ` ${curKeys} `.includes(` ${tagInfo.key} `); + + if (tagInfo.ordinal.includes("~AUTOTAG") && !removeKey) { + return; + } + // TODO We want to either remove or "check" the tags that already exist. + let item = parent.ownerDocument.createXULElement(elementName); + SetMessageTagLabel(item, index + 1, tagInfo.tag); + + if (removeKey) { + item.setAttribute("checked", "true"); + } + item.setAttribute("value", tagInfo.key); + item.setAttribute("type", "checkbox"); + item.addEventListener("command", function (event) { + goDoCommand("cmd_toggleTag", event); + }); + + if (tagInfo.color) { + item.setAttribute("style", `color: ${tagInfo.color};`); + } + if (classes) { + item.setAttribute("class", classes); + } + parent.appendChild(item); + }); +} + +function getMsgToolbarMenu_init() { + document.commandDispatcher.updateCommands("create-menu-getMsgToolbar"); +} + +function InitMessageMark() { + let tab = document.getElementById("tabmail")?.currentTabInfo; + let flaggedItem = document.getElementById("markFlaggedMenuItem"); + if (tab?.message?.isFlagged) { + flaggedItem.setAttribute("checked", "true"); + } else { + flaggedItem.removeAttribute("checked"); + } + + document.commandDispatcher.updateCommands("create-menu-mark"); +} + +function GetFirstSelectedMsgFolder() { + try { + var selectedFolders = GetSelectedMsgFolders(); + } catch (e) { + console.error(e); + } + return selectedFolders.length > 0 ? selectedFolders[0] : null; +} + +function GetMessagesForInboxOnServer(server) { + var inboxFolder = MailUtils.getInboxFolder(server); + + // If the server doesn't support an inbox it could be an RSS server or some + // other server type. Just use the root folder and the server implementation + // can figure out what to do. + if (!inboxFolder) { + inboxFolder = server.rootFolder; + } + + GetNewMsgs(server, inboxFolder); +} + +function MsgGetMessage(folders) { + // if offline, prompt for getting messages + if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) { + GetFolderMessages(folders); + } +} + +function MsgPauseUpdates(selectedFolders = GetSelectedMsgFolders(), pause) { + // Pause single feed folder subscription updates, or all account updates if + // folder is the account folder. + let folder = selectedFolders.length ? selectedFolders[0] : null; + if (!FeedUtils.isFeedFolder(folder)) { + return; + } + + FeedUtils.pauseFeedFolderUpdates(folder, pause, true); + Services.obs.notifyObservers(folder, "folder-properties-changed"); +} + +function MsgGetMessagesForAllServers(defaultServer) { + // now log into any server + try { + // Array of arrays of servers for a particular folder. + var pop3DownloadServersArray = []; + // Parallel array of folders to download to... + var localFoldersToDownloadTo = []; + var pop3Server; + for (let server of MailServices.accounts.allServers) { + if (server.protocolInfo.canLoginAtStartUp && server.loginAtStartUp) { + if ( + defaultServer && + defaultServer.equals(server) && + !defaultServer.isDeferredTo && + defaultServer.rootFolder == defaultServer.rootMsgFolder + ) { + // skip, already opened + } else if (server.type == "pop3" && server.downloadOnBiff) { + CoalesceGetMsgsForPop3ServersByDestFolder( + server, + pop3DownloadServersArray, + localFoldersToDownloadTo + ); + pop3Server = server.QueryInterface(Ci.nsIPop3IncomingServer); + } else { + // Check to see if there are new messages on the server + server.performBiff(msgWindow); + } + } + } + for (let i = 0; i < pop3DownloadServersArray.length; ++i) { + // Any ol' pop3Server will do - the serversArray specifies which servers + // to download from. + pop3Server.downloadMailFromServers( + pop3DownloadServersArray[i], + msgWindow, + localFoldersToDownloadTo[i], + null + ); + } + } catch (ex) { + dump(ex + "\n"); + } +} + +/** + * Get messages for all those accounts which have the capability + * of getting messages and have session password available i.e., + * currently logged in accounts. + * if offline, prompt for getting messages. + */ +function MsgGetMessagesForAllAuthenticatedAccounts() { + if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) { + GetMessagesForAllAuthenticatedAccounts(); + } +} + +/** + * Get messages for the account selected from Menu dropdowns. + * if offline, prompt for getting messages. + * + * @param aFolder (optional) a folder in the account for which messages should + * be retrieved. If null, all accounts will be used. + */ +function MsgGetMessagesForAccount(aFolder) { + if (!aFolder) { + goDoCommand("cmd_getNewMessages"); + return; + } + + if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) { + var server = aFolder.server; + GetMessagesForInboxOnServer(server); + } +} + +// if offline, prompt for getNextNMessages +function MsgGetNextNMessages() { + if (MailOfflineMgr.isOnline() || MailOfflineMgr.getNewMail()) { + GetNextNMessages(GetFirstSelectedMsgFolder()); + } +} + +function MsgNewMessage(event) { + let msgFolder = document.getElementById("tabmail")?.currentTabInfo.folder; + + if (event?.shiftKey) { + ComposeMessage( + Ci.nsIMsgCompType.New, + Ci.nsIMsgCompFormat.OppositeOfDefault, + msgFolder, + [] + ); + } else { + ComposeMessage( + Ci.nsIMsgCompType.New, + Ci.nsIMsgCompFormat.Default, + msgFolder, + [] + ); + } +} + +/** Open subscribe window. */ +function MsgSubscribe(folder) { + var preselectedFolder = folder || GetFirstSelectedMsgFolder(); + + if (FeedUtils.isFeedFolder(preselectedFolder)) { + // Open feed subscription dialog. + openSubscriptionsDialog(preselectedFolder); + } else { + // Open IMAP/NNTP subscription dialog. + Subscribe(preselectedFolder); + } +} + +/** + * Show a confirmation dialog - check if the user really want to unsubscribe + * from the given newsgroup/s. + * + * @folders an array of newsgroup folders to unsubscribe from + * @returns true if the user said it's ok to unsubscribe + */ +function ConfirmUnsubscribe(folders) { + var bundle = document.getElementById("bundle_messenger"); + var titleMsg = bundle.getString("confirmUnsubscribeTitle"); + var dialogMsg = + folders.length == 1 + ? bundle.getFormattedString( + "confirmUnsubscribeText", + [folders[0].name], + 1 + ) + : bundle.getString("confirmUnsubscribeManyText"); + + return Services.prompt.confirm(window, titleMsg, dialogMsg); +} + +/** + * Unsubscribe from selected or passed in newsgroup/s. + * @param {nsIMsgFolder[]} selectedFolders - The folders to unsubscribe. + */ +function MsgUnsubscribe(folders) { + if (!ConfirmUnsubscribe(folders)) { + return; + } + + for (let i = 0; i < folders.length; i++) { + let subscribableServer = folders[i].server.QueryInterface( + Ci.nsISubscribableServer + ); + subscribableServer.unsubscribe(folders[i].name); + subscribableServer.commitSubscribeChanges(); + } +} + +function MsgOpenNewWindowForFolder(folderURI, msgKeyToSelect) { + window.openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,all,dialog=no", + folderURI, + msgKeyToSelect + ); +} + +/** + * UI-triggered command to open the currently selected folder(s) in new tabs. + * + * @param {nsIMsgFolder[]} folders - Folders to open in new tabs. + * @param {object} [tabParams] - Parameters to pass to the new tabs. + */ +function MsgOpenNewTabForFolders(folders, tabParams = {}) { + if (tabParams.background === undefined) { + tabParams.background = Services.prefs.getBoolPref( + "mail.tabs.loadInBackground" + ); + if (tabParams.event?.shiftKey) { + tabParams.background = !tabParams.background; + } + } + + let tabmail = document.getElementById("tabmail"); + for (let i = 0; i < folders.length; i++) { + tabmail.openTab("mail3PaneTab", { + ...tabParams, + folderURI: folders[i].URI, + }); + } +} + +function MsgOpenFromFile() { + var fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + + var bundle = document.getElementById("bundle_messenger"); + var filterLabel = bundle.getString("EMLFiles"); + var windowTitle = bundle.getString("OpenEMLFiles"); + + fp.init(window, windowTitle, Ci.nsIFilePicker.modeOpen); + fp.appendFilter(filterLabel, "*.eml"); + + // Default or last filter is "All Files". + fp.appendFilters(Ci.nsIFilePicker.filterAll); + + fp.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !fp.file) { + return; + } + MailUtils.openEMLFile(window, fp.file, fp.fileURL); + }); +} + +function MsgOpenNewWindowForMessage(aMsgHdr, aView) { + // We need to tell the window about our current view so that it can clone it. + // This enables advancing through the messages, etc. + return window.openDialog( + "chrome://messenger/content/messageWindow.xhtml", + "_blank", + "all,chrome,dialog=no,status,toolbar", + aMsgHdr, + aView + ); +} + +/** + * Display the given message in an existing folder tab. + * + * @param aMsgHdr The message header to display. + */ +function MsgDisplayMessageInFolderTab(aMsgHdr) { + let tabmail = document.getElementById("tabmail"); + tabmail.switchToTab(0); + tabmail.currentAbout3Pane.selectMessage(aMsgHdr); +} + +function MsgMarkAllRead(folders) { + for (let i = 0; i < folders.length; i++) { + folders[i].markAllMessagesRead(msgWindow); + } +} + +/** + * Go through each selected server and mark all its folders read. + * + * @param {nsIMsgFolder[]} selectedFolders - Folders in the servers to be + * marked as read. + */ +function MsgMarkAllFoldersRead(selectedFolders) { + let selectedServers = selectedFolders.filter(folder => folder.isServer); + if (!selectedServers.length) { + return; + } + + let bundle = document.getElementById("bundle_messenger"); + if ( + !Services.prompt.confirm( + window, + bundle.getString("confirmMarkAllFoldersReadTitle"), + bundle.getString("confirmMarkAllFoldersReadMessage") + ) + ) { + return; + } + + selectedServers.forEach(function (server) { + for (let folder of server.rootFolder.descendants) { + folder.markAllMessagesRead(msgWindow); + } + }); +} + +/** + * Opens the filter list. + * If an email address was passed, first a new filter is offered for creation + * with the data prefilled. + * + * @param {?string} emailAddress - An email address to use as value in the first + * search term. + * @param {?nsIMsgFolder} folder - The filter will be created in this folder's + * filter list. + * @param {?string} fieldName - Search field string, from + * nsMsgSearchTerm.cpp::SearchAttribEntryTable. + */ +function MsgFilters(emailAddress, folder, fieldName) { + // Don't trigger anything if there are no accounts configured. This is to + // disable potential triggers via shortcuts. + if (MailServices.accounts.accounts.length == 0) { + return; + } + + if (!folder) { + let chromeBrowser = + document.getElementById("tabmail")?.currentTabInfo.chromeBrowser || + document.getElementById("messageBrowser"); + let dbView = chromeBrowser?.contentWindow?.gDBView; + // Try to determine the folder from the selected message. + if (dbView?.numSelected) { + // Here we face a decision. If the message has been moved to a different + // account, then a single filter cannot work for both manual and incoming + // scope. So we will create the filter based on its existing location, + // which will make it work properly in manual scope. This is the best + // solution for POP3 with global inbox (as then both manual and incoming + // filters work correctly), but may not be what IMAP users who filter to a + // local folder really want. + folder = dbView.hdrForFirstSelectedMessage.folder; + } + if (!folder) { + folder = GetFirstSelectedMsgFolder(); + } + } + let args; + if (emailAddress) { + // We have to do prefill filter so we are going to launch the filterEditor + // dialog and prefill that with the emailAddress. + args = { + filterList: folder.getEditableFilterList(msgWindow), + filterName: emailAddress, + }; + // Set the field name to prefill in the filter, if one was specified. + if (fieldName) { + args.fieldName = fieldName; + } + + window.openDialog( + "chrome://messenger/content/FilterEditor.xhtml", + "", + "chrome, modal, resizable,centerscreen,dialog=yes", + args + ); + + // If the user hits OK in the filterEditor dialog we set args.refresh=true + // there and we check this here in args to show filterList dialog. + // We also received the filter created via args.newFilter. + if ("refresh" in args && args.refresh) { + args = { refresh: true, folder, filter: args.newFilter }; + MsgFilterList(args); + } + } else { + // Just launch filterList dialog. + args = { refresh: false, folder }; + MsgFilterList(args); + } +} + +function MsgViewAllHeaders() { + Services.prefs.setIntPref( + "mail.show_headers", + Ci.nsMimeHeaderDisplayTypes.AllHeaders + ); +} + +function MsgViewNormalHeaders() { + Services.prefs.setIntPref( + "mail.show_headers", + Ci.nsMimeHeaderDisplayTypes.NormalHeaders + ); +} + +function MsgBodyAllowHTML() { + Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false); + Services.prefs.setIntPref("mailnews.display.html_as", 0); + Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", 0); +} + +function MsgBodySanitized() { + Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false); + Services.prefs.setIntPref("mailnews.display.html_as", 3); + Services.prefs.setIntPref( + "mailnews.display.disallow_mime_handlers", + gDisallow_classes_no_html + ); +} + +function MsgBodyAsPlaintext() { + Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", true); + Services.prefs.setIntPref("mailnews.display.html_as", 1); + Services.prefs.setIntPref( + "mailnews.display.disallow_mime_handlers", + gDisallow_classes_no_html + ); +} + +function MsgBodyAllParts() { + Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", false); + Services.prefs.setIntPref("mailnews.display.html_as", 4); + Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", 0); +} + +function MsgFeedBodyRenderPrefs(plaintext, html, mime) { + // Separate render prefs not implemented for feeds, bug 458606. + // Services.prefs.setBoolPref("rss.display.prefer_plaintext", plaintext); + // Services.prefs.setIntPref("rss.display.html_as", html); + // Services.prefs.setIntPref("rss.display.disallow_mime_handlers", mime); + + Services.prefs.setBoolPref("mailnews.display.prefer_plaintext", plaintext); + Services.prefs.setIntPref("mailnews.display.html_as", html); + Services.prefs.setIntPref("mailnews.display.disallow_mime_handlers", mime); + // Reload only if showing rss summary; menuitem hidden if web page.. +} + +function ToggleInlineAttachment(target) { + var viewAttachmentInline = !Services.prefs.getBoolPref( + "mail.inline_attachments" + ); + Services.prefs.setBoolPref("mail.inline_attachments", viewAttachmentInline); + target.setAttribute("checked", viewAttachmentInline ? "true" : "false"); +} + +function IsGetNewMessagesEnabled() { + for (let server of MailServices.accounts.allServers) { + if (server.type == "none") { + continue; + } + return true; + } + return false; +} + +function IsGetNextNMessagesEnabled() { + let selectedFolders = GetSelectedMsgFolders(); + let folder = selectedFolders.length ? selectedFolders[0] : null; + + let menuItem = document.getElementById("menu_getnextnmsg"); + if ( + folder && + !folder.isServer && + folder.server instanceof Ci.nsINntpIncomingServer + ) { + menuItem.label = PluralForm.get( + folder.server.maxArticles, + document + .getElementById("bundle_messenger") + .getString("getNextNewsMessages") + ).replace("#1", folder.server.maxArticles); + menuItem.removeAttribute("hidden"); + return true; + } + + menuItem.setAttribute("hidden", "true"); + return false; +} + +function MsgSynchronizeOffline() { + window.openDialog( + "chrome://messenger/content/msgSynchronize.xhtml", + "", + "centerscreen,chrome,modal,titlebar,resizable=yes", + { msgWindow } + ); +} + +function IsAccountOfflineEnabled() { + var selectedFolders = GetSelectedMsgFolders(); + + if (selectedFolders && selectedFolders.length == 1) { + return selectedFolders[0].supportsOffline; + } + return false; +} + +function GetDefaultAccountRootFolder() { + var account = MailServices.accounts.defaultAccount; + if (account) { + return account.incomingServer.rootMsgFolder; + } + + return null; +} + +/** + * Check for new messages for all selected folders, or for the default account + * in case no folders are selected. + */ +function GetFolderMessages(selectedFolders = GetSelectedMsgFolders()) { + var defaultAccountRootFolder = GetDefaultAccountRootFolder(); + + // if nothing selected, use the default + var folders = selectedFolders.length + ? selectedFolders + : [defaultAccountRootFolder]; + + if (!folders[0]) { + return; + } + + for (var i = 0; i < folders.length; i++) { + var serverType = folders[i].server.type; + if (folders[i].isServer && serverType == "nntp") { + // If we're doing "get msgs" on a news server. + // Update unread counts on this server. + folders[i].server.performExpand(msgWindow); + } else if (folders[i].isServer && serverType == "imap") { + GetMessagesForInboxOnServer(folders[i].server); + } else if (serverType == "none") { + // If "Local Folders" is selected and the user does "Get Msgs" and + // LocalFolders is not deferred to, get new mail for the default account + // + // XXX TODO + // Should shift click get mail for all (authenticated) accounts? + // see bug #125885. + if (!folders[i].server.isDeferredTo) { + if (!defaultAccountRootFolder) { + continue; + } + GetNewMsgs(defaultAccountRootFolder.server, defaultAccountRootFolder); + } else { + GetNewMsgs(folders[i].server, folders[i]); + } + } else { + GetNewMsgs(folders[i].server, folders[i]); + } + } +} + +/** + * Gets new messages for the given server, for the given folder. + * + * @param server which nsIMsgIncomingServer to check for new messages + * @param folder which nsIMsgFolder folder to check for new messages + */ +function GetNewMsgs(server, folder) { + // Note that for Global Inbox folder.server != server when we want to get + // messages for a specific account. + + // Whenever we do get new messages, clear the old new messages. + folder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail; + folder.clearNewMessages(); + server.getNewMessages(folder, msgWindow, new TransportErrorUrlListener()); +} + +function InformUserOfCertError(secInfo, targetSite) { + let params = { + exceptionAdded: false, + securityInfo: secInfo, + prefetchCert: true, + location: targetSite, + }; + window.openDialog( + "chrome://pippki/content/exceptionDialog.xhtml", + "", + "chrome,centerscreen,modal", + params + ); +} + +/** + * A listener to be passed to the url object of the server request being issued + * to detect the bad server certificates. + * + * @implements {nsIUrlListener} + */ +function TransportErrorUrlListener() {} + +TransportErrorUrlListener.prototype = { + OnStartRunningUrl(url) {}, + + OnStopRunningUrl(url, exitCode) { + if (Components.isSuccessCode(exitCode)) { + return; + } + let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService( + Ci.nsINSSErrorsService + ); + try { + let errorClass = nssErrorsService.getErrorClass(exitCode); + if (errorClass == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) { + let mailNewsUrl = url.QueryInterface(Ci.nsIMsgMailNewsUrl); + let secInfo = mailNewsUrl.failedSecInfo; + InformUserOfCertError(secInfo, url.asciiHostPort); + } + } catch (e) { + // It's not an NSS error. + } + }, + + // nsISupports + QueryInterface: ChromeUtils.generateQI(["nsIUrlListener"]), +}; + +function SendUnsentMessages() { + let msgSendlater = Cc["@mozilla.org/messengercompose/sendlater;1"].getService( + Ci.nsIMsgSendLater + ); + + for (let identity of MailServices.accounts.allIdentities) { + let msgFolder = msgSendlater.getUnsentMessagesFolder(identity); + if (msgFolder) { + let numMessages = msgFolder.getTotalMessages( + false /* include subfolders */ + ); + if (numMessages > 0) { + msgSendlater.sendUnsentMessages(identity); + // Right now, all identities point to the same unsent messages + // folder, so to avoid sending multiple copies of the + // unsent messages, we only call messenger.SendUnsentMessages() once. + // See bug #89150 for details. + break; + } + } + } +} + +function CoalesceGetMsgsForPop3ServersByDestFolder( + currentServer, + pop3DownloadServersArray, + localFoldersToDownloadTo +) { + var inboxFolder = currentServer.rootMsgFolder.getFolderWithFlags( + Ci.nsMsgFolderFlags.Inbox + ); + // coalesce the servers that download into the same folder... + var index = localFoldersToDownloadTo.indexOf(inboxFolder); + if (index == -1) { + if (inboxFolder) { + inboxFolder.biffState = Ci.nsIMsgFolder.nsMsgBiffState_NoMail; + inboxFolder.clearNewMessages(); + } + localFoldersToDownloadTo.push(inboxFolder); + index = pop3DownloadServersArray.length; + pop3DownloadServersArray.push([]); + } + pop3DownloadServersArray[index].push(currentServer); +} + +function GetMessagesForAllAuthenticatedAccounts() { + // now log into any server + try { + // Array of arrays of servers for a particular folder. + var pop3DownloadServersArray = []; + // parallel array of folders to download to... + var localFoldersToDownloadTo = []; + var pop3Server; + + for (let server of MailServices.accounts.allServers) { + if ( + server.protocolInfo.canGetMessages && + !server.passwordPromptRequired + ) { + if (server.type == "pop3") { + CoalesceGetMsgsForPop3ServersByDestFolder( + server, + pop3DownloadServersArray, + localFoldersToDownloadTo + ); + pop3Server = server.QueryInterface(Ci.nsIPop3IncomingServer); + } else { + // get new messages on the server for imap or rss + GetMessagesForInboxOnServer(server); + } + } + } + for (let i = 0; i < pop3DownloadServersArray.length; ++i) { + // any ol' pop3Server will do - the serversArray specifies which servers to download from + pop3Server.downloadMailFromServers( + pop3DownloadServersArray[i], + msgWindow, + localFoldersToDownloadTo[i], + null + ); + } + } catch (ex) { + dump(ex + "\n"); + } +} + +function CommandUpdate_UndoRedo() { + EnableMenuItem("menu_undo", SetupUndoRedoCommand("cmd_undo")); + EnableMenuItem("menu_redo", SetupUndoRedoCommand("cmd_redo")); +} + +function SetupUndoRedoCommand(command) { + let folder = document.getElementById("tabmail")?.currentTabInfo.folder; + if (!folder?.server.canUndoDeleteOnServer) { + return false; + } + + let canUndoOrRedo = false; + let txnType; + try { + if (command == "cmd_undo") { + canUndoOrRedo = messenger.canUndo(); + txnType = messenger.getUndoTransactionType(); + } else { + canUndoOrRedo = messenger.canRedo(); + txnType = messenger.getRedoTransactionType(); + } + } catch (ex) { + // If this fails, assume we can't undo or redo. + console.error(ex); + } + + if (canUndoOrRedo) { + let commands = { + [Ci.nsIMessenger.eUnknown]: "valueDefault", + [Ci.nsIMessenger.eDeleteMsg]: "valueDeleteMsg", + [Ci.nsIMessenger.eMoveMsg]: "valueMoveMsg", + [Ci.nsIMessenger.eCopyMsg]: "valueCopyMsg", + [Ci.nsIMessenger.eMarkAllMsg]: "valueUnmarkAllMsgs", + }; + goSetMenuValue(command, commands[txnType]); + } else { + goSetMenuValue(command, "valueDefault"); + } + + return canUndoOrRedo; +} + +/** + * Focus the gloda global search input box on current tab, or, + * if the search box is not available, open a new gloda search tab + * (with its search box focused). + */ +function QuickSearchFocus() { + // Default to focusing the search box on the current tab + let newTab = false; + let searchInput; + let tabmail = document.getElementById("tabmail"); + // Tabmail should never be undefined. + if (!tabmail || tabmail.globalOverlay) { + return; + } + + switch (tabmail.currentTabInfo.mode.name) { + case "glodaFacet": + // If we're currently viewing a Gloda tab, drill down to find the + // built-in search input, and select that. + searchInput = tabmail.currentTabInfo.panel.querySelector( + ".remote-gloda-search" + ); + break; + default: + searchInput = document.querySelector( + "#unifiedToolbarContent .search-bar global-search-bar" + ); + break; + } + + if (!searchInput) { + // If searchInput is not found on current tab (e.g. removed by user), + // use a new tab. + newTab = true; + } else { + // The searchInput element exists on current tab. + // However, via toolbar customization, it can be in different places: + // Toolbars, tab bar, menu bar, etc. If the containing elements are hidden, + // searchInput will also be hidden, so clientHeight and clientWidth of the + // searchbox or one of its parents will typically be zero and we can test + // for that. If searchInput is hidden, use a new tab. + let element = searchInput; + while (element) { + if (element.clientHeight == 0 || element.clientWidth == 0) { + newTab = true; + } + element = element.parentElement; + } + } + + if (!newTab) { + // Focus and select global search box on current tab. + if (searchInput.select) { + searchInput.select(); + } else { + searchInput.focus(); + } + } else { + // Open a new global search tab (with focus on its global search box) + tabmail.openTab("glodaFacet"); + } +} + +/** + * Open a new gloda search tab, with its search box focused. + */ +function openGlodaSearchTab() { + document.getElementById("tabmail").openTab("glodaFacet"); +} + +function MsgSearchAddresses() { + var args = { directory: null }; + OpenOrFocusWindow( + args, + "mailnews:absearch", + "chrome://messenger/content/addressbook/abSearchDialog.xhtml" + ); +} + +function MsgFilterList(args) { + OpenOrFocusWindow( + args, + "mailnews:filterlist", + "chrome://messenger/content/FilterListDialog.xhtml" + ); +} + +function OpenOrFocusWindow(args, windowType, chromeURL) { + var desiredWindow = Services.wm.getMostRecentWindow(windowType); + + if (desiredWindow) { + desiredWindow.focus(); + if ("refresh" in args && args.refresh) { + desiredWindow.refresh(args); + } + } else { + window.openDialog( + chromeURL, + "", + "chrome,resizable,status,centerscreen,dialog=no", + args + ); + } +} + +function initAppMenuPopup() { + file_init(); + view_init(); + InitGoMessagesMenu(); + menu_new_init(); + CommandUpdate_UndoRedo(); + document.commandDispatcher.updateCommands("create-menu-tasks"); + UIFontSize.updateAppMenuButton(window); + initUiDensityAppMenu(); + + document.getElementById("appmenu_FolderViews").disabled = + document.getElementById("tabmail").currentTabInfo.mode.name != + "mail3PaneTab"; +} + +/** + * Generate menu items that open a preferences dialog/tab for an installed addon, + * and add them to a menu popup. E.g. in the appmenu or Tools menu > addon prefs. + * + * @param {Element} parent - The element (e.g. menupopup) to populate. + * @param {string} [elementName] - The kind of menu item elements to create (e.g. "toolbarbutton"). + * @param {string} [classes] - Classes for menu item elements with no icon. + * @param {string} [iconClasses] - Classes for menu item elements with an icon. + */ +async function initAddonPrefsMenu( + parent, + elementName = "menuitem", + classes, + iconClasses = "menuitem-iconic" +) { + // Starting at the bottom, clear all menu items until we hit + // "no add-on prefs", which is the only disabled element. Above this element + // there may be further items that we want to preserve. + let noPrefsElem = parent.querySelector('[disabled="true"]'); + while (parent.lastChild != noPrefsElem) { + parent.lastChild.remove(); + } + + // Enumerate all enabled addons with URL to XUL document with prefs. + let addonsFound = []; + for (let addon of await AddonManager.getAddonsByTypes(["extension"])) { + if (addon.userDisabled || addon.appDisabled || addon.softDisabled) { + continue; + } + if (addon.optionsURL) { + if (addon.optionsType == 5) { + addonsFound.push({ + addon, + optionsURL: `addons://detail/${encodeURIComponent( + addon.id + )}/preferences`, + optionsOpenInAddons: true, + }); + } else if (addon.optionsType === null || addon.optionsType == 3) { + addonsFound.push({ + addon, + optionsURL: addon.optionsURL, + optionsOpenInTab: addon.optionsType == 3, + }); + } + } + } + + // Populate the menu with addon names and icons. + // Note: Having the following code in the getAddonsByTypes() async callback + // above works on Windows and Linux but doesn't work on Mac, see bug 1419145. + if (addonsFound.length > 0) { + addonsFound.sort((a, b) => a.addon.name.localeCompare(b.addon.name)); + for (let { + addon, + optionsURL, + optionsOpenInTab, + optionsOpenInAddons, + } of addonsFound) { + let newItem = document.createXULElement(elementName); + newItem.setAttribute("label", addon.name); + newItem.setAttribute("value", optionsURL); + if (optionsOpenInTab) { + newItem.setAttribute("optionsType", "tab"); + } else if (optionsOpenInAddons) { + newItem.setAttribute("optionsType", "addons"); + } + let iconURL = addon.iconURL || addon.icon64URL; + if (iconURL) { + newItem.setAttribute("class", iconClasses); + newItem.setAttribute("image", iconURL); + } else if (classes) { + newItem.setAttribute("class", classes); + } + parent.appendChild(newItem); + } + noPrefsElem.setAttribute("collapsed", "true"); + } else { + // Only show message that there are no addons with prefs. + noPrefsElem.setAttribute("collapsed", "false"); + } +} + +function openNewCardDialog() { + toAddressBook({ action: "create" }); +} + +/** + * Opens Address Book tab and triggers address book creation dialog defined + * type. + * + * @param {?string}[type = "JS"] type - The address book type needing creation. + */ +function openNewABDialog(type = "JS") { + toAddressBook({ action: `create_ab_${type}` }); +} + +/** + * Verifies we have the attachments in order to populate the menupopup. + * Resets the popup to be populated. + * + * @param {DOMEvent} event - The popupshowing event. + */ +function fillAttachmentListPopup(event) { + if (event.target.id != "attachmentMenuList") { + return; + } + + const popup = event.target; + + // Clear out the old menupopup. + while (popup.firstElementChild?.localName == "menu") { + popup.firstElementChild?.remove(); + } + + let aboutMessage = + document.getElementById("tabmail")?.currentAboutMessage || + document.getElementById("messageBrowser")?.contentWindow; + if (!aboutMessage) { + return; + } + + let attachments = aboutMessage.currentAttachments; + for (let [index, attachment] of attachments.entries()) { + addAttachmentToPopup(aboutMessage, popup, attachment, index); + } + aboutMessage.goUpdateAttachmentCommands(); +} + +/** + * Add each attachment to the menupop up before the menuseparator and create + * a submenu with the attachments' options (open, save, detach and delete). + * + * @param {?Window} aboutMessage - The current message on the message pane. + * @param {XULPopupElement} popup - #attachmentMenuList menupopup. + * @param {AttachmentInfo} attachment - The file attached to the email. + * @param {integer} attachmentIndex - The attachment's index. + */ +function addAttachmentToPopup( + aboutMessage, + popup, + attachment, + attachmentIndex +) { + let item = document.createXULElement("menu"); + + function getString(aName) { + return document.getElementById("bundle_messenger").getString(aName); + } + + // Insert the item just before the separator. The separator is the 2nd to + // last element in the popup. + item.classList.add("menu-iconic"); + item.setAttribute("image", getIconForAttachment(attachment)); + + const separator = popup.querySelector("menuseparator"); + + // We increment the attachmentIndex here since we only use it for the + // label and accesskey attributes, and we want the accesskeys for the + // attachments list in the menu to be 1-indexed. + attachmentIndex++; + + let displayName = SanitizeAttachmentDisplayName(attachment); + let label = document + .getElementById("bundle_messenger") + .getFormattedString("attachmentDisplayNameFormat", [ + attachmentIndex, + displayName, + ]); + item.setAttribute("crop", "center"); + item.setAttribute("label", label); + item.setAttribute("accesskey", attachmentIndex % 10); + + // Each attachment in the list gets its own menupopup with options for + // saving, deleting, detaching, etc. + let menupopup = document.createXULElement("menupopup"); + menupopup = item.appendChild(menupopup); + + item = popup.insertBefore(item, separator); + + if (attachment.isExternalAttachment) { + if (!attachment.hasFile) { + item.classList.add("notfound"); + } else { + // The text-link class must be added to the <label> and have a <menu> + // hover rule. Adding to <menu> makes hover overflow the underline to + // the popup items. + let label = item.children[1]; + label.classList.add("text-link"); + } + } + + if (attachment.isDeleted) { + item.classList.add("notfound"); + } + + let detached = attachment.isExternalAttachment; + let deleted = !attachment.hasFile; + let canDetach = aboutMessage?.CanDetachAttachments() && !deleted && !detached; + + if (deleted) { + // We can't do anything with a deleted attachment, so just return. + item.disabled = true; + return; + } + + // Create the "open" menu item + let menuitem = document.createXULElement("menuitem"); + menuitem.attachment = attachment; + menuitem.addEventListener("command", () => + attachment.open(aboutMessage.browsingContext) + ); + menuitem.setAttribute("label", getString("openLabel")); + menuitem.setAttribute("accesskey", getString("openLabelAccesskey")); + menuitem.setAttribute("disabled", deleted); + menuitem = menupopup.appendChild(menuitem); + + // Create the "save" menu item + menuitem = document.createXULElement("menuitem"); + menuitem.attachment = attachment; + menuitem.addEventListener("command", () => attachment.save(messenger)); + menuitem.setAttribute("label", getString("saveLabel")); + menuitem.setAttribute("accesskey", getString("saveLabelAccesskey")); + menuitem.setAttribute("disabled", deleted); + menuitem = menupopup.appendChild(menuitem); + + // Create the "detach" menu item + menuitem = document.createXULElement("menuitem"); + menuitem.attachment = attachment; + menuitem.addEventListener("command", () => + attachment.detach(messenger, true) + ); + menuitem.setAttribute("label", getString("detachLabel")); + menuitem.setAttribute("accesskey", getString("detachLabelAccesskey")); + menuitem.setAttribute("disabled", !canDetach); + menuitem = menupopup.appendChild(menuitem); + + // Create the "delete" menu item + menuitem = document.createXULElement("menuitem"); + menuitem.attachment = attachment; + menuitem.addEventListener("command", () => + attachment.detach(messenger, false) + ); + menuitem.setAttribute("label", getString("deleteLabel")); + menuitem.setAttribute("accesskey", getString("deleteLabelAccesskey")); + menuitem.setAttribute("disabled", !canDetach); + menuitem = menupopup.appendChild(menuitem); + + // Create the "open containing folder" menu item, for existing detached only. + if (attachment.isFileAttachment) { + let menuseparator = document.createXULElement("menuseparator"); + menupopup.appendChild(menuseparator); + menuitem = document.createXULElement("menuitem"); + menuitem.attachment = attachment; + menuitem.setAttribute("oncommand", "this.attachment.openFolder();"); + menuitem.setAttribute("label", getString("openFolderLabel")); + menuitem.setAttribute("accesskey", getString("openFolderLabelAccesskey")); + menuitem.setAttribute("disabled", !attachment.hasFile); + menuitem = menupopup.appendChild(menuitem); + } +} + +/** + * Return the string of the corresponding type of attachment's icon. + * + * @param {AttachmentInfo} attachment - The file attached to the email. + * @returns {string} + */ +function getIconForAttachment(attachment) { + return attachment.isDeleted + ? "chrome://messenger/skin/icons/attachment-deleted.svg" + : `moz-icon://${attachment.name}?size=16&contentType=${attachment.contentType}`; +} + +/** + * Opens the Address Book to add the email address from the given mailto: URL. + * + * @param {string} url + */ +function addEmail(url) { + let addresses = getEmail(url); + toAddressBook({ + action: "create", + address: addresses, + }); +} + +/** + * Extracts email address(es) from the given mailto: URL. + * + * @param {string} url + * @returns {string} + */ +function getEmail(url) { + let mailtolength = 7; + let qmark = url.indexOf("?"); + let addresses; + + if (qmark > mailtolength) { + addresses = url.substring(mailtolength, qmark); + } else { + addresses = url.substr(mailtolength); + } + // Let's try to unescape it using a character set + try { + addresses = Services.textToSubURI.unEscapeURIForUI(addresses); + } catch (ex) { + // Do nothing. + } + return addresses; +} + +/** + * Begins composing an email to the address from the given mailto: URL. + * + * @param {string} linkURL + * @param {nsIMsgIdentity} [identity] - The identity to use, otherwise the + * default identity is used. + */ +function composeEmailTo(linkURL, identity) { + let fields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + let params = Cc[ + "@mozilla.org/messengercompose/composeparams;1" + ].createInstance(Ci.nsIMsgComposeParams); + fields.to = getEmail(linkURL); + params.type = Ci.nsIMsgCompType.New; + params.format = Ci.nsIMsgCompFormat.Default; + if (identity) { + params.identity = identity; + } + params.composeFields = fields; + MailServices.compose.OpenComposeWindowWithParams(null, params); +} diff --git a/comm/mail/base/content/mainCommandSet.inc.xhtml b/comm/mail/base/content/mainCommandSet.inc.xhtml new file mode 100644 index 0000000000..ddc4fb6b66 --- /dev/null +++ b/comm/mail/base/content/mainCommandSet.inc.xhtml @@ -0,0 +1,247 @@ +# 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/. + + <command id="cmd_quitApplication" oncommand="goQuitApplication(event)"/> + <command id="cmd_CustomizeMailToolbar" + oncommand="CustomizeMailToolbar('mail-toolbox', 'CustomizeMailToolbar')"/> + + <commandset id="mailFileMenuItems" + commandupdater="true" + events="create-menu-file" + oncommandupdate="goUpdateMailMenuItems(this)"> + <command id="cmd_newFolder" oncommand="goDoCommand('cmd_newFolder')" disabled="true"/> + <command id="cmd_newVirtualFolder" oncommand="goDoCommand('cmd_newVirtualFolder')" disabled="true"/> + <command id="cmd_getNewMessages" oncommand="goDoCommand('cmd_getNewMessages')" disabled="true"/> + <command id="cmd_open" oncommand="goDoCommand('cmd_open')"/> + + <command id="cmd_emptyTrash" oncommand="goDoCommand('cmd_emptyTrash')" disabled="true"/> + <command id="cmd_compactFolder" oncommand="goDoCommand('cmd_compactFolder')" disabled="true"/> + <command id="cmd_print" oncommand="goDoCommand('cmd_print')" disabled="true"/> + <command id="cmd_saveAsFile" oncommand="goDoCommand('cmd_saveAsFile')" disabled="true"/> + <command id="cmd_saveAsTemplate" oncommand="goDoCommand('cmd_saveAsTemplate')" disabled="true"/> + <command id="cmd_getNextNMessages" oncommand="goDoCommand('cmd_getNextNMessages')" disabled="true"/> + <command id="cmd_deleteFolder" oncommand="goDoCommand('cmd_deleteFolder')"/> + <command id="cmd_renameFolder" oncommand="goDoCommand('cmd_renameFolder')" /> + <command id="cmd_sendUnsentMsgs" oncommand="goDoCommand('cmd_sendUnsentMsgs')" /> + <command id="cmd_subscribe" oncommand="goDoCommand('cmd_subscribe')" disabled="true"/> + <command id="cmd_synchronizeOffline" oncommand="goDoCommand('cmd_synchronizeOffline')" disabled="true"/> + <command id="cmd_downloadFlagged" oncommand="goDoCommand('cmd_downloadFlagged')" disabled="true"/> + <command id="cmd_downloadSelected" oncommand="goDoCommand('cmd_downloadSelected')" disabled="true"/> + <command id="cmd_settingsOffline" oncommand="goDoCommand('cmd_settingsOffline')" disabled="true"/> + </commandset> + + <commandset id="mailViewMenuItems" + commandupdater="true" + events="create-menu-view" + oncommandupdate="goUpdateMailMenuItems(this)"> + <command id="cmd_showQuickFilterBar" + oncommand="goDoCommand('cmd_showQuickFilterBar');"/> + <command id="cmd_toggleQuickFilterBar" + oncommand="goDoCommand('cmd_toggleQuickFilterBar');"/> + <command id="cmd_viewPageSource" oncommand="goDoCommand('cmd_viewPageSource')" disabled="true"/> + <command id="cmd_setFolderCharset" oncommand="goDoCommand('cmd_setFolderCharset')" /> + + <command id="cmd_expandAllThreads" oncommand="goDoCommand('cmd_expandAllThreads')" disabled="true"/> + <command id="cmd_collapseAllThreads" oncommand="goDoCommand('cmd_collapseAllThreads')" disabled="true"/> + <command id="cmd_viewClassicMailLayout" oncommand="goDoCommand('cmd_viewClassicMailLayout')" disabled="true"/> + <command id="cmd_viewWideMailLayout" oncommand="goDoCommand('cmd_viewWideMailLayout')" disabled="true"/> + <command id="cmd_viewVerticalMailLayout" oncommand="goDoCommand('cmd_viewVerticalMailLayout')" disabled="true"/> + <command id="cmd_toggleFolderPane" oncommand="goDoCommand('cmd_toggleFolderPane')" disabled="true"/> + <command id="cmd_toggleThreadPaneHeader" oncommand="goDoCommand('cmd_toggleThreadPaneHeader')" disabled="true"/> + <command id="cmd_toggleMessagePane" oncommand="goDoCommand('cmd_toggleMessagePane')" disabled="true"/> + <command id="cmd_viewAllMsgs" oncommand="goDoCommand('cmd_viewAllMsgs')" disabled="true"/> + <command id="cmd_viewUnreadMsgs" oncommand="goDoCommand('cmd_viewUnreadMsgs')" disabled="true"/> + <command id="cmd_viewThreadsWithUnread" oncommand="goDoCommand('cmd_viewThreadsWithUnread')" disabled="true"/> + <command id="cmd_viewWatchedThreadsWithUnread" oncommand="goDoCommand('cmd_viewWatchedThreadsWithUnread')" disabled="true"/> + <command id="cmd_viewIgnoredThreads" oncommand="goDoCommand('cmd_viewIgnoredThreads')" disabled="true"/> + <commandset id="viewZoomCommands" + commandupdater="true" + events="create-menu-view" + oncommandupdate="goUpdateMailMenuItems(this);"> + <command id="cmd_fullZoomReduce" + oncommand="goDoCommand('cmd_fullZoomReduce');"/> + <command id="cmd_fullZoomEnlarge" + oncommand="goDoCommand('cmd_fullZoomEnlarge');"/> + <command id="cmd_fullZoomReset" + oncommand="goDoCommand('cmd_fullZoomReset');"/> + <command id="cmd_fullZoomToggle" + oncommand="goDoCommand('cmd_fullZoomToggle');" + checked="false"/> + </commandset> + </commandset> + + <commandset id="mailEditMenuItems" + commandupdater="true" + events="create-menu-edit" + oncommandupdate="goUpdateMailMenuItems(this)"> + + <command id="cmd_undo" + oncommand="goDoCommand('cmd_undo')" + disabled="true" + valueDeleteMsg="&undoDeleteMsgCmd.label;" + valueMoveMsg="&undoMoveMsgCmd.label;" + valueCopyMsg="&undoCopyMsgCmd.label;" + valueUnmarkAllMsgs="&undoMarkAllCmd.label;" + valueDefault="&undoDefaultCmd.label;"/> + <command id="cmd_redo" + oncommand="goDoCommand('cmd_redo')" + disabled="true" + valueDeleteMsg="&redoDeleteMsgCmd.label;" + valueMoveMsg="&redoMoveMsgCmd.label;" + valueCopyMsg="&redoCopyMsgCmd.label;" + valueUnmarkAllMsgs="&redoMarkAllCmd.label;" + valueDefault="&redoDefaultCmd.label;"/> + <command id="cmd_cut" + oncommand="goDoCommand('cmd_cut')" + disabled="true"/> + <command id="cmd_copy" + oncommand="goDoCommand('cmd_copy')" + disabled="true"/> + <command id="cmd_paste" + oncommand="goDoCommand('cmd_paste')" + disabled="true"/> + <command id="cmd_delete" + oncommand="goDoCommand('cmd_delete')" + disabled="true"/> + <command id="cmd_cancel" oncommand="goDoCommand('cmd_cancel')"/> + <command id="cmd_selectThread" oncommand="goDoCommand('cmd_selectThread')"/> + <command id="cmd_selectFlagged" oncommand="goDoCommand('cmd_selectFlagged')"/> + <command id="cmd_toggleFavoriteFolder" oncommand="goDoCommand('cmd_toggleFavoriteFolder')"/> + <command id="cmd_properties" oncommand="goDoCommand('cmd_properties')"/> + <command id="cmd_find" oncommand="goDoCommand('cmd_find')" disabled="true"/> + <command id="cmd_findAgain" oncommand="goDoCommand('cmd_findAgain')" disabled="true"/> + <command id="cmd_findPrevious" oncommand="goDoCommand('cmd_findPrevious')" + disabled="true"/> + <command id="cmd_searchMessages" oncommand="goDoCommand('cmd_searchMessages');"/> + <!-- Stop/abort current network activities. --> + <command id="cmd_stop" oncommand="goDoCommand('cmd_stop')"/> + <command id="cmd_reload" oncommand="goDoCommand('cmd_reload')"/> + </commandset> + + <commandset id="mailEditContextMenuItems"> + <command id="cmd_copyLink" oncommand="goDoCommand('cmd_copyLink')" disabled="false"/> + <command id="cmd_copyImage" oncommand="goDoCommand('cmd_copyImageContents')" disabled="false"/> + </commandset> + + <commandset id="mailGoMenuItems" + commandupdater="true" + events="create-menu-go" + oncommandupdate="goUpdateMailMenuItems(this)"> + + <command id="cmd_nextMsg" oncommand="goDoCommand('cmd_nextMsg')" disabled="true"/> + <command id="cmd_nextUnreadMsg" oncommand="goDoCommand('cmd_nextUnreadMsg')" disabled="true"/> + <command id="cmd_nextFlaggedMsg" oncommand="goDoCommand('cmd_nextFlaggedMsg')" disabled="true"/> + <command id="cmd_nextUnreadThread" oncommand="goDoCommand('cmd_nextUnreadThread')" disabled="true"/> + <command id="cmd_previousMsg" oncommand="goDoCommand('cmd_previousMsg')" disabled="true"/> + <command id="cmd_previousUnreadMsg" oncommand="goDoCommand('cmd_previousUnreadMsg')" disabled="true"/> + <command id="cmd_previousFlaggedMsg" oncommand="goDoCommand('cmd_previousFlaggedMsg')" disabled="true"/> + <command id="cmd_goStartPage" oncommand="goDoCommand('cmd_goStartPage');"/> + <command id="cmd_undoCloseTab" oncommand="goDoCommand('cmd_undoCloseTab');"/> + <command id="cmd_goForward" oncommand="goDoCommand('cmd_goForward')" disabled="true"/> + <command id="cmd_goBack" oncommand="goDoCommand('cmd_goBack')" disabled="true"/> + <command id="cmd_goFolder" oncommand="goDoCommand('cmd_goFolder', event)" disabled="true"/> + <command id="cmd_chat" oncommand="goDoCommand('cmd_chat')" disabled="true"/> + </commandset> + + <commandset id="mailMessageMenuItems" + commandupdater="true" + events="create-menu-message" + oncommandupdate="goUpdateMailMenuItems(this)"> + <command id="cmd_archive" oncommand="goDoCommand('cmd_archive')"/> + <command id="cmd_newMessage" oncommand="goDoCommand('cmd_newMessage', event)"/> + <command id="cmd_reply" oncommand="goDoCommand('cmd_reply', event)"/> + <command id="cmd_replySender" oncommand="goDoCommand('cmd_replySender', event)"/> + <command id="cmd_replyGroup" oncommand="goDoCommand('cmd_replyGroup', event)"/> + <command id="cmd_replyall" oncommand="goDoCommand('cmd_replyall', event)"/> + <command id="cmd_replylist" oncommand="goDoCommand('cmd_replylist', event)"/> + <command id="cmd_forward" oncommand="goDoCommand('cmd_forward', event);"/> + <command id="cmd_forwardInline" oncommand="goDoCommand('cmd_forwardInline', event)"/> + <command id="cmd_forwardAttachment" oncommand="goDoCommand('cmd_forwardAttachment', event);"/> + <command id="cmd_redirect" oncommand="goDoCommand('cmd_redirect', event)"/> + <command id="cmd_editAsNew" oncommand="goDoCommand('cmd_editAsNew', event)"/> + <command id="cmd_editDraftMsg" oncommand="goDoCommand('cmd_editDraftMsg', event)"/> + <command id="cmd_newMsgFromTemplate" oncommand="goDoCommand('cmd_newMsgFromTemplate', event)"/> + <command id="cmd_editTemplateMsg" oncommand="goDoCommand('cmd_editTemplateMsg', event)"/> + <command id="cmd_openMessage" oncommand="goDoCommand('cmd_openMessage')"/> + <command id="cmd_openConversation" oncommand="goDoCommand('cmd_openConversation')"/> + <command id="cmd_moveToFolderAgain" oncommand="goDoCommand('cmd_moveToFolderAgain')"/> + <command id="cmd_createFilterFromMenu" oncommand="goDoCommand('cmd_createFilterFromMenu')"/> + <command id="cmd_killThread" oncommand="goDoCommand('cmd_killThread')"/> + <command id="cmd_killSubthread" oncommand="goDoCommand('cmd_killSubthread')"/> + <command id="cmd_watchThread" oncommand="goDoCommand('cmd_watchThread')"/> + </commandset> + + <commandset id="mailGetMsgMenuItems" + commandupdater="true" + events="create-menu-getMsgToolbar,create-menu-file" + oncommandupdate="goUpdateMailMenuItems(this)"> + + <command id="cmd_getMsgsForAuthAccounts" + oncommand="goDoCommand('cmd_getMsgsForAuthAccounts'); event.stopPropagation()" + disabled="true"/> + </commandset> + + <commandset id="mailMarkMenuItems" + commandupdater="true" + events="create-menu-mark" + oncommandupdate="goUpdateMailMenuItems(this)"> + <command id="cmd_mark"/> + <command id="cmd_tag"/> + <command id="cmd_toggleRead" oncommand="goDoCommand('cmd_toggleRead'); event.stopPropagation()" disabled="true"/> + <command id="cmd_markAsRead" oncommand="goDoCommand('cmd_markAsRead'); event.stopPropagation()" disabled="true"/> + <command id="cmd_markAsUnread" oncommand="goDoCommand('cmd_markAsUnread'); event.stopPropagation()" disabled="true"/> + <command id="cmd_markAllRead" oncommand="goDoCommand('cmd_markAllRead'); event.stopPropagation()" disabled="true"/> + <command id="cmd_markThreadAsRead" oncommand="goDoCommand('cmd_markThreadAsRead'); event.stopPropagation()" disabled="true"/> + <command id="cmd_markReadByDate" oncommand="goDoCommand('cmd_markReadByDate');" disabled="true"/> + <command id="cmd_markAsFlagged" oncommand="goDoCommand('cmd_markAsFlagged'); event.stopPropagation()" disabled="true"/> + <command id="cmd_markAsJunk" oncommand="goDoCommand('cmd_markAsJunk'); event.stopPropagation()" disabled="true"/> + <command id="cmd_markAsNotJunk" oncommand="goDoCommand('cmd_markAsNotJunk'); event.stopPropagation()" disabled="true"/> + <command id="cmd_recalculateJunkScore" oncommand="goDoCommand('cmd_recalculateJunkScore');" disabled="true"/> + <command id="cmd_viewAllHeader" oncommand="goDoCommand('cmd_viewAllHeader');" disabled="true"/> + <command id="cmd_viewNormalHeader" oncommand="goDoCommand('cmd_viewNormalHeader');" disabled="true"/> + </commandset> + + <commandset id="mailTagMenuItems" + commandupdater="true" + events="create-menu-tag" + oncommandupdate="goUpdateMailMenuItems(this);"> + + <command id="cmd_addTag" oncommand="goDoCommand('cmd_addTag'); event.stopPropagation();"/> + <command id="cmd_manageTags" oncommand="goDoCommand('cmd_manageTags'); event.stopPropagation();"/> + <command id="cmd_removeTags" oncommand="goDoCommand('cmd_removeTags'); event.stopPropagation();"/> + </commandset> + + <commandset id="mailToolsMenuItems" + commandupdater="true" + events="create-menu-tasks" + oncommandupdate="goUpdateMailMenuItems(this);"> + <command id="cmd_applyFilters" oncommand="goDoCommand('cmd_applyFilters');" disabled="true"/> + <command id="cmd_applyFiltersToSelection" + oncommand="goDoCommand('cmd_applyFiltersToSelection');" + disabled="true" + valueSelection="&filtersApplyToSelection.label;" + valueSelectionAccessKey="&filtersApplyToSelection.accesskey;" + valueMessage="&filtersApplyToMessage.label;" + valueMessageAccessKey="&filtersApplyToMessage.accesskey;"/> + <command id="cmd_runJunkControls" oncommand="goDoCommand('cmd_runJunkControls');" disabled="true"/> + <command id="cmd_deleteJunk" oncommand="goDoCommand('cmd_deleteJunk');" disabled="true"/> + </commandset> + + <commandset id="mailChatMenuItems" + commandupdater="true" + events="create-menu-tag"> + <command id="cmd_joinChat" oncommand="chatHandler.joinChat();" disabled="true"/> + <command id="cmd_chatStatus" oncommand="chatHandler.setStatusMenupopupCommand(event);" disabled="true"/> + <command id="cmd_addChatBuddy" oncommand="chatHandler.addBuddy();" disabled="true"/> + </commandset> + +#ifdef XP_MACOSX + <commandset id="macWindowMenuItems"> + <!-- Mac Window menu --> + <command id="minimizeWindow" label="&minimizeWindow.label;" oncommand="window.minimize();"/> + <command id="zoomWindow" label="&zoomWindow.label;" oncommand="zoomWindow();"/> + <command id="Tasks:Mail" oncommand="focusOnMail(1);"/> + <command id="Tasks:AddressBook" oncommand="toAddressBook();"/> + </commandset> +#endif diff --git a/comm/mail/base/content/mainKeySet.inc.xhtml b/comm/mail/base/content/mainKeySet.inc.xhtml new file mode 100644 index 0000000000..6d79ad8c0c --- /dev/null +++ b/comm/mail/base/content/mainKeySet.inc.xhtml @@ -0,0 +1,264 @@ +# 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/. + +#ifdef XP_UNIX +#ifndef XP_MACOSX +#define XP_GNOME 1 +#endif +#endif + + <key id="space" key=" " modifiers="shift any" oncommand="goDoCommand('cmd_space', event);"/> + + <!-- File Menu --> + <key id="key_close" key="&closeCmd.key;" command="cmd_close" modifiers="accel"/> +#ifndef XP_MACOSX + <key id="key_close2" keycode="VK_F4" modifiers="accel" command="cmd_close"/> + <key id="key_renameFolder" keycode="&renameFolder.key;" oncommand="goDoCommand('cmd_renameFolder')"/> +#endif + <key id="key_quitApplication" data-l10n-id="quit-app-shortcut" +#ifdef XP_WIN + modifiers="accel,shift" +#else + modifiers="accel" +#endif +#ifdef XP_MACOSX + internal="true" +#else + command="cmd_quitApplication" +#endif + reserved="true"/> + <!-- Edit Menu --> + <key id="key_undo" data-l10n-id="text-action-undo-shortcut" modifiers="accel" internal="true"/> + <key id="key_redo" +#ifdef XP_UNIX + data-l10n-id="text-action-undo-shortcut" modifiers="shift, accel" +#else + data-l10n-id="text-action-redo-shortcut" modifiers="accel" +#endif + internal="true"/> + <key id="key_cut" data-l10n-id="text-action-cut-shortcut" modifiers="accel" internal="true"/> + <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" internal="true"/> + <key id="key_paste" data-l10n-id="text-action-paste-shortcut" modifiers="accel" internal="true"/> +#ifdef XP_MACOSX + <key id="key_delete" keycode="VK_BACK" + oncommand="goDoCommand('cmd_delete');"/> + <key id="key_delete2" keycode="VK_DELETE" + oncommand="goDoCommand('cmd_delete');"/> + <key id="cmd_shiftDelete" keycode="VK_BACK" + oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/> + <key id="cmd_shiftDelete2" keycode="VK_DELETE" + oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/> +#else + <key id="key_delete" keycode="VK_DELETE" + oncommand="goDoCommand('cmd_delete');"/> + <key id="cmd_shiftDelete" keycode="VK_DELETE" + oncommand="goDoCommand('cmd_shiftDelete');" modifiers="shift"/> +#endif + <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" internal="true"/> + <key id="key_selectThread" key="&selectThreadCmd.key;" oncommand="goDoCommand('cmd_selectThread');" modifiers="accel, shift"/> + + <key id="key_toggleRead" key="&toggleReadCmd.key;" oncommand="goDoCommand('cmd_toggleRead');"/> + <key id="key_toggleFlagged" key="&markStarredCmd.key;" oncommand="goDoCommand('cmd_markAsFlagged');"/> + <key id="key_markJunk" key="&markAsJunkCmd.key;" oncommand="goDoCommand('cmd_markAsJunk');"/> + <key id="key_markNotJunk" key="&markAsNotJunkCmd.key;" oncommand="goDoCommand('cmd_markAsNotJunk');" + modifiers="shift"/> + + <key id="key_markAllRead" key="&markAllReadCmd.key;" + oncommand="goDoCommand('cmd_markAllRead');" modifiers="shift"/> + + <key id="key_markThreadAsRead" key="&markThreadAsReadCmd.key;" oncommand="goDoCommand('cmd_markThreadAsRead')"/> + <key id="key_markReadByDate" key="&markReadByDateCmd.key;" oncommand="goDoCommand('cmd_markReadByDate')"/> + <key id="key_nextMsg" key="&nextMsgCmd.key;" oncommand="goDoCommand('cmd_nextMsg')"/> + <key id="key_nextUnreadMsg" key="&nextUnreadMsgCmd.key;" oncommand="goDoCommand('cmd_nextUnreadMsg')"/> + <key id="key_expandAllThreads" key="&expandAllThreadsCmd.key;" oncommand="goDoCommand('cmd_expandAllThreads')"/> + <key key="&expandAllThreadsCmd.key;" modifiers="shift" oncommand="goDoCommand('cmd_expandAllThreads')"/> + <key id="key_collapseAllThreads" key="&collapseAllThreadsCmd.key;" oncommand="goDoCommand('cmd_collapseAllThreads')"/> + <key key="&collapseAllThreadsCmd.key;" modifiers="shift" oncommand="goDoCommand('cmd_collapseAllThreads')"/> + <key id="key_nextUnreadThread" key="&nextUnreadThread.key;" oncommand="goDoCommand('cmd_nextUnreadThread')"/> + <key id="key_previousMsg" key="&prevMsgCmd.key;" oncommand="goDoCommand('cmd_previousMsg')"/> + <key id="key_previousUnreadMsg" key="&prevUnreadMsgCmd.key;" oncommand="goDoCommand('cmd_previousUnreadMsg')"/> + <key id="key_archive" key="&archiveMsgCmd.key;" oncommand="goDoCommand('cmd_archive')"/> + <key id="key_goForward" key="&goForwardCmd.commandKey;" oncommand="goDoCommand('cmd_goForward')"/> + <key id="key_goBack" key="&goBackCmd.commandKey;" oncommand="goDoCommand('cmd_goBack')"/> + <key id="key_goStartPage" keycode="VK_HOME" oncommand="goDoCommand('cmd_goStartPage')" modifiers="alt"/> + <key id="key_undoCloseTab" key="&undoCloseTabCmd.commandkey;" oncommand="goDoCommand('cmd_undoCloseTab')" modifiers="accel, shift"/> + <key id="key_reply" key="&replyMsgCmd.key;" oncommand="goDoCommand('cmd_reply')" modifiers="accel"/> + <key id="key_replyall" key="&replyToAllMsgCmd.key;" oncommand="goDoCommand('cmd_replyall')" modifiers="accel, shift"/> + <key id="key_replylist" key="&replyToListMsgCmd.key;" oncommand="goDoCommand('cmd_replylist')" modifiers="accel, shift"/> + <key id="key_forward" key="&forwardMsgCmd.key;" oncommand="goDoCommand('cmd_forward')" modifiers="accel"/> + <key id="key_editAsNew" key="&editAsNewMsgCmd.key;" oncommand="goDoCommand('cmd_editAsNew')" modifiers="accel"/> + <key id="key_newMsgFromTemplate" keycode="&newMsgFromTemplateCmd.keycode;" internal="true"/><!-- for display on menus only --> + <key id="key_watchThread" key="&watchThreadMenu.key;" oncommand="goDoCommand('cmd_watchThread')" /> + <key id="key_killThread" key="&killThreadMenu.key;" oncommand="goDoCommand('cmd_killThread')" /> + <key id="key_killSubthread" key="&killSubthreadMenu.key;" oncommand="goDoCommand('cmd_killSubthread')" modifiers="shift" /> + <key id="key_openMessage" key="&openMessageWindowCmd.key;" oncommand="goDoCommand('cmd_openMessage')" modifiers="accel"/> + <key id="key_openConversation" key="&openInConversationCmd.key;" oncommand="goDoCommand('cmd_openConversation')" modifiers="accel, shift"/> +#ifdef XP_MACOSX + <key id="key_moveToFolderAgain" key="&moveToFolderAgainCmd.key;" oncommand="goDoCommand('cmd_moveToFolderAgain')" modifiers="alt, accel"/> +#else + <key id="key_moveToFolderAgain" key="&moveToFolderAgainCmd.key;" oncommand="goDoCommand('cmd_moveToFolderAgain')" modifiers="accel, shift"/> +#endif + <key id="key_print" key="&printCmd.key;" oncommand="goDoCommand('cmd_print')" modifiers="accel"/> + <key id="key_saveAsFile" key="&saveAsFileCmd.key;" oncommand="goDoCommand('cmd_saveAsFile')" modifiers="accel"/> + <key id="key_viewPageSource" key="&pageSourceCmd.key;" oncommand="goDoCommand('cmd_viewPageSource')" modifiers="accel"/> +#ifdef XP_MACOSX + <key id="key_getNewMessagesAlt" keycode="VK_F5" + oncommand="goDoCommand('cmd_getNewMessages');"/> + <key id="key_getAllNewMessagesAlt" keycode="VK_F5" modifiers="shift" + oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/> + <key id="key_getNewMessages" key="&getNewMessagesCmd.key;" modifiers="accel" + oncommand="goDoCommand('cmd_getNewMessages');"/> + <key id="key_getAllNewMessages" key="&getAllNewMessagesCmd.key;" modifiers="accel, shift" + oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/> +#else + <key id="key_getNewMessages" keycode="VK_F5" + oncommand="goDoCommand('cmd_getNewMessages');"/> + <key id="key_getAllNewMessages" keycode="VK_F5" modifiers="shift" + oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/> +#endif +#ifdef XP_GNOME + <key id="key_getNewMessages2" keycode="VK_F9" + oncommand="goDoCommand('cmd_getNewMessages');"/> + <key id="key_getAllNewMessages2" keycode="VK_F9" modifiers="shift" + oncommand="goDoCommand('cmd_getMsgsForAuthAccounts');"/> +#endif + <key id="key_find" key="&findCmd.key;" oncommand="goDoCommand('cmd_find')" modifiers="accel"/> + <key id="key_findAgain" key="&findAgainCmd.key;" oncommand="goDoCommand('cmd_findAgain')" modifiers="accel"/> + <key id="key_findPrev" key="&findPrevCmd.key;" oncommand="goDoCommand('cmd_findPrevious')" modifiers="accel, shift"/> + <key keycode="&findAgainCmd.key2;" oncommand="goDoCommand('cmd_findAgain')"/> + <key keycode="&findPrevCmd.key2;" oncommand="goDoCommand('cmd_findPrevious')" modifiers="shift"/> + <key id="key_quickSearchFocus" key="&quickSearchCmd.key;" oncommand="QuickSearchFocus()" modifiers="accel"/> + + <keyset id="viewZoomKeys"> + <key id="key_fullZoomReduce" key="&fullZoomReduceCmd.commandkey;" + command="cmd_fullZoomReduce" modifiers="accel"/> + <key key="&fullZoomReduceCmd.commandkey2;" + command="cmd_fullZoomReduce" modifiers="accel"/> + <key id="key_fullZoomEnlarge" key="&fullZoomEnlargeCmd.commandkey;" + command="cmd_fullZoomEnlarge" modifiers="accel"/> + <key key="&fullZoomEnlargeCmd.commandkey2;" + command="cmd_fullZoomEnlarge" modifiers="accel"/> + <key key="&fullZoomEnlargeCmd.commandkey3;" + command="cmd_fullZoomEnlarge" modifiers="accel"/> + <key id="key_fullZoomReset" key="&fullZoomResetCmd.commandkey;" + command="cmd_fullZoomReset" modifiers="accel"/> + <key key="&fullZoomResetCmd.commandkey2;" + command="cmd_fullZoomReset" modifiers="accel"/> + </keyset> + + <!-- View Toggle Keys (F8) --> + <key id="key_toggleMessagePane" keycode="VK_F8" oncommand="goDoCommand('cmd_toggleMessagePane');"/> + + <!-- Tag Keys --> + <!-- Includes both shifted and not, for Azerty and other layouts where the + numeric keys are shifted. --> + <key id="key_tag0" key="&tagCmd0.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_removeTags');"/> + <key id="key_tag1" key="&tagCmd1.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag1');"/> + <key id="key_tag2" key="&tagCmd2.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag2');"/> + <key id="key_tag3" key="&tagCmd3.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag3');"/> + <key id="key_tag4" key="&tagCmd4.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag4');"/> + <key id="key_tag5" key="&tagCmd5.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag5');"/> + <key id="key_tag6" key="&tagCmd6.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag6');"/> + <key id="key_tag7" key="&tagCmd7.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag7');"/> + <key id="key_tag8" key="&tagCmd8.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag8');"/> + <key id="key_tag9" key="&tagCmd9.key;" modifiers="shift any" + oncommand="goDoCommand('cmd_tag9');"/> + + <!-- Tools Keys --> + <key id="key_searchMail" + key="&searchMailCmd.key;" + modifiers="accel,shift" + command="cmd_searchMessages"/> + <key id="key_errorConsole" + key="&errorConsoleCmd.commandkey;" + oncommand="toJavaScriptConsole();" + modifiers="accel,shift"/> + <key id="key_devtoolsToolbox" + key="&devToolboxCmd.commandkey;" +#ifdef XP_MACOSX + modifiers="accel,alt" +#else + modifiers="accel,shift" +#endif + oncommand="BrowserToolboxLauncher.init();"/> + <key id="key_sanitizeHistory" + keycode="VK_DELETE" + oncommand="toSanitize();" + modifiers="accel,shift"/> +#ifdef XP_MACOSX + <key id="key_sanitizeHistory_mac" + keycode="VK_BACK" + oncommand="toSanitize();" + modifiers="accel,shift"/> +#endif + <key id="key_addressbook" + key="&addressBookCmd.key;" + modifiers="accel, shift" + oncommand="toAddressBook();"/> + <key id="key_savedFiles" + key="&savedFiles.key;" + modifiers="accel" + oncommand="openSavedFilesWnd();"/> + + <key id="key_qfb_show" + modifiers="accel,shift" + data-l10n-id="quick-filter-bar-show" + command="cmd_showQuickFilterBar"/> +#ifdef XP_GNOME +#define NUM_SELECT_TAB_MODIFIER alt +#else +#define NUM_SELECT_TAB_MODIFIER accel +#endif + +#expand <key id="key_mail" oncommand="focusOnMail(0, event);" key="1" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> +#expand <key id="key_selectTab2" oncommand="focusOnMail(1, event);" key="2" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> +#expand <key id="key_selectTab3" oncommand="focusOnMail(2, event);" key="3" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> +#expand <key id="key_selectTab4" oncommand="focusOnMail(3, event);" key="4" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> +#expand <key id="key_selectTab5" oncommand="focusOnMail(4, event);" key="5" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> +#expand <key id="key_selectTab6" oncommand="focusOnMail(5, event);" key="6" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> +#expand <key id="key_selectTab7" oncommand="focusOnMail(6, event);" key="7" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> +#expand <key id="key_selectTab8" oncommand="focusOnMail(7, event);" key="8" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> +#expand <key id="key_selectLastTab" oncommand="focusOnMail(-1, event);" key="9" modifiers="__NUM_SELECT_TAB_MODIFIER__"/> + +#ifdef XP_MACOSX + <!-- Mac Window menu keys --> + <key id="key_minimizeWindow" + command="minimizeWindow" + key="&minimizeWindow.key;" + modifiers="accel"/> + <key id="key_addressbook" + oncommand="toAddressBook();" + key="&addressBookCmd.key;" + modifiers="accel, shift"/> + <!-- the following 3 keys are used in the application menu on Mac OS X Cocoa widgets --> + <key id="key_preferencesCmdMac" + key="&preferencesCmdMac.commandkey;" + modifiers="&preferencesCmdMac.modifiers;" + internal="true"/> + <key id="key_hideThisAppCmdMac" + key="&hideThisAppCmdMac.commandkey;" + modifiers="&hideThisAppCmdMac.modifiers;" + internal="true"/> + <key id="key_hideOtherAppsCmdMac" + key="&hideOtherAppsCmdMac.commandkey;" + modifiers="&hideOtherAppsCmdMac.modifiers;" + internal="true"/> + <key id="key_openHelp" + oncommand="openSupportURL();" + key="&productHelpMac.commandkey;" + modifiers="&productHelpMac.modifiers;"/> +#else + <key id="key_openHelp" + oncommand="openSupportURL();" + keycode="&productHelp.commandkey;"/> +#endif diff --git a/comm/mail/base/content/mainStatusbar.inc.xhtml b/comm/mail/base/content/mainStatusbar.inc.xhtml new file mode 100644 index 0000000000..149d06fea7 --- /dev/null +++ b/comm/mail/base/content/mainStatusbar.inc.xhtml @@ -0,0 +1,19 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + <hbox id="statusTextBox" ondblclick="openActivityMgr();" flex="1"> + <hbox class="statusbarpanel"> + <toolbarbutton id="offline-status" oncommand="MailOfflineMgr.toggleOfflineStatus();"/> + </hbox> + <label id="statusText" class="statusbarpanel" value="&statusText.label;" flex="1" crop="end"/> + <hbox id="statusbar-progresspanel" class="statusbarpanel statusbarpanel-progress" collapsed="true"> + <html:progress class="progressmeter-statusbar" id="statusbar-icon" value="0" max="100"/> + </hbox> + <hbox id="quotaPanel" class="statusbarpanel statusbarpanel-progress" hidden="true"> + <stack> + <html:progress id="quotaMeter" class="progressmeter-statusbar" value="0" max="100"/> + <html:a id="quotaLabel" onclick="openFolderQuota();"></html:a> + </stack> + </hbox> + </hbox> diff --git a/comm/mail/base/content/messageWindow.js b/comm/mail/base/content/messageWindow.js new file mode 100644 index 0000000000..2b93de1fc9 --- /dev/null +++ b/comm/mail/base/content/messageWindow.js @@ -0,0 +1,742 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* This is where functions related to the standalone message window are kept */ + +/* import-globals-from ../../../../toolkit/content/viewZoomOverlay.js */ +/* import-globals-from ../../../mailnews/base/prefs/content/accountUtils.js */ +/* import-globals-from ../../components/customizableui/content/panelUI.js */ +/* import-globals-from mail-offline.js */ +/* import-globals-from mailCommands.js */ +/* import-globals-from mailCore.js */ +/* import-globals-from mailWindowOverlay.js */ +/* import-globals-from messenger-customization.js */ +/* import-globals-from toolbarIconColor.js */ + +/* globals messenger, CreateMailWindowGlobals, InitMsgWindow, OnMailWindowUnload */ // From mailWindow.js + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm", + UIDensity: "resource:///modules/UIDensity.jsm", + UIFontSize: "resource:///modules/UIFontSize.jsm", +}); + +var messageBrowser; + +function getBrowser() { + return document + .getElementById("messageBrowser") + .contentDocument.getElementById("messagepane"); +} + +this.__defineGetter__("browser", getBrowser); + +window.addEventListener("DOMContentLoaded", event => { + if (event.target != document) { + return; + } + + messageBrowser = document.getElementById("messageBrowser"); + messageBrowser.addEventListener("messageURIChanged", () => { + // Update toolbar buttons. + goUpdateCommand("cmd_getNewMessages"); + goUpdateCommand("cmd_print"); + goUpdateCommand("cmd_delete"); + document.commandDispatcher.updateCommands("create-menu-go"); + document.commandDispatcher.updateCommands("create-menu-message"); + }); + messageBrowser.addEventListener( + "load", + event => (messageBrowser.contentWindow.tabOrWindow = window), + true + ); +}); +window.addEventListener("load", OnLoadMessageWindow); +window.addEventListener("unload", OnUnloadMessageWindow); + +// we won't show the window until the onload() handler is finished +// so we do this trick (suggested by hyatt / blaker) +function OnLoadMessageWindow() { + // Set a sane starting width/height for all resolutions on new profiles. + // Do this before the window loads. + if (!document.documentElement.hasAttribute("width")) { + // Prefer 860xfull height. + let defaultHeight = screen.availHeight; + let defaultWidth = screen.availWidth >= 860 ? 860 : screen.availWidth; + + // On small screens, default to maximized state. + if (defaultHeight <= 600) { + document.documentElement.setAttribute("sizemode", "maximized"); + } + + document.documentElement.setAttribute("width", defaultWidth); + document.documentElement.setAttribute("height", defaultHeight); + // Make sure we're safe at the left/top edge of screen + document.documentElement.setAttribute("screenX", screen.availLeft); + document.documentElement.setAttribute("screenY", screen.availTop); + } + + updateTroubleshootMenuItem(); + ToolbarIconColor.init(); + BondOpenPGP.init(); + PanelUI.init(); + gExtensionsNotifications.init(); + + setTimeout(delayedOnLoadMessageWindow, 0); // when debugging, set this to 5000, so you can see what happens after the window comes up. + + messageBrowser.addEventListener("DOMTitleChanged", () => { + if (messageBrowser.contentTitle) { + if (AppConstants.platform == "macosx") { + document.title = messageBrowser.contentTitle; + } else { + document.title = + messageBrowser.contentTitle + + document.documentElement.getAttribute("titlemenuseparator") + + document.documentElement.getAttribute("titlemodifier"); + } + } else { + document.title = document.documentElement.getAttribute("titlemodifier"); + } + }); + + UIDensity.registerWindow(window); + UIFontSize.registerWindow(window); +} + +function delayedOnLoadMessageWindow() { + HideMenus(); + ShowMenus(); + MailOfflineMgr.init(); + CreateMailWindowGlobals(); + + // Run menubar initialization first, to avoid TabsInTitlebar code picking + // up mutations from it and causing a reflow. + if (AppConstants.platform != "macosx") { + AutoHideMenubar.init(); + } + + InitMsgWindow(); + + // initialize the customizeDone method on the customizeable toolbar + var toolbox = document.getElementById("mail-toolbox"); + toolbox.customizeDone = function (aEvent) { + MailToolboxCustomizeDone(aEvent, "CustomizeMailToolbar"); + }; + + SetupCommandUpdateHandlers(); + + setTimeout(actuallyLoadMessage, 0); +} + +function actuallyLoadMessage() { + /* + * Our actual use cases that drive the arguments we take are: + * 1) Displaying a message from disk or that was an attachment on a message. + * Such messages have no (real) message header and must come in the form of + * a URI. (The message display code creates a 'dummy' header.) + * 2) Displaying a message that has a header available, either as a result of + * the user selecting a message in another window to spawn us or through + * some indirection like displaying a message by message-id. (The + * newsgroup UI exposes this, as well as the spotlight/vista indexers.) + * + * We clone views when possible for: + * - Consistency of navigation within the message display. Users would find + * it odd if they showed a message from a cross-folder view but ended up + * navigating around the message's actual folder. + * - Efficiency. It's faster to clone a view than open a new one. + * + * Our argument idioms for the use cases are thus: + * 1) [{msgHdr: A message header, viewWrapperToClone: (optional) a view + * wrapper to clone}] + * 2) [A Message header, (optional) the origin DBViewWraper] + * 3) [A Message URI] where the URI is an nsIURL corresponding to a message + * on disk or that is an attachment part on another message. + * + * Our original set of arguments, in case these get passed in and you're + * wondering why we explode, was: + * 0: A message URI, string or nsIURI. + * 1: A folder URI. If arg 0 was an nsIURI, it may have had a folder attribute. + * 2: The nsIMsgDBView used to open us. + */ + if (window.arguments && window.arguments.length) { + let contentWindow = messageBrowser.contentWindow; + if (window.arguments[0] instanceof Ci.nsIURI) { + contentWindow.displayMessage(window.arguments[0].spec); + return; + } + + let msgHdr, viewWrapperToClone; + // message header as an object? + if ("wrappedJSObject" in window.arguments[0]) { + let hdrObject = window.arguments[0].wrappedJSObject; + ({ msgHdr, viewWrapperToClone } = hdrObject); + } else if (window.arguments[0] instanceof Ci.nsIMsgDBHdr) { + // message header as a separate param? + msgHdr = window.arguments[0]; + viewWrapperToClone = window.arguments[1]; + } + + contentWindow.displayMessage( + msgHdr.folder.getUriForMsg(msgHdr), + viewWrapperToClone + ); + } + + // set focus to the message pane + window.content.focus(); +} + +/** + * Load the given message into this window, and bring it to the front. This is + * supposed to be called whenever a message is supposed to be displayed in this + * window. + * + * @param aMsgHdr the message to display + * @param aViewWrapperToClone [optional] a DB view wrapper to clone for the + * message window + */ +function displayMessage(aMsgHdr, aViewWrapperToClone) { + let contentWindow = messageBrowser.contentWindow; + contentWindow.displayMessage( + aMsgHdr.folder.getUriForMsg(aMsgHdr), + aViewWrapperToClone + ); + + // bring this window to the front + window.focus(); +} + +function ShowMenus() { + var openMail3Pane_menuitem = document.getElementById("tasksMenuMail"); + if (openMail3Pane_menuitem) { + openMail3Pane_menuitem.removeAttribute("hidden"); + } +} + +/* eslint-disable complexity */ +function HideMenus() { + // TODO: Seems to be a lot of repetitive code. + // Can we just fold this into an array of element IDs and loop over them? + var message_menuitem = document.getElementById("menu_showMessage"); + if (message_menuitem) { + message_menuitem.setAttribute("hidden", "true"); + } + + message_menuitem = document.getElementById("appmenu_showMessage"); + if (message_menuitem) { + message_menuitem.setAttribute("hidden", "true"); + } + + var folderPane_menuitem = document.getElementById("menu_showFolderPane"); + if (folderPane_menuitem) { + folderPane_menuitem.setAttribute("hidden", "true"); + } + + folderPane_menuitem = document.getElementById("appmenu_showFolderPane"); + if (folderPane_menuitem) { + folderPane_menuitem.setAttribute("hidden", "true"); + } + + var showSearch_showMessage_Separator = document.getElementById( + "menu_showSearch_showMessage_Separator" + ); + if (showSearch_showMessage_Separator) { + showSearch_showMessage_Separator.setAttribute("hidden", "true"); + } + + var expandOrCollapseMenu = document.getElementById("menu_expandOrCollapse"); + if (expandOrCollapseMenu) { + expandOrCollapseMenu.setAttribute("hidden", "true"); + } + + var menuDeleteFolder = document.getElementById("menu_deleteFolder"); + if (menuDeleteFolder) { + menuDeleteFolder.hidden = true; + } + + var renameFolderMenu = document.getElementById("menu_renameFolder"); + if (renameFolderMenu) { + renameFolderMenu.setAttribute("hidden", "true"); + } + + var viewLayoutMenu = document.getElementById("menu_MessagePaneLayout"); + if (viewLayoutMenu) { + viewLayoutMenu.setAttribute("hidden", "true"); + } + + viewLayoutMenu = document.getElementById("appmenu_MessagePaneLayout"); + if (viewLayoutMenu) { + viewLayoutMenu.setAttribute("hidden", "true"); + } + + let paneViewSeparator = document.getElementById("appmenu_paneViewSeparator"); + if (paneViewSeparator) { + paneViewSeparator.setAttribute("hidden", "true"); + } + + var viewFolderMenu = document.getElementById("menu_FolderViews"); + if (viewFolderMenu) { + viewFolderMenu.setAttribute("hidden", "true"); + } + + viewFolderMenu = document.getElementById("appmenu_FolderViews"); + if (viewFolderMenu) { + viewFolderMenu.setAttribute("hidden", "true"); + } + + var viewMessagesMenu = document.getElementById("viewMessagesMenu"); + if (viewMessagesMenu) { + viewMessagesMenu.setAttribute("hidden", "true"); + } + + viewMessagesMenu = document.getElementById("appmenu_viewMessagesMenu"); + if (viewMessagesMenu) { + viewMessagesMenu.setAttribute("hidden", "true"); + } + + var viewMessageViewMenu = document.getElementById("viewMessageViewMenu"); + if (viewMessageViewMenu) { + viewMessageViewMenu.setAttribute("hidden", "true"); + } + + var viewMessagesMenuSeparator = document.getElementById( + "viewMessagesMenuSeparator" + ); + if (viewMessagesMenuSeparator) { + viewMessagesMenuSeparator.setAttribute("hidden", "true"); + } + + var openMessageMenu = document.getElementById("openMessageWindowMenuitem"); + if (openMessageMenu) { + openMessageMenu.setAttribute("hidden", "true"); + } + + openMessageMenu = document.getElementById( + "appmenu_openMessageWindowMenuitem" + ); + if (openMessageMenu) { + openMessageMenu.setAttribute("hidden", "true"); + } + + var viewSortMenuSeparator = document.getElementById("viewSortMenuSeparator"); + if (viewSortMenuSeparator) { + viewSortMenuSeparator.setAttribute("hidden", "true"); + } + + viewSortMenuSeparator = document.getElementById( + "appmenu_viewAfterThreadsSeparator" + ); + if (viewSortMenuSeparator) { + viewSortMenuSeparator.setAttribute("hidden", "true"); + } + + var viewSortMenu = document.getElementById("viewSortMenu"); + if (viewSortMenu) { + viewSortMenu.setAttribute("hidden", "true"); + } + + var emptryTrashMenu = document.getElementById("menu_emptyTrash"); + if (emptryTrashMenu) { + emptryTrashMenu.setAttribute("hidden", "true"); + } + + emptryTrashMenu = document.getElementById("appmenu_emptyTrash"); + if (emptryTrashMenu) { + emptryTrashMenu.setAttribute("hidden", "true"); + } + + var menuPropertiesSeparator = document.getElementById( + "editPropertiesSeparator" + ); + if (menuPropertiesSeparator) { + menuPropertiesSeparator.setAttribute("hidden", "true"); + } + + menuPropertiesSeparator = document.getElementById( + "appmenu_editPropertiesSeparator" + ); + if (menuPropertiesSeparator) { + menuPropertiesSeparator.setAttribute("hidden", "true"); + } + + var menuProperties = document.getElementById("menu_properties"); + if (menuProperties) { + menuProperties.setAttribute("hidden", "true"); + } + + menuProperties = document.getElementById("appmenu_properties"); + if (menuProperties) { + menuProperties.setAttribute("hidden", "true"); + } + + var favoriteFolder = document.getElementById("menu_favoriteFolder"); + if (favoriteFolder) { + favoriteFolder.setAttribute("disabled", "true"); + favoriteFolder.setAttribute("hidden", "true"); + } + + favoriteFolder = document.getElementById("appmenu_favoriteFolder"); + if (favoriteFolder) { + favoriteFolder.setAttribute("disabled", "true"); + favoriteFolder.setAttribute("hidden", "true"); + } + + var compactFolderMenu = document.getElementById("menu_compactFolder"); + if (compactFolderMenu) { + compactFolderMenu.setAttribute("hidden", "true"); + } + + let trashSeparator = document.getElementById("trashMenuSeparator"); + if (trashSeparator) { + trashSeparator.setAttribute("hidden", "true"); + } + + let goStartPageSeparator = document.getElementById("goNextSeparator"); + if (goStartPageSeparator) { + goStartPageSeparator.hidden = true; + } + + let goRecentlyClosedTabsSeparator = document.getElementById( + "goRecentlyClosedTabsSeparator" + ); + if (goRecentlyClosedTabsSeparator) { + goRecentlyClosedTabsSeparator.setAttribute("hidden", "true"); + } + + let goFolder = document.getElementById("goFolderMenu"); + if (goFolder) { + goFolder.hidden = true; + } + + goFolder = document.getElementById("goFolderSeparator"); + if (goFolder) { + goFolder.hidden = true; + } + + let goStartPage = document.getElementById("goStartPage"); + if (goStartPage) { + goStartPage.hidden = true; + } + + let quickFilterBar = document.getElementById("appmenu_quickFilterBar"); + if (quickFilterBar) { + quickFilterBar.hidden = true; + } + + var menuFileClose = document.getElementById("menu_close"); + var menuFileQuit = document.getElementById("menu_FileQuitItem"); + if (menuFileClose && menuFileQuit) { + menuFileQuit.parentNode.replaceChild(menuFileClose, menuFileQuit); + } +} +/* eslint-enable complexity */ + +function OnUnloadMessageWindow() { + UnloadCommandUpdateHandlers(); + ToolbarIconColor.uninit(); + PanelUI.uninit(); + OnMailWindowUnload(); +} + +// MessageWindowController object (handles commands when one of the trees does not have focus) +var MessageWindowController = { + supportsCommand(command) { + switch (command) { + case "cmd_undo": + case "cmd_redo": + case "cmd_getMsgsForAuthAccounts": + case "cmd_newMessage": + case "cmd_getNextNMessages": + case "cmd_find": + case "cmd_findAgain": + case "cmd_findPrevious": + case "cmd_reload": + case "cmd_getNewMessages": + case "cmd_settingsOffline": + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + case "cmd_fullZoomToggle": + case "cmd_viewAllHeader": + case "cmd_viewNormalHeader": + case "cmd_stop": + case "cmd_chat": + return true; + case "cmd_synchronizeOffline": + return MailOfflineMgr.isOnline(); + default: + return false; + } + }, + + isCommandEnabled(command) { + switch (command) { + case "cmd_newMessage": + return MailServices.accounts.allIdentities.length > 0; + case "cmd_reload": + case "cmd_find": + case "cmd_stop": + return false; + case "cmd_getNewMessages": + case "cmd_getMsgsForAuthAccounts": + return IsGetNewMessagesEnabled(); + case "cmd_getNextNMessages": + return IsGetNextNMessagesEnabled(); + case "cmd_synchronizeOffline": + return MailOfflineMgr.isOnline(); + case "cmd_settingsOffline": + return IsAccountOfflineEnabled(); + case "cmd_findAgain": + case "cmd_findPrevious": + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + case "cmd_fullZoomToggle": + case "cmd_viewAllHeader": + case "cmd_viewNormalHeader": + return true; + case "cmd_undo": + case "cmd_redo": + return SetupUndoRedoCommand(command); + case "cmd_chat": + return true; + default: + return false; + } + }, + + doCommand(command) { + // If the user invoked a key short cut then it is possible that we got here + // for a command which is really disabled. Kick out if the command should be disabled. + if (!this.isCommandEnabled(command)) { + return; + } + + switch (command) { + case "cmd_getNewMessages": + MsgGetMessage(); + break; + case "cmd_undo": + messenger.undo(msgWindow); + break; + case "cmd_redo": + messenger.redo(msgWindow); + break; + case "cmd_getMsgsForAuthAccounts": + MsgGetMessagesForAllAuthenticatedAccounts(); + break; + case "cmd_getNextNMessages": + MsgGetNextNMessages(); + break; + case "cmd_newMessage": + MsgNewMessage(null); + break; + case "cmd_reload": + ReloadMessage(); + break; + case "cmd_find": + document.getElementById("FindToolbar").onFindCommand(); + break; + case "cmd_findAgain": + document.getElementById("FindToolbar").onFindAgainCommand(false); + break; + case "cmd_findPrevious": + document.getElementById("FindToolbar").onFindAgainCommand(true); + break; + case "cmd_viewAllHeader": + MsgViewAllHeaders(); + return; + case "cmd_viewNormalHeader": + MsgViewNormalHeaders(); + return; + case "cmd_synchronizeOffline": + MsgSynchronizeOffline(); + return; + case "cmd_settingsOffline": + MailOfflineMgr.openOfflineAccountSettings(); + return; + case "cmd_fullZoomReduce": + ZoomManager.reduce(); + break; + case "cmd_fullZoomEnlarge": + ZoomManager.enlarge(); + break; + case "cmd_fullZoomReset": + ZoomManager.reset(); + break; + case "cmd_fullZoomToggle": + ZoomManager.toggleZoom(); + break; + case "cmd_stop": + msgWindow.StopUrls(); + break; + case "cmd_chat": + let win = Services.wm.getMostRecentWindow("mail:3pane"); + if (win) { + win.focus(); + win.showChatTab(); + } else { + window.openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar", + null, + { tabType: "chat", tabParams: {} } + ); + } + break; + } + }, + + onEvent(event) {}, +}; + +function SetupCommandUpdateHandlers() { + top.controllers.insertControllerAt(0, MessageWindowController); + top.controllers.insertControllerAt( + 0, + messageBrowser.contentWindow.commandController + ); +} + +function UnloadCommandUpdateHandlers() { + top.controllers.removeController(MessageWindowController); + top.controllers.removeController( + messageBrowser.contentWindow.commandController + ); +} + +/** + * Message history popup implementation from mail-go-button ported for the old + * mail toolbar. + * + * @param {XULPopupElement} popup + */ +function messageHistoryMenu_init(popup) { + const { messageHistory } = messageBrowser.contentWindow; + const { entries, currentIndex } = messageHistory.getHistory(); + + // For populating the back menu, we want the most recently visited + // messages first in the menu. So we go backward from curPos to 0. + // For the forward menu, we want to go forward from curPos to the end. + const items = []; + const relativePositionBase = entries.length - 1 - currentIndex; + for (const [index, entry] of entries.reverse().entries()) { + const folder = MailServices.folderLookup.getFolderForURL(entry.folderURI); + if (!folder) { + // Where did the folder go? + continue; + } + + let menuText = ""; + let msgHdr; + try { + msgHdr = MailServices.messageServiceFromURI( + entry.messageURI + ).messageURIToMsgHdr(entry.messageURI); + } catch (ex) { + // Let's just ignore this history entry. + continue; + } + const messageSubject = msgHdr.mime2DecodedSubject; + const messageAuthor = msgHdr.mime2DecodedAuthor; + + if (!messageAuthor && !messageSubject) { + // Avoid empty entries in the menu. The message was most likely (re)moved. + continue; + } + + // If the message was not being displayed via the current folder, prepend + // the folder name. We do not need to check underlying folders for + // virtual folders because 'folder' is the display folder, not the + // underlying one. + if (folder != messageBrowser.contentWindow.gFolder) { + menuText = folder.prettyName + " - "; + } + + let subject = ""; + if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) { + subject = "Re: "; + } + if (messageSubject) { + subject += messageSubject; + } + if (subject) { + menuText += subject + " - "; + } + + menuText += messageAuthor; + const newMenuItem = document.createXULElement("menuitem"); + newMenuItem.setAttribute("label", menuText); + const relativePosition = relativePositionBase - index; + newMenuItem.setAttribute("value", relativePosition); + newMenuItem.addEventListener("command", commandEvent => { + navigateToUri(commandEvent.target); + commandEvent.stopPropagation(); + }); + if (relativePosition === 0 && !messageHistory.canPop(0)) { + newMenuItem.setAttribute("checked", true); + newMenuItem.setAttribute("type", "radio"); + } + items.push(newMenuItem); + } + popup.replaceChildren(...items); +} + +/** + * Select the message in the appropriate folder for the history popup entry. + * Finds the message based on the value of the item, which is the relative + * index of the item in the message history. + * + * @param {Element} target + */ +function navigateToUri(target) { + const nsMsgViewIndex_None = 0xffffffff; + const historyIndex = Number.parseInt(target.getAttribute("value"), 10); + const currentWindow = messageBrowser.contentWindow; + const { messageHistory } = currentWindow; + if (!messageHistory || !messageHistory.canPop(historyIndex)) { + return; + } + const item = messageHistory.pop(historyIndex); + + if ( + currentWindow.displayFolder && + currentWindow.gFolder?.URI !== item.folderURI + ) { + const folder = MailServices.folderLookup.getFolderForURL(item.folderURI); + currentWindow.displayFolder(folder); + } + const msgHdr = MailServices.messageServiceFromURI( + item.messageURI + ).messageURIToMsgHdr(item.messageURI); + const index = currentWindow.gDBView.findIndexOfMsgHdr(msgHdr, true); + if (index != nsMsgViewIndex_None) { + currentWindow.gViewWrapper.dbView.selection.select(index); + currentWindow.displayMessage( + currentWindow.gViewWrapper.dbView.URIForFirstSelectedMessage, + currentWindow.gViewWrapper + ); + } +} + +function backToolbarMenu_init(popup) { + messageHistoryMenu_init(popup); +} + +function forwardToolbarMenu_init(popup) { + messageHistoryMenu_init(popup); +} + +function GetSelectedMsgFolders() { + return [messageBrowser.contentWindow.gFolder]; +} diff --git a/comm/mail/base/content/messageWindow.xhtml b/comm/mail/base/content/messageWindow.xhtml new file mode 100644 index 0000000000..55b11f9c5d --- /dev/null +++ b/comm/mail/base/content/messageWindow.xhtml @@ -0,0 +1,484 @@ +<?xml version="1.0"?> +# 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/. + +#filter substitution + +<?xml-stylesheet href="chrome://messenger/skin/messageWindow.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/popupPanel.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/panelUI.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/calendar.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-toolbar.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?> + +<!DOCTYPE html [ +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" > +%brandDTD; +<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd"> +%msgHdrViewOverlayDTD; +<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" > +%messengerDTD; +<!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd"> +%customizeToolbarDTD; +<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd"> +%utilityDTD; +<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" > +%msgViewPickerDTD; +<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd"> +%baseMenuOverlayDTD; +<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd"> +%utilityDTD; +<!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd"> +%viewZoomOverlayDTD; +<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" > +%msgViewPickerDTD; +<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd"> +%lightningDTD; +<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" > +%calendarDTD; +<!ENTITY % calendarMenuOverlayDTD SYSTEM "chrome://calendar/locale/menuOverlay.dtd" > +%calendarMenuOverlayDTD; +<!ENTITY % toolbarDTD SYSTEM "chrome://lightning/locale/lightning-toolbar.dtd"> +%toolbarDTD; +<!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > +%eventDialogDTD; +<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd"> +%smimeDTD; +]> + +<!-- + - This window displays a single message. + --> +<html id="messengerWindow" xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + icon="messengerWindow" + scrolling="false" + titlemodifier="&titledefault.label;@PRE_RELEASE_SUFFIX@" + titlemenuseparator="&titleSeparator.label;" + persist="width height screenX screenY sizemode" + toggletoolbar="true" + windowtype="mail:messageWindow" +#ifdef XP_MACOSX + macanimationtype="document" + chromemargin="0,-1,-1,-1" +#endif + lightweightthemes="true" + fullscreenbutton="true"> +<head> + <title>&titledefault.label;@PRE_RELEASE_SUFFIX@</title> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="messenger/messenger.ftl" /> + <link rel="localization" href="toolkit/main-window/findbar.ftl" /> + <link rel="localization" href="toolkit/global/textActions.ftl" /> + <link rel="localization" href="toolkit/printing/printUI.ftl" /> + <link rel="localization" href="messenger/menubar.ftl" /> + <link rel="localization" href="messenger/appmenu.ftl" /> + <link rel="localization" href="messenger/openpgp/openpgp.ftl" /> + <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" /> + + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script> + <script defer="defer" src="chrome://messenger/content/messageWindow.js"></script> + <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script> + <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script> + <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script> + <script defer="defer" src="chrome://messenger/content/mailCommands.js"></script> + <script defer="defer" src="chrome://messenger/content/mailWindowOverlay.js"></script> + <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script> + <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script> + <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/mailCore.js"></script> +#ifdef NIGHTLY_BUILD + <script defer="defer" src="chrome://messenger/content/sync.js"></script> +#endif + <script defer="defer" src="chrome://messenger/content/panelUI.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/toolbarbutton-badge-button.js"></script> + <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script> +#ifdef XP_MACOSX + <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script> +#endif + <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-chrome-startup.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-management.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-extract.js"></script> +</head> +<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/> + <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/> + + <commandset id="mailCommands"> +#include mainCommandSet.inc.xhtml + <commandset id="mailSearchMenuItems"/> + <commandset id="attachmentCommands"> + <command id="cmd_openAllAttachments" + oncommand="goDoCommand('cmd_openAllAttachments');"/> + <command id="cmd_saveAllAttachments" + oncommand="goDoCommand('cmd_saveAllAttachments');"/> + <command id="cmd_detachAllAttachments" + oncommand="goDoCommand('cmd_detachAllAttachments');"/> + <command id="cmd_deleteAllAttachments" + oncommand="goDoCommand('cmd_deleteAllAttachments');"/> + </commandset> + <commandset id="tasksCommands"> + <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/> + <command id="cmd_newCard" oncommand="openNewCardDialog()"/> + </commandset> + <commandset id="commandKeys"/> + <command id="cmd_close" oncommand="window.close();"/> + </commandset> + + <keyset id="mailKeys"> + <key keycode="VK_ESCAPE" oncommand="window.close();"/> +#include mainKeySet.inc.xhtml + <keyset id="tasksKeys"> +#ifdef XP_MACOSX + <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage" + modifiers="accel,shift"/> + <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage" + modifiers="accel"/> +#else + <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage" + modifiers="accel"/> + <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage" + modifiers="accel"/> +#endif + </keyset> + </keyset> + + <popupset id="mainPopupSet"> +#include widgets/browserPopups.inc.xhtml +#include widgets/toolbarContext.inc.xhtml +<!-- The panelUI is for the appmenu. --> +#include ../../components/customizableui/content/panelUI.inc.xhtml + </popupset> + + <toolbox id="mail-toolbox" + class="mail-toolbox" + mode="full" + defaultmode="full" +#ifdef XP_MACOSX + iconsize="small" + defaulticonsize="small" +#endif + labelalign="end" + defaultlabelalign="end"> +#ifdef XP_MACOSX + <hbox id="titlebar"> + <hbox id="titlebar-title" align="center" flex="1"> + <label id="titlebar-title-label" value="&titledefault.label;" flex="1" crop="end"/> + </hbox> +#include messenger-titlebar-items.inc.xhtml + </hbox> +#endif + <!-- Menu --> + <toolbar is="customizable-toolbar" id="toolbar-menubar" + class="chromeclass-menubar themeable-full" + type="menubar" + customizable="true" + toolboxid="mail-toolbox" +#ifdef XP_MACOSX + defaultset="menubar-items" + autohide="true" +#else + defaultset="menubar-items,spring" +#endif +#ifndef XP_MACOSX + data-l10n-id="toolbar-context-menu-menu-bar" + data-l10n-attrs="toolbarname" +#endif + context="toolbar-context-menu" + mode="icons" + insertbefore="tabs-toolbar" + prependmenuitem="true"> + <toolbaritem id="menubar-items" align="center"> +# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it +# can be shared with other top level windows. +#include messenger-menubar.inc.xhtml + </toolbaritem> + </toolbar> + <!-- mail-toolbox with the main toolbarbuttons --> + <toolbarpalette id="MailToolbarPalette"> + <toolbarbutton is="toolbarbutton-menu-button" id="button-getmsg" + type="menu" + class="toolbarbutton-1" + label="&getMsgButton1.label;" + tooltiptext="&getMsgButton.tooltip;" + command="cmd_getNewMessages"> + <menupopup is="folder-menupopup" id="button-getMsgPopup" + onpopupshowing="getMsgToolbarMenu_init();" + oncommand="MsgGetMessagesForAccount(event.target._folder); event.stopPropagation();" + expandFolders="false" + mode="getMail"> + <menuitem id="button-getAllNewMsg" + class="menuitem-iconic folderMenuItem" + label="&getAllNewMsgCmd.label;" + accesskey="&getAllNewMsgCmd.accesskey;" + command="cmd_getMsgsForAuthAccounts"/> + <menuseparator id="button-getAllNewMsgSeparator"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-newmsg" + class="toolbarbutton-1" + label="&newMsgButton.label;" + tooltiptext="&newMsgButton.tooltip;" + command="cmd_newMessage"/> + <toolbarbutton id="button-file" + type="menu" + wantdropmarker="true" + class="toolbarbutton-1" + label="&fileButton.label;" + tooltiptext="&fileButton.tooltip;" + oncommand="goDoCommand('cmd_moveMessage', event.target._folder)"> + <menupopup is="folder-menupopup" id="button-filePopup" + mode="filing" + showRecent="true" + showFileHereLabel="true" + recentLabel="&moveCopyMsgRecentMenu.label;" + recentAccessKey="&moveCopyMsgRecentMenu.accesskey;"/> + </toolbarbutton> + <toolbarbutton id="button-showconversation" + class="toolbarbutton-1" + label="&openConversationButton.label;" + tooltiptext="&openMsgConversationButton.tooltip;" + command="cmd_openConversation"/> + <toolbarbutton is="toolbarbutton-menu-button" id="button-goback" + type="menu" + class="toolbarbutton-1" + label="&backButton1.label;" + command="cmd_goBack" + tooltiptext="&goBackButton.tooltip;"> + <menupopup id="button-goBackPopup" onpopupshowing="backToolbarMenu_init(this)"> + <menuitem id="button-goBack" label="&goBackCmd.label;" command="cmd_goBack"/> + <menuseparator id="button-goBackSeparator"/> + </menupopup> + </toolbarbutton> + <toolbarbutton is="toolbarbutton-menu-button" id="button-goforward" + type="menu" + class="toolbarbutton-1" + label="&goForwardButton1.label;" + command="cmd_goForward" + tooltiptext="&goForwardButton.tooltip;"> + <menupopup id="button-goForwardPopup" onpopupshowing="forwardToolbarMenu_init(this)"> + <menuitem id="button-goForward" + label="&goForwardCmd.label;" + command="cmd_goForward"/> + <menuseparator id="button-goForwardSeparator"/> + </menupopup> + </toolbarbutton> + <toolbaritem id="button-previous" + title="&previousButtonToolbarItem.label;" + align="center" + class="chromeclass-toolbar-additional"> + <toolbarbutton id="button-previousUnread" + class="toolbarbutton-1" + label="&previousButton.label;" + command="cmd_previousUnreadMsg" + tooltiptext="&previousButton.tooltip;"/> + </toolbaritem> + <toolbarbutton id="button-previousMsg" + class="toolbarbutton-1" + label="&previousMsgButton.label;" + command="cmd_previousMsg" + tooltiptext="&previousMsgButton.tooltip;"/> + <toolbaritem id="button-next" + title="&nextButtonToolbarItem.label;" + align="center" + class="chromeclass-toolbar-additional"> + <toolbarbutton id="button-nextUnread" + class="toolbarbutton-1" + label="&nextButton.label;" + command="cmd_nextUnreadMsg" + tooltiptext="&nextButton.tooltip;"/> + </toolbaritem> + <toolbarbutton id="button-nextMsg" + class="toolbarbutton-1" + label="&nextMsgButton.label;" + command="cmd_nextMsg" + tooltiptext="&nextMsgButton.tooltip;"/> + <toolbarbutton id="button-print" + class="toolbarbutton-1" + label="&printButton.label;" + command="cmd_print" + tooltiptext="&printButton.tooltip;"/> + <toolbarbutton is="toolbarbutton-menu-button" id="button-mark" + type="menu" + class="toolbarbutton-1" + label="&markButton.label;" + tooltiptext="&markButton.tooltip;"> + <menupopup id="button-markPopup" onpopupshowing="InitMessageMark()"> + <menuitem id="markReadToolbarItem" + label="&markAsReadCmd.label;" + accesskey="&markAsReadCmd.accesskey;" + key="key_toggleRead" + command="cmd_markAsRead"/> + <menuitem id="markUnreadToolbarItem" + label="&markAsUnreadCmd.label;" + accesskey="&markAsUnreadCmd.accesskey;" + key="key_toggleRead" + command="cmd_markAsUnread"/> + <menuitem id="button-markThreadAsRead" + label="&markThreadAsReadCmd.label;" + key="key_markThreadAsRead" + accesskey="&markThreadAsReadCmd.accesskey;" + command="cmd_markThreadAsRead"/> + <menuitem id="button-markReadByDate" + label="&markReadByDateCmd.label;" + key="key_markReadByDate" + accesskey="&markReadByDateCmd.accesskey;" + command="cmd_markReadByDate"/> + <menuitem id="button-markAllRead" + label="&markAllReadCmd.label;" + key="key_markAllRead" + accesskey="&markAllReadCmd.accesskey;" + command="cmd_markAllRead"/> + <menuseparator id="button-markAllReadSeparator"/> + <menuitem id="markFlaggedToolbarItem" + type="checkbox" + label="&markStarredCmd.label;" + accesskey="&markStarredCmd.accesskey;" + key="key_toggleFlagged" + command="cmd_markAsFlagged"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-tag" + type="menu" + wantdropmarker="true" + class="toolbarbutton-1" + label="&tagButton.label;" + tooltiptext="&tagButton.tooltip;" + command="cmd_tag"> + <menupopup id="button-tagpopup" + onpopupshowing="InitMessageTags(this);"> + <menuitem id="button-addNewTag" + label="&addNewTag.label;" + accesskey="&addNewTag.accesskey;" + command="cmd_addTag"/> + <menuitem id="button-manageTags" + label="&manageTags.label;" + accesskey="&manageTags.accesskey;" + command="cmd_manageTags"/> + <menuseparator id="button-tagpopup-sep-afterTagAddNew"/> + <menuitem id="button-tagRemoveAll" + command="cmd_removeTags"/> + <menuseparator id="button-afterTagRemoveAllSeparator"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-address" + class="toolbarbutton-1" + label="&addressBookButton.label;" + oncommand="toAddressBook();" + tooltiptext="&addressBookButton.tooltip;"/> + <toolbarbutton is="toolbarbutton-badge-button" id="button-chat" + image="chrome://messenger/skin/icons/new/compact/chat.svg" + class="toolbarbutton-1" + label="&chatButton.label;" + command="cmd_chat" + observes="cmd_chat" + tooltiptext="&chatButton.tooltip;"/> + <toolbaritem id="throbber-box" title="&throbberItem.title;"> + <!-- NOTE: We only display up to one of these images at any given time. + - Only show the static icon when customizing the toolbar. + - Only show the animated icon when we are not customizing the toolbar + - and there is some activity. + - Once loading animation is handled by CSS, we can use a single image + - here instead. --> + <html:img class="animated-throbber-icon" + src="chrome://global/skin/icons/loading.png" + srcset="chrome://global/skin/icons/loading@2x.png 2x" + alt="" /> + <html:img class="static-throbber-icon" + src="chrome://messenger/skin/icons/notloading.png" + srcset="chrome://messenger/skin/icons/notloading@2x.png 2x" + alt="" /> + </toolbaritem> + + <toolbarbutton id="button-addons" class="toolbarbutton-1" + data-l10n-id="addons-and-themes-toolbarbutton" + oncommand="openAddonsMgr();"/> + + <toolbarbutton id="lightning-button-calendar" + class="toolbarbutton-1" + label="&lightning.toolbar.calendar.label;" + tooltiptext="&lightning.toolbar.calendar.tooltip;" + command="new_calendar_tab"/> + <toolbarbutton id="lightning-button-tasks" + class="toolbarbutton-1" + label="&lightning.toolbar.task.label;" + tooltiptext="&lightning.toolbar.task.tooltip;" + command="new_task_tab"/> + <toolbarbutton is="toolbarbutton-menu-button" id="extractEventButton" + type="menu" + class="toolbarbutton-1" + label="&calendar.extract.event.button;" + tooltiptext="&calendar.extract.event.button.tooltip;" + oncommand="calendarExtract.extractFromEmail(document.getElementById('messageBrowser').contentWindow.gMessage, true);"> + <menupopup id="extractEventLocaleList" + oncommand="calendarExtract.extractWithLocale(event, true);" + onpopupshowing="calendarExtract.onShowLocaleMenu(event.target);"/> + </toolbarbutton> + <toolbarbutton is="toolbarbutton-menu-button" id="extractTaskButton" + type="menu" + class="toolbarbutton-1" + label="&calendar.extract.task.button;" + tooltiptext="&calendar.extract.task.button.tooltip;" + oncommand="calendarExtract.extractFromEmail(document.getElementById('messageBrowser').contentWindow.gMessage, false);"> + <menupopup id="extractTaskLocaleList" + oncommand="calendarExtract.extractWithLocale(event, false);" + onpopupshowing="calendarExtract.onShowLocaleMenu(event.target);"/> + </toolbarbutton> + </toolbarpalette> + + <!-- If changes are made to the default set of toolbar buttons, you may need to rev the id + of mail-bar in order to force the new default items to show up for users who customized their toolbar + in earlier versions. Bumping the id means users will have to re-customize their toolbar! + --> + + <toolbar is="customizable-toolbar" id="mail-bar3" + class="inline-toolbar chromeclass-toolbar themeable-full" + toolbarname="&showMessengerToolbarCmd.label;" + accesskey="&showMessengerToolbarCmd.accesskey;" + fullscreentoolbar="true" mode="full" + customizable="true" + context="toolbar-context-menu" +#ifdef XP_MACOSX + iconsize="small" + defaultset="button-getmsg,button-newmsg,spacer,button-tag,qfb-show-filter-bar,spring"> +#else + defaultset="button-getmsg,button-newmsg,separator,button-tag,qfb-show-filter-bar,spring"> +#endif + </toolbar> + </toolbox> + + <stack flex="1" class="printPreviewStack"> + <browser id="messageBrowser" + flex="1" + src="about:message" + autocompletepopup="PopupAutoComplete" + messagemanagergroup="single-page"/> + </stack> + + <panel id="customizeToolbarSheetPopup" noautohide="true"> + <iframe id="customizeToolbarSheetIFrame" + style="&dialog.dimensions;" + hidden="true"/> + </panel> + + <hbox id="status-bar" class="statusbar chromeclass-status" role="status"> +#include mainStatusbar.inc.xhtml + </hbox> + +#include tabDialogs.inc.xhtml +</html:body> +</html> diff --git a/comm/mail/base/content/messenger-customization.js b/comm/mail/base/content/messenger-customization.js new file mode 100644 index 0000000000..bf6fc46834 --- /dev/null +++ b/comm/mail/base/content/messenger-customization.js @@ -0,0 +1,185 @@ +/** + * 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 { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +var AutoHideMenubar = { + get _node() { + delete this._node; + return (this._node = + document.getElementById("toolbar-menubar") || + document.getElementById("compose-toolbar-menubar2") || + document.getElementById("addrbook-toolbar-menubar2")); + }, + + _contextMenuListener: { + contextMenu: null, + + get active() { + return !!this.contextMenu; + }, + + init(event) { + // Ignore mousedowns in <menupopup>s. + if (event.target.closest("menupopup")) { + return; + } + + let contextMenuId = AutoHideMenubar._node.getAttribute("context"); + this.contextMenu = document.getElementById(contextMenuId); + this.contextMenu.addEventListener("popupshown", this); + this.contextMenu.addEventListener("popuphiding", this); + AutoHideMenubar._node.addEventListener("mousemove", this); + }, + + handleEvent(event) { + switch (event.type) { + case "popupshown": + AutoHideMenubar._node.removeEventListener("mousemove", this); + break; + case "popuphiding": + case "mousemove": + AutoHideMenubar._setInactiveAsync(); + AutoHideMenubar._node.removeEventListener("mousemove", this); + this.contextMenu.removeEventListener("popuphiding", this); + this.contextMenu.removeEventListener("popupshown", this); + this.contextMenu = null; + break; + } + }, + }, + + init() { + this._node.addEventListener("toolbarvisibilitychange", this); + this._enable(); + }, + + _updateState() { + if (this._node.getAttribute("autohide") == "true") { + this._enable(); + } else { + this._disable(); + } + }, + + _events: [ + "DOMMenuBarInactive", + "DOMMenuBarActive", + "popupshowing", + "mousedown", + ], + _enable() { + this._node.setAttribute("inactive", "true"); + for (let event of this._events) { + this._node.addEventListener(event, this); + } + }, + + _disable() { + this._setActive(); + for (let event of this._events) { + this._node.removeEventListener(event, this); + } + }, + + handleEvent(event) { + switch (event.type) { + case "toolbarvisibilitychange": + this._updateState(); + break; + case "popupshowing": + // fall through + case "DOMMenuBarActive": + this._setActive(); + break; + case "mousedown": + if (event.button == 2) { + this._contextMenuListener.init(event); + } + break; + case "DOMMenuBarInactive": + if (!this._contextMenuListener.active) { + this._setInactiveAsync(); + } + break; + } + }, + + _setInactiveAsync() { + this._inactiveTimeout = setTimeout(() => { + if (this._node.getAttribute("autohide") == "true") { + this._inactiveTimeout = null; + this._node.setAttribute("inactive", "true"); + } + }, 0); + }, + + _setActive() { + if (this._inactiveTimeout) { + clearTimeout(this._inactiveTimeout); + this._inactiveTimeout = null; + } + this._node.removeAttribute("inactive"); + }, +}; + +var ToolbarContextMenu = { + _getExtensionId(popup) { + let node = popup.triggerNode; + if (!node) { + return null; + } + if (node.hasAttribute("data-extensionid")) { + return node.getAttribute("data-extensionid"); + } + const extensionButton = node.closest('[item-id^="ext-"]'); + return extensionButton?.getAttribute("item-id").slice(4); + }, + + async updateExtension(popup) { + let removeExtension = popup.querySelector( + ".customize-context-removeExtension" + ); + let manageExtension = popup.querySelector( + ".customize-context-manageExtension" + ); + let separator = popup.querySelector("#extensionsMailToolbarMenuSeparator"); + let id = this._getExtensionId(popup); + let addon = id && (await AddonManager.getAddonByID(id)); + + for (let element of [removeExtension, manageExtension, separator]) { + if (!element) { + continue; + } + + element.hidden = !addon; + } + + if (addon) { + removeExtension.disabled = !( + addon.permissions & AddonManager.PERM_CAN_UNINSTALL + ); + } + }, + + async removeExtensionForContextAction(popup) { + let id = this._getExtensionId(popup); + + // This can be called from a composeAction button, where + // popup.ownerGlobal.BrowserAddonUI is undefined. + let win = Services.wm.getMostRecentWindow("mail:3pane"); + await win.BrowserAddonUI.removeAddon(id); + }, + + openAboutAddonsForContextAction(popup) { + let id = this._getExtensionId(popup); + if (id) { + let viewID = "addons://detail/" + encodeURIComponent(id); + popup.ownerGlobal.openAddonsMgr(viewID); + } + }, +}; diff --git a/comm/mail/base/content/messenger-doctype.inc.dtd b/comm/mail/base/content/messenger-doctype.inc.dtd new file mode 100644 index 0000000000..0d42d56feb --- /dev/null +++ b/comm/mail/base/content/messenger-doctype.inc.dtd @@ -0,0 +1,42 @@ +# 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/. + +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" > +%brandDTD; +<!ENTITY % msgHdrViewOverlayDTD SYSTEM "chrome://messenger/locale/msgHdrViewOverlay.dtd"> +%msgHdrViewOverlayDTD; +<!ENTITY % messengerDTD SYSTEM "chrome://messenger/locale/messenger.dtd" > +%messengerDTD; +<!ENTITY % chatDTD SYSTEM "chrome://messenger/locale/chat.dtd"> +%chatDTD; +<!ENTITY % customizeToolbarDTD SYSTEM "chrome://messenger/locale/customizeToolbar.dtd"> +%customizeToolbarDTD; +<!ENTITY % tabMailDTD SYSTEM "chrome://messenger/locale/tabmail.dtd" > +%tabMailDTD; +<!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd"> +%utilityDTD; +<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" > +%msgViewPickerDTD; +<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd"> +%baseMenuOverlayDTD; +<!ENTITY % viewZoomOverlayDTD SYSTEM "chrome://messenger/locale/viewZoomOverlay.dtd"> +%viewZoomOverlayDTD; +<!ENTITY % msgViewPickerDTD SYSTEM "chrome://messenger/locale/msgViewPickerOverlay.dtd" > +%msgViewPickerDTD; +<!ENTITY % calendarGlobalDTD SYSTEM "chrome://calendar/locale/global.dtd"> +%calendarGlobalDTD; +<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd"> +%calendarDTD; +<!ENTITY % calendarMenuOverlayDTD SYSTEM "chrome://calendar/locale/menuOverlay.dtd" > +%calendarMenuOverlayDTD; +<!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> +%eventDialogDTD; +<!ENTITY % lightningDTD SYSTEM "chrome://lightning/locale/lightning.dtd"> +%lightningDTD; +<!ENTITY % lightningToolbarDTD SYSTEM "chrome://lightning/locale/lightning-toolbar.dtd" > +%lightningToolbarDTD; +<!ENTITY % mailOverlayDTD SYSTEM "chrome://messenger/locale/mailOverlay.dtd"> +%mailOverlayDTD; +<!ENTITY % smimeDTD SYSTEM "chrome://messenger-smime/locale/msgReadSecurityInfo.dtd"> +%smimeDTD; diff --git a/comm/mail/base/content/messenger-menubar.inc.xhtml b/comm/mail/base/content/messenger-menubar.inc.xhtml new file mode 100644 index 0000000000..1a0859998e --- /dev/null +++ b/comm/mail/base/content/messenger-menubar.inc.xhtml @@ -0,0 +1,1271 @@ +# 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/. + +<menubar id="mail-menubar"> + <!-- File --> + <menu id="menu_File" + label="&fileMenu.label;" + accesskey="&fileMenu.accesskey;"> + <menupopup id="menu_FilePopup" onpopupshowing="file_init();"> + <menu id="menu_New" + label="&newMenu.label;" + accesskey="&newMenu.accesskey;"> + <menupopup id="menu_NewPopup" onpopupshowing="menu_new_init();"> + <menuitem id="menu_newNewMsgCmd" label="&newNewMsgCmd.label;" + accesskey="&newNewMsgCmd.accesskey;" + key="key_newMessage2" + command="cmd_newMessage"/> +#ifdef MAIN_WINDOW + <menuitem id="calendar-new-event-menuitem" + class="hide-when-calendar-deactivated" + label="&lightning.menupopup.new.event.label;" + accesskey="&lightning.menupopup.new.event.accesskey;" + key="calendar-new-event-key" + command="calendar_new_event_command"/> + <menuitem id="calendar-new-task-menuitem" + class="hide-when-calendar-deactivated" + label="&lightning.menupopup.new.task.label;" + accesskey="&lightning.menupopup.new.task.accesskey;" + key="calendar-new-todo-key" + command="calendar_new_todo_command"/> + <menuseparator id="calendar-after-new-task-menuseparator" + class="hide-when-calendar-deactivated" + observes="menu_newFolder"/> +#endif + <menuitem id="menu_newFolder" label="&newFolderCmd.label;" + command="cmd_newFolder" + accesskey="&newFolderCmd.accesskey;"/> + <menuitem id="menu_newVirtualFolder" label="&newVirtualFolderCmd.label;" + command="cmd_newVirtualFolder" + accesskey="&newVirtualFolderCmd.accesskey;"/> + <menuseparator id="newAccountPopupMenuSeparator"/> + <menuitem id="newCreateEmailAccountMenuItem" + label="&newCreateEmailAccountCmd.label;" + accesskey="&newCreateEmailAccountCmd.accesskey;" + oncommand="openAccountProvisionerTab();"/> + <menuitem id="newMailAccountMenuItem" + label="&newExistingEmailAccountCmd.label;" + accesskey="&newExistingEmailAccountCmd.accesskey;" + oncommand="openAccountSetupTab();"/> + <menuitem id="newIMAccountMenuItem" + label="&newIMAccountCmd.label;" + accesskey="&newIMAccountCmd.accesskey;" + oncommand="openIMAccountWizard();"/> + <menuitem id="newFeedAccountMenuItem" + label="&newFeedAccountCmd.label;" + accesskey="&newFeedAccountCmd.accesskey;" + oncommand="AddFeedAccount();"/> + <menuitem id="newNewsgroupAccountMenuItem" + data-l10n-id="file-new-newsgroup-account" + oncommand="openNewsgroupAccountWizard();"/> +#ifdef MAIN_WINDOW + <menuitem id="calendar-new-calendar-menuitem" + label="&lightning.menupopup.new.calendar.label;" + command="calendar_new_calendar_command" + accesskey="&lightning.menupopup.new.calendar.accesskey;"/> +#endif + <menuseparator id="newPopupMenuSeparator"/> + <menuitem id="menu_newCard" + label="&newContactCmd.label;" + accesskey="&newContactCmd.accesskey;" + command="cmd_newCard"/> + <menuitem id="newIMContactMenuItem" + label="&newIMContactCmd.label;" + accesskey="&newIMContactCmd.accesskey;" + command="cmd_addChatBuddy"/> + </menupopup> + </menu> + <menu id="menu_Open" + mode="calendar" + label="&openMenuCmd.label;" + accesskey="&openMenuCmd.accesskey;"> + <menupopup id="menu_OpenPopup"> + <menuitem id="openMessageFileMenuitem" + label="&openMessageFileCmd.label;" + accesskey="&openMessageFileCmd.accesskey;" + oncommand="MsgOpenFromFile();"/> +#ifdef MAIN_WINDOW + <menuitem id="calendar-open-calendar-file-menuitem" + label="&lightning.menupopup.open.calendar.label;" + accesskey="&lightning.menupopup.open.calendar.accesskey;" + oncommand="openLocalCalendar();"/> +#endif + </menupopup> + </menu> + <menuitem id="menu_close" + label="&closeCmd.label;" + key="key_close" + accesskey="&closeCmd.accesskey;" + command="cmd_close"/> + <menuseparator id="fileMenuAfterCloseSeparator"/> +#ifdef MAIN_WINDOW + <menuitem id="calendar-save-menuitem" + class="hide-when-calendar-deactivated" + label="&event.menu.item.save.label;" + accesskey="&event.menu.item.save.tab.accesskey;" + key="save-key" + command="cmd_save"/> + <menuitem id="calendar-save-and-close-menuitem" + class="hide-when-calendar-deactivated" + label="&event.menu.item.saveandclose.label;" + accesskey="&event.menu.item.saveandclose.tab.accesskey;" + command="cmd_accept"/> +#endif + <menu id="menu_saveAs" + label="&saveAsMenu.label;" accesskey="&saveAsMenu.accesskey;"> + <menupopup id="menu_SavePopup"> + <menuitem id="menu_saveAsFile" + data-l10n-id="menu-file-save-as-file" + key="key_saveAsFile" + command="cmd_saveAsFile"/> + <menuitem id="menu_saveAsTemplate" + label="&saveAsTemplateCmd.label;" + accesskey="&saveAsTemplateCmd.accesskey;" + command="cmd_saveAsTemplate"/> + </menupopup> + </menu> + <menuseparator id="fileMenuAfterSaveSeparator"/> + <menu label="&getNewMsgForCmd.label;" accesskey="&getNewMsgForCmd.accesskey;" + id="menu_getAllNewMsg" + oncommand="MsgGetMessagesForAccount();"> + <menupopup is="folder-menupopup" id="menu_getAllNewMsgPopup" + expandFolders="false" + oncommand="MsgGetMessagesForAccount(event.target._folder); event.stopPropagation();"> + <menuitem id="menu_getnewmsgs_all_accounts" + label="&getAllNewMsgCmdPopupMenu.label;" + accesskey="&getAllNewMsgCmdPopupMenu.accesskey;" + key="key_getAllNewMessages" + command="cmd_getMsgsForAuthAccounts"/> + <menuitem id="menu_getnewmsgs_current_account" + label="&getNewMsgCurrentAccountCmdPopupMenu.label;" + accesskey="&getNewMsgCurrentAccountCmdPopupMenu.accesskey;" + key="key_getNewMessages" + command="cmd_getNewMessages"/> + <menuseparator/> + </menupopup> + </menu> + <menuitem id="menu_getnextnmsg" label="&getNextNMsgCmd2.label;" + accesskey="&getNextNMsgCmd2.accesskey;" + command="cmd_getNextNMessages"/> + <menuitem id="menu_sendunsentmsgs" label="&sendUnsentCmd.label;" + accesskey="&sendUnsentCmd.accesskey;" command="cmd_sendUnsentMsgs"/> + <menuitem id="menu_subscribe" label="&subscribeCmd.label;" + accesskey="&subscribeCmd.accesskey;" command="cmd_subscribe"/> + <menuseparator id="fileMenuAfterSubscribeSeparator"/> + <menuitem id="menu_deleteFolder" + data-l10n-id="menu-edit-delete-folder" + command="cmd_deleteFolder"/> + <menuitem id="menu_renameFolder" label="&renameFolder.label;" + accesskey="&renameFolder.accesskey;" +#ifndef XP_MACOSX + key="key_renameFolder" +#endif + command="cmd_renameFolder"/> + <menuitem id="menu_compactFolder" + label="&compactFolders.label;" + accesskey="&compactFolders.accesskey;" + command="cmd_compactFolder"/> + <menuitem id="menu_emptyTrash" label="&emptyTrashCmd.label;" + accesskey="&emptyTrashCmd.accesskey;" + command="cmd_emptyTrash"/> + <menuseparator id="trashMenuSeparator"/> + <menu id="offlineMenuItem" label="&offlineMenu.label;" accesskey="&offlineMenu.accesskey;"> + <menupopup id="menu_OfflinePopup"> + <menuitem id="goOfflineMenuItem" type="checkbox" label="&offlineGoOfflineCmd.label;" + accesskey="&offlineGoOfflineCmd.accesskey;" oncommand="MailOfflineMgr.toggleOfflineStatus();"/> + <menuseparator id="offlineMenuAfterGoSeparator"/> + <menuitem id="menu_synchronizeOffline" + label="&synchronizeOfflineCmd.label;" + accesskey="&synchronizeOfflineCmd.accesskey;" + command="cmd_synchronizeOffline"/> + <menuitem id="menu_settingsOffline" + label="&settingsOfflineCmd2.label;" + accesskey="&settingsOfflineCmd2.accesskey;" + command="cmd_settingsOffline"/> + <menuseparator id="offlineMenuAfterSettingsSeparator"/> + <menuitem id="menu_downloadFlagged" + label="&downloadStarredCmd.label;" + accesskey="&downloadStarredCmd.accesskey;" + command="cmd_downloadFlagged"/> + <menuitem id="menu_downloadSelected" + label="&downloadSelectedCmd.label;" + accesskey="&downloadSelectedCmd.accesskey;" + command="cmd_downloadSelected"/> + </menupopup> + </menu> + <menuseparator id="fileMenuAfterOfflineSeparator"/> + <menuitem id="printMenuItem" + key="key_print" + label="&printCmd.label;" + accesskey="&printCmd.accesskey;" + command="cmd_print"/> + <menuseparator id="menu_FileQuitSeparator"/> + <menuitem id="menu_FileQuitItem" +#ifdef XP_MACOSX + data-l10n-id="menu-quit-mac" +#else + data-l10n-id="menu-quit" +#endif + key="key_quitApplication" + command="cmd_quitApplication"/> + </menupopup> + </menu> + +<!-- Edit --> +<menu id="menu_Edit" + label="&editMenu.label;" + accesskey="&editMenu.accesskey;" + oncommand="CommandUpdate_UndoRedo();"> + <menupopup id="menu_EditPopup" onpopupshowing="InitEditMessagesMenu()"> + <menuitem id="menu_undo" + label="&undoDefaultCmd.label;" + accesskey="&undoDefaultCmd.accesskey;" + key="key_undo" + command="cmd_undo"/> + <menuitem id="menu_redo" + label="&redoDefaultCmd.label;" + accesskey="&redoDefaultCmd.accesskey;" + key="key_redo" + command="cmd_redo"/> + <menuseparator id="editMenuAfterRedoSeparator"/> + <menuitem id="menu_cut" + data-l10n-id="text-action-cut" + key="key_cut" + command="cmd_cut"/> + <menuitem id="menu_copy" + data-l10n-id="text-action-copy" + key="key_copy" + command="cmd_copy"/> + <menuitem id="menu_paste" + data-l10n-id="text-action-paste" + key="key_paste" + command="cmd_paste"/> + <menuitem id="menu_delete" + key="key_delete" + command="cmd_delete"/> + <menuseparator id="editMenuAfterDeleteSeparator"/> + <menu id="menu_select" label="&selectMenu.label;" accesskey="&selectMenu.accesskey;"> + <menupopup id="menu_SelectPopup"> + <menuitem id="menu_SelectAll" label="&all.label;" + accesskey="&all.accesskey;" key="key_selectAll" + command="cmd_selectAll"/> + <menuseparator id="selectMenuSeparator"/> + <menuitem id="menu_selectThread" label="&selectThreadCmd.label;" + accesskey="&selectThreadCmd.accesskey;" + key="key_selectThread" + command="cmd_selectThread"/> + <menuitem id="menu_selectFlagged" + label="&selectFlaggedCmd.label;" + accesskey="&selectFlaggedCmd.accesskey;" + command="cmd_selectFlagged"/> + </menupopup> + </menu> + <menuseparator id="editMenuAfterSelectSeparator"/> + <menu id="menu_find" + label="&findMenu.label;" accesskey="&findMenu.accesskey;"> + <menupopup id="menu_FindPopup" + onpopupshowing="initSearchMessagesMenu()"> + <menuitem id="menu_findCmd" + label="&findCmd.label;" + key="key_find" + accesskey="&findCmd.accesskey;" + command="cmd_find"/> + <menuitem id="menu_findAgainCmd" + label="&findAgainCmd.label;" + key="key_findAgain" + accesskey="&findAgainCmd.accesskey;" + command="cmd_findAgain"/> + <menuseparator id="editMenuAfterFindSeparator"/> + <menuitem id="searchMailCmd" label="&searchMailCmd.label;" + key="key_searchMail" + accesskey="&searchMailCmd.accesskey;" + command="cmd_searchMessages"/> + <menuitem id="glodaSearchCmd" + label="&glodaSearchCmd.label;" + accesskey="&glodaSearchCmd.accesskey;" + oncommand="openGlodaSearchTab()"/> + <menuitem id="searchAddressesCmd" label="&searchAddressesCmd.label;" + accesskey="&searchAddressesCmd.accesskey;" + oncommand="MsgSearchAddresses()"/> + </menupopup> + </menu> + <menuseparator id="editPropertiesSeparator"/> + <menuitem id="menu_favoriteFolder" + type="checkbox" + label="&menuFavoriteFolder.label;" + accesskey="&menuFavoriteFolder.accesskey;" + checked="false" + command="cmd_toggleFavoriteFolder"/> + <menuitem id="menu_properties" + command="cmd_properties"/> +#ifdef MAIN_WINDOW + <menuitem id="calendar-properties-menuitem" + label="&calendar.properties.label;" + accesskey="&calendar.properties.accesskey;" + command="calendar_edit_calendar_command"/> +#endif +#ifdef XP_UNIX +#ifndef XP_MACOSX + <menuseparator id="prefSep"/> + <menuitem id="menu_preferences" + oncommand="openOptionsDialog()" + data-l10n-id="menu-tools-settings"/> + <menuitem id="menu_accountmgr" + label="&accountManagerCmd2.label;" + accesskey="&accountManagerCmdUnix2.accesskey;" + oncommand="MsgAccountManager(null);"/> +#endif +#endif + </menupopup> +</menu> + +<!-- View --> +<menu id="menu_View" + label="&viewMenu.label;" + accesskey="&viewMenu.accesskey;"> + <menupopup id="menu_View_Popup" onpopupshowing="view_init(event);"> + <menu id="menu_Toolbars" + label="&viewToolbarsMenu.label;" + accesskey="&viewToolbarsMenu.accesskey;" + onpopupshowing="calendarOnToolbarsPopupShowing(event);"> + <menupopup id="view_toolbars_popup"> +#ifdef MAIN_WINDOW + <menuitem id="view_toolbars_popup_quickFilterBar" + type="checkbox" + command="cmd_toggleQuickFilterBar" + data-l10n-id="quick-filter-bar-toggle"/> + <menuitem id="viewToolbarsPopupSpacesToolbar" + type="checkbox" + data-l10n-id="menu-spaces-toolbar-button" + oncommand="gSpacesToolbar.toggleToolbarFromMenu();"/> +#endif + <menuitem id="menu_showTaskbar" + type="checkbox" + label="&showTaskbarCmd.label;" + accesskey="&showTaskbarCmd.accesskey;" + oncommand="goToggleToolbar('status-bar', 'menu_showTaskbar')" + checked="true"/> + <menuseparator id="viewMenuBeforeCustomizeMailToolbarsSeparator"/> + <menuitem id="customizeMailToolbars" + command="cmd_CustomizeMailToolbar" + label="&customizeToolbar.label;" + accesskey="&customizeToolbar.accesskey;"/> + </menupopup> + </menu> + <menu id="menu_MessagePaneLayout" label="&messagePaneLayoutStyle.label;" accesskey="&messagePaneLayoutStyle.accesskey;"> + <menupopup id="view_layout_popup" onpopupshowing="InitViewLayoutStyleMenu(event)"> + <menuitem id="messagePaneClassic" type="radio" label="&messagePaneClassic.label;" name="viewlayoutgroup" + accesskey="&messagePaneClassic.accesskey;" command="cmd_viewClassicMailLayout"/> + <menuitem id="messagePaneWide" type="radio" label="&messagePaneWide.label;" name="viewlayoutgroup" + accesskey="&messagePaneWide.accesskey;" command="cmd_viewWideMailLayout"/> + <menuitem id="messagePaneVertical" type="radio" label="&messagePaneVertical.label;" name="viewlayoutgroup" + accesskey="&messagePaneVertical.accesskey;" command="cmd_viewVerticalMailLayout"/> + <menuseparator id="viewMenuAfterPaneVerticalSeparator"/> + <menuitem id="menu_showFolderPane" type="checkbox" label="&showFolderPaneCmd.label;" + accesskey="&showFolderPaneCmd.accesskey;" command="cmd_toggleFolderPane"/> + <menuitem id="menu_toggleThreadPaneHeader" + type="checkbox" + name="threadheader" + data-l10n-id="menu-view-toggle-thread-pane-header" + command="cmd_toggleThreadPaneHeader"/> + <menuitem id="menu_showMessage" type="checkbox" label="&showMessageCmd.label;" key="key_toggleMessagePane" + accesskey="&showMessageCmd.accesskey;" command="cmd_toggleMessagePane"/> + </menupopup> + </menu> + <menu id="menu_FolderViews" label="&folderView.label;" accesskey="&folderView.accesskey;"> + <menupopup id="menu_FolderViewsPopup" + onpopupshowing="PanelUI._onFoldersViewShow(event)"> + <menuitem id="menu_toggleFolderHeader" + name="paneheader" + value="toggle-header" + data-l10n-id="menu-view-folders-toggle-header" + type="checkbox" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <menuseparator id="folderViewsHeaderSeparator"/> + <menuitem id="menu_allFolders" value="all" + data-l10n-id="show-all-folders-label" + type="checkbox" name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <menuitem id="menu_smartFolders" value="smart" + data-l10n-id="show-smart-folders-label" + type="checkbox" name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <menuitem id="menu_unreadFolders" value="unread" + data-l10n-id="show-unread-folders-label" + type="checkbox" name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <menuitem id="menu_favoriteFolders" value="favorite" + data-l10n-id="show-favorite-folders-label" + type="checkbox" name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <menuitem id="menu_recentFolders" value="recent" + data-l10n-id="show-recent-folders-label" + type="checkbox" name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <menuseparator/> + <menuitem id="menu_tags" value="tags" + data-l10n-id="show-tags-folders-label" + type="checkbox" name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderViewMenuOnCommand(event);"/> + <menuseparator/> + <menuitem id="menu_compactMode" value="compact" + data-l10n-id="folder-toolbar-toggle-folder-compact-view" + type="checkbox" name="viewmessages" + closemenu="none" + oncommand="PanelUI.folderCompactMenuOnCommand(event);"/> + </menupopup> + </menu> + <menuseparator id="viewUIZoomMenuSeparator"/> + <menu id="menu_uiDensity" + data-l10n-id="mail-uidensity-label"> + <menupopup id="view_density_popup" onpopupshowing="initUiDensityMenu(event);"> + <menuitem id="uiDensityCompact" + data-l10n-id="mail-uidensity-compact" + type="radio" + name="uidensity" + closemenu="none" + oncommand="UIDensity.setMode(this.mode);"/> + <menuitem id="uiDensityNormal" + data-l10n-id="mail-uidensity-default" + type="radio" + name="uidensity" + closemenu="none" + oncommand="UIDensity.setMode(this.mode);"/> + <menuitem id="uiDensityTouch" + data-l10n-id="mail-uidensity-relaxed" + type="radio" + name="uidensity" + closemenu="none" + oncommand="UIDensity.setMode(this.mode);"/> + </menupopup> + </menu> + <menu id="viewFullZoomMenu" label="&fullZoom.label;" accesskey="&fullZoom.accesskey;" + onpopupshowing="UpdateFullZoomMenu()"> + <menupopup id="viewFullZoomPopupMenu"> + <menuitem id="menu_fullZoomEnlarge" key="key_fullZoomEnlarge" + label="&fullZoomEnlargeCmd.label;" + accesskey="&fullZoomEnlargeCmd.accesskey;" + command="cmd_fullZoomEnlarge"/> + <menuitem id="menu_fullZoomReduce" key="key_fullZoomReduce" + label="&fullZoomReduceCmd.label;" + accesskey="&fullZoomReduceCmd.accesskey;" + command="cmd_fullZoomReduce"/> + <menuseparator id="fullZoomAfterReduceSeparator"/> + <menuitem id="menu_fullZoomReset" key="key_fullZoomReset" + label="&fullZoomResetCmd.label;" + accesskey="&fullZoomResetCmd.accesskey;" + command="cmd_fullZoomReset"/> + <menuseparator id="fullZoomAfterResetSeparator"/> + <menuitem id="menu_fullZoomToggle" label="&fullZoomToggleCmd.label;" + accesskey="&fullZoomToggleCmd.accesskey;" + type="checkbox" command="cmd_fullZoomToggle" checked="false"/> + </menupopup> + </menu> + <menu id="menu_uiFontSize" + data-l10n-id="menu-font-size-label"> + <menupopup id="view_font_size_popup"> + <menuitem id="menu_fontSizeEnlarge" + data-l10n-id="menuitem-font-size-enlarge" + oncommand="UIFontSize.increaseSize();" + closemenu="none"/> + <menuitem id="menu_fontSizeReduce" + data-l10n-id="menuitem-font-size-reduce" + oncommand="UIFontSize.reduceSize();" + closemenu="none"/> + <menuseparator id="fontSizeAfterReduceSeparator"/> + <menuitem id="menu_fontSizeReset" + data-l10n-id="menuitem-font-size-reset" + oncommand="UIFontSize.resetSize();" + closemenu="none"/> + </menupopup> + </menu> + +#ifdef MAIN_WINDOW +#include ../../../calendar/base/content/calendar-view-menu.inc.xhtml +#endif + + <menuseparator id="viewSortMenuSeparator"/> + <menu id="viewSortMenu" accesskey="&sortMenu.accesskey;" label="&sortMenu.label;"> + <menupopup id="menu_viewSortPopup" oncommand="goDoCommand('cmd_sort', event);" onpopupshowing="InitViewSortByMenu()"> + <menuitem id="sortByDateMenuitem" + type="radio" + name="sortby" + value="byDate" + label="&sortByDateCmd.label;" + accesskey="&sortByDateCmd.accesskey;"/> + <menuitem id="sortByReceivedMenuitem" + type="radio" + name="sortby" + value="byReceived" + label="&sortByReceivedCmd.label;" + accesskey="&sortByReceivedCmd.accesskey;"/> + <menuitem id="sortByFlagMenuitem" + type="radio" + name="sortby" + value="byFlagged" + label="&sortByStarCmd.label;" + accesskey="&sortByStarCmd.accesskey;"/> + <menuitem id="sortByOrderReceivedMenuitem" + type="radio" + name="sortby" + value="byId" + label="&sortByOrderReceivedCmd.label;" + accesskey="&sortByOrderReceivedCmd.accesskey;"/> + <menuitem id="sortByPriorityMenuitem" + type="radio" + name="sortby" + value="byPriority" + label="&sortByPriorityCmd.label;" + accesskey="&sortByPriorityCmd.accesskey;"/> + <menuitem id="sortByFromMenuitem" + type="radio" + name="sortby" + value="byAuthor" + label="&sortByFromCmd.label;" + accesskey="&sortByFromCmd.accesskey;"/> + <menuitem id="sortByRecipientMenuitem" + type="radio" + name="sortby" + value="byRecipient" + label="&sortByRecipientCmd.label;" + accesskey="&sortByRecipientCmd.accesskey;"/> + <menuitem id="sortByCorrespondentMenuitem" + type="radio" + name="sortby" + value="byCorrespondent" + label="&sortByCorrespondentCmd.label;" + accesskey="&sortByCorrespondentCmd.accesskey;"/> + <menuitem id="sortBySizeMenuitem" + type="radio" + name="sortby" + value="bySize" + label="&sortBySizeCmd.label;" + accesskey="&sortBySizeCmd.accesskey;"/> + <menuitem id="sortByStatusMenuitem" + type="radio" + name="sortby" + value="byStatus" + label="&sortByStatusCmd.label;" + accesskey="&sortByStatusCmd.accesskey;"/> + <menuitem id="sortBySubjectMenuitem" + type="radio" + name="sortby" + value="bySubject" + label="&sortBySubjectCmd.label;" + accesskey="&sortBySubjectCmd.accesskey;"/> + <menuitem id="sortByUnreadMenuitem" + type="radio" + name="sortby" + value="byUnread" + label="&sortByUnreadCmd.label;" + accesskey="&sortByUnreadCmd.accesskey;"/> + <menuitem id="sortByTagsMenuitem" + type="radio" + name="sortby" + value="byTags" + label="&sortByTagsCmd.label;" + accesskey="&sortByTagsCmd.accesskey;"/> + <menuitem id="sortByJunkStatusMenuitem" + type="radio" + name="sortby" + value="byJunkStatus" + label="&sortByJunkStatusCmd.label;" + accesskey="&sortByJunkStatusCmd.accesskey;"/> + <menuitem id="sortByAttachmentsMenuitem" + type="radio" + name="sortby" + value="byAttachments" + label="&sortByAttachmentsCmd.label;" + accesskey="&sortByAttachmentsCmd.accesskey;"/> + <menuseparator id="sortAfterAttachmentSeparator"/> + <menuitem id="sortAscending" + type="radio" + name="sortdirection" + value="ascending" + label="&sortAscending.label;" + accesskey="&sortAscending.accesskey;"/> + <menuitem id="sortDescending" + type="radio" + name="sortdirection" + value="descending" + label="&sortDescending.label;" + accesskey="&sortDescending.accesskey;"/> + <menuseparator id="sortAfterDescendingSeparator"/> + <menuitem id="sortThreaded" + type="radio" + name="threaded" + value="threaded" + label="&sortThreaded.label;" + accesskey="&sortThreaded.accesskey;"/> + <menuitem id="sortUnthreaded" + type="radio" + name="threaded" + value="unthreaded" + label="&sortUnthreaded.label;" + accesskey="&sortUnthreaded.accesskey;"/> + <menuitem id="groupBySort" + type="checkbox" + name="group" + value="group" + label="&groupBySort.label;" + accesskey="&groupBySort.accesskey;"/> + </menupopup> + </menu> + <menu id="viewMessageViewMenu" label="&msgsMenu.label;" accesskey="&msgsMenu.accesskey;" + command="mailHideMenus" oncommand="ViewChangeByMenuitem(event.target);"> + <menupopup id="viewMessagePopup" onpopupshowing="RefreshViewPopup(this);"> + <menuitem id="viewMessageAll" value="0" type="radio" label="&viewAll.label;" accesskey="&viewAll.accesskey;"/> + <menuitem id="viewMessageUnread" value="1" type="radio" label="&viewUnread.label;" accesskey="&viewUnread.accesskey;"/> + <menuitem id="viewMessageNotDeleted" value="3" type="radio" label="&viewNotDeleted.label;" accesskey="&viewNotDeleted.accesskey;"/> + <menuseparator id="messageViewAfterUnreadSeparator"/> + <menu id="viewMessageTags" label="&viewTags.label;" accesskey="&viewTags.accesskey;"> + <menupopup id="viewMessageTagsPopup" onpopupshowing="RefreshTagsPopup(this);"/> + </menu> + <menu id="viewMessageCustomViews" label="&viewCustomViews.label;" accesskey="&viewCustomViews.accesskey;"> + <menupopup id="viewMessageCustomViewsPopup" onpopupshowing="RefreshCustomViewsPopup(this);"/> + </menu> + <menuseparator id="messageViewAfterCustomSeparator"/> + <menuitem id="viewMessageVirtualFolder" value="7" label="&viewVirtualFolder.label;" accesskey="&viewVirtualFolder.accesskey;"/> + <menuitem id="viewMessageCustomize" value="8" label="&viewCustomizeView.label;" accesskey="&viewCustomizeView.accesskey;"/> + </menupopup> + </menu> + + <menu label="&threads.label;" id="viewMessagesMenu" accesskey="&threads.accesskey;"> + <menupopup id="menu_ThreadsPopup" onpopupshowing="InitViewMessagesMenu()"> + <menuitem id="viewAllMessagesMenuItem" type="radio" name="viewmessages" label="&allMsgsCmd.label;" accesskey="&allMsgsCmd.accesskey;" disabled="true" command="cmd_viewAllMsgs"/> + <menuitem id="viewUnreadMessagesMenuItem" type="radio" name="viewmessages" label="&unreadMsgsCmd.label;" accesskey="&unreadMsgsCmd.accesskey;" disabled="true" command="cmd_viewUnreadMsgs"/> + <menuitem id="viewThreadsWithUnreadMenuItem" type="radio" name="viewmessages" label="&threadsWithUnreadCmd.label;" accesskey="&threadsWithUnreadCmd.accesskey;" disabled="true" command="cmd_viewThreadsWithUnread"/> + <menuitem id="viewWatchedThreadsWithUnreadMenuItem" type="radio" name="viewmessages" label="&watchedThreadsWithUnreadCmd.label;" accesskey="&watchedThreadsWithUnreadCmd.accesskey;" disabled="true" command="cmd_viewWatchedThreadsWithUnread"/> + <menuseparator id="threadsAfterWatchedSeparator"/> + <menuitem id="viewIgnoredThreadsMenuItem" type="checkbox" label="&ignoredThreadsCmd.label;" disabled="true" command="cmd_viewIgnoredThreads" accesskey="&ignoredThreadsCmd.accesskey;"/> + <menuseparator id="threadsAfterIgnoredSeparator"/> + <menuitem id="menu_expandAllThreads" label="&expandAllThreadsCmd.label;" accesskey="&expandAllThreadsCmd.accesskey;" key="key_expandAllThreads" disabled="true" command="cmd_expandAllThreads"/> + <menuitem id="collapseAllThreads" label="&collapseAllThreadsCmd.label;" accesskey="&collapseAllThreadsCmd.accesskey;" key="key_collapseAllThreads" disabled="true" command="cmd_collapseAllThreads"/> + </menupopup> + </menu> + <menuseparator id="viewAfterThreadsSeparator"/> + <menu id="viewheadersmenu" label="&headersMenu.label;" accesskey="&headersMenu.accesskey;"> + <menupopup id="menu_HeadersPopup" onpopupshowing="InitViewHeadersMenu();"> + <menuitem id="viewallheaders" + type="radio" + name="viewheadergroup" + label="&headersAllCmd.label;" + accesskey="&headersAllCmd.accesskey;" + command="cmd_viewAllHeader"/> + <menuitem id="viewnormalheaders" + type="radio" + name="viewheadergroup" + label="&headersNormalCmd.label;" + accesskey="&headersNormalCmd.accesskey;" + command="cmd_viewNormalHeader"/> + </menupopup> + </menu> + <menu id="viewBodyMenu" accesskey="&bodyMenu.accesskey;" label="&bodyMenu.label;"> + <menupopup id="viewBodyPopMenu" onpopupshowing="InitViewBodyMenu()"> + <menuitem id="bodyAllowHTML" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAllowHTML.label;" + accesskey="&bodyAllowHTML.accesskey;" oncommand="MsgBodyAllowHTML()"/> + <menuitem id="bodySanitized" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodySanitized.label;" + accesskey="&bodySanitized.accesskey;" + oncommand="MsgBodySanitized()"/> + <menuitem id="bodyAsPlaintext" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAsPlaintext.label;" + accesskey="&bodyAsPlaintext.accesskey;" oncommand="MsgBodyAsPlaintext()"/> + <menuitem id="bodyAllParts" type="radio" name="bodyPlaintextVsHTMLPref" label="&bodyAllParts.label;" + accesskey="&bodyAllParts.accesskey;" oncommand="MsgBodyAllParts()"/> + </menupopup> + </menu> + <menu id="viewFeedSummary" + label="&bodyMenuFeed.label;" + accesskey="&bodyMenuFeed.accesskey;"> + <menupopup id="viewFeedSummaryPopupMenu" + onpopupshowing="InitViewBodyMenu()"> + <menuitem id="bodyFeedGlobalWebPage" + type="radio" + name="viewFeedSummaryGroup" + label="&viewFeedWebPage.label;" + accesskey="&viewFeedWebPage.accesskey;" + oncommand="FeedMessageHandler.onSelectPref = 0"/> + <menuitem id="bodyFeedGlobalSummary" + type="radio" + name="viewFeedSummaryGroup" + label="&viewFeedSummary.label;" + accesskey="&viewFeedSummary.accesskey;" + oncommand="FeedMessageHandler.onSelectPref = 1"/> + <menuitem id="bodyFeedPerFolderPref" + type="radio" + name="viewFeedSummaryGroup" + label="&viewFeedSummaryFeedPropsPref.label;" + accesskey="&viewFeedSummaryFeedPropsPref.accesskey;" + oncommand="FeedMessageHandler.onSelectPref = 2"/> + <menuseparator id="viewFeedSummarySeparator"/> + <menuitem id="bodyFeedSummaryAllowHTML" + type="radio" + name="viewFeedBodyHTMLGroup" + label="&bodyAllowHTML.label;" + accesskey="&bodyAllowHTML.accesskey;" + oncommand="MsgFeedBodyRenderPrefs(false, 0, 0)"/> + <menuitem id="bodyFeedSummarySanitized" + type="radio" + name="viewFeedBodyHTMLGroup" + label="&bodySanitized.label;" + accesskey="&bodySanitized.accesskey;" + oncommand="MsgFeedBodyRenderPrefs(false, 3, gDisallow_classes_no_html)"/> + <menuitem id="bodyFeedSummaryAsPlaintext" + type="radio" + name="viewFeedBodyHTMLGroup" + label="&bodyAsPlaintext.label;" + accesskey="&bodyAsPlaintext.accesskey;" + oncommand="MsgFeedBodyRenderPrefs(true, 1, gDisallow_classes_no_html)"/> + </menupopup> + </menu> + <menuitem id="viewAttachmentsInlineMenuitem" label="&viewAttachmentsInlineCmd.label;" accesskey="&viewAttachmentsInlineCmd.accesskey;" + oncommand="ToggleInlineAttachment(event.target)" type="checkbox" checked="true"/> + <menuseparator id="viewAfterAttachmentsSeparator"/> + <menuitem id="pageSourceMenuItem" + label="&pageSourceCmd.label;" + key="key_viewPageSource" + accesskey="&pageSourceCmd.accesskey;" + command="cmd_viewPageSource"/> + </menupopup> + </menu> + + <!-- Go --> + <menu id="menu_Go" label="&goMenu.label;" accesskey="&goMenu.accesskey;"> + <menupopup id="menu_GoPopup" onpopupshowing="InitGoMessagesMenu();"> + <menu id="goNextMenu" label="&nextMenu.label;" accesskey="&nextMenu.accesskey;"> + <menupopup id="menu_GoNextPopup"> + <menuitem id="menu_nextMsg" + label="&nextMsgCmd.label;" + accesskey="&nextMsgCmd.accesskey;" + command="cmd_nextMsg" + key="key_nextMsg"/> + <menuitem id="menu_nextUnreadMsg" + label="&nextUnreadMsgCmd.label;" + accesskey="&nextUnreadMsgCmd.accesskey;" + command="cmd_nextUnreadMsg" + key="key_nextUnreadMsg"/> + <menuitem id="menu_nextFlaggedMsg" + label="&nextStarredMsgCmd.label;" + accesskey="&nextStarredMsgCmd.accesskey;" + command="cmd_nextFlaggedMsg"/> + <menuseparator id="goNextAfterFlaggedSeparator"/> + <menuitem id="menu_nextUnreadThread" + label="&nextUnreadThread.label;" + accesskey="&nextUnreadThread.accesskey;" + command="cmd_nextUnreadThread" + key="key_nextUnreadThread"/> +#ifdef MAIN_WINDOW + <menuseparator id="goNextAfterUnreadThreadSeparator" + class="hide-before-calendar-loaded hide-when-calendar-deactivated" + hidden="true"/> + <!-- Label is set up automatically using the view id. When writing + a view extension, add a `label-<myviewtype>` attribute with + the correct label. --> + <menuitem id="calendar-go-menu-next" + class="hide-before-calendar-loaded hide-when-calendar-deactivated" + label="" + label-day="&lightning.toolbar.day.label;" + label-week="&lightning.toolbar.week.label;" + label-multiweek="&lightning.toolbar.week.label;" + label-month="&lightning.toolbar.month.label;" + accesskey-day="&lightning.toolbar.day.accesskey;" + accesskey-week="&lightning.toolbar.week.accesskey;" + accesskey-multiweek="&lightning.toolbar.week.accesskey;" + accesskey-month="&lightning.toolbar.month.accesskey;" + command="calendar_view_next_command" + hidden="true"/> +#endif + </menupopup> + </menu> + <menu id="goPreviousMenu" label="&prevMenu.label;" accesskey="&prevMenu.accesskey;"> + <menupopup id="menu_GoPreviousPopup"> + <menuitem id="menu_prevMsg" + label="&prevMsgCmd.label;" + accesskey="&prevMsgCmd.accesskey;" + command="cmd_previousMsg" + key="key_previousMsg"/> + <menuitem id="menu_prevUnreadMsg" + label="&prevUnreadMsgCmd.label;" + accesskey="&prevUnreadMsgCmd.accesskey;" + command="cmd_previousUnreadMsg" + key="key_previousUnreadMsg"/> + <menuitem id="menu_prevFlaggedMsg" + label="&prevStarredMsgCmd.label;" + accesskey="&prevStarredMsgCmd.accesskey;" + command="cmd_previousFlaggedMsg"/> +#ifdef MAIN_WINDOW + <menuseparator id="goPreviousAfterFlaggedSeparator" + class="hide-before-calendar-loaded hide-when-calendar-deactivated" + hidden="true"/> + <!-- Label is set up automatically using the view id. When writing + a view extension, add a `label-<myviewtype>` attribute with + the correct label. --> + <menuitem id="calendar-go-menu-previous" + class="hide-before-calendar-loaded hide-when-calendar-deactivated" + label="" + label-day="&lightning.toolbar.day.label;" + label-week="&lightning.toolbar.week.label;" + label-multiweek="&lightning.toolbar.week.label;" + label-month="&lightning.toolbar.month.label;" + accesskey-day="&lightning.toolbar.day.accesskey;" + accesskey-week="&lightning.toolbar.week.accesskey;" + accesskey-multiweek="&lightning.toolbar.week.accesskey;" + accesskey-month="&lightning.toolbar.month.accesskey;" + command="calendar_view_prev_command" + hidden="true"/> +#endif + </menupopup> + </menu> + <menuitem id="menu_goForward" label="&goForwardCmd.label;" + accesskey="&goForwardCmd.accesskey;" command="cmd_goForward" + key="key_goForward"/> + <menuitem id="menu_goBack" label="&goBackCmd.label;" + accesskey="&goBackCmd.accesskey;" command="cmd_goBack" + key="key_goBack"/> + <menuseparator id="goNextSeparator"/> +#ifdef MAIN_WINDOW + <menuitem id="calendar-go-to-today-menuitem" + class="hide-when-calendar-deactivated" + label="&goTodayCmd.label;" + accesskey="&goTodayCmd.accesskey;" + command="calendar_go_to_today_command" + key="calendar-go-to-today-key"/> +#endif + <menuitem id="menu_goChat" label="&goChatCmd.label;" + accesskey="&goChatCmd.accesskey;" + command="cmd_chat" + data-l10n-attrs="acceltext"/> + <menuseparator id="goChatSeparator"/> + <menu id="goFolderMenu" + label="&folderMenu.label;" + accesskey="&folderMenu.accesskey;" + command="cmd_goFolder"> + <menupopup is="folder-menupopup" id="menu_GoFolderPopup" + showFileHereLabel="true" + showRecent="true" + recentLabel="&contextMoveCopyMsgRecentMenu.label;" + recentAccessKey="&contextMoveCopyMsgRecentMenu.accesskey;" + showFavorites="true" + favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;" + favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/> + </menu> + <menuseparator id="goFolderSeparator"/> + + <menu id="goRecentlyClosedTabs" + label="&goRecentlyClosedTabs.label;" + accesskey="&goRecentlyClosedTabs.accesskey;" + observes="cmd_undoCloseTab"> + <menupopup id="menu_GoRecentlyClosedTabsPopup" + onpopupshowing="return InitRecentlyClosedTabsPopup(this)" /> + </menu> + <menuseparator id="goRecentlyClosedTabsSeparator"/> + + <menuitem id="goStartPage" + label="&startPageCmd.label;" + accesskey="&startPageCmd.accesskey;" + command="cmd_goStartPage" + key="key_goStartPage"/> + </menupopup> + </menu> + + <!-- Message --> + <menu id="messageMenu" label="&msgMenu.label;" accesskey="&msgMenu.accesskey;"> + <menupopup id="messageMenuPopup" onpopupshowing="InitMessageMenu();"> + <menuitem id="newMsgCmd" label="&newMsgCmd.label;" + accesskey="&newMsgCmd.accesskey;" + key="key_newMessage2" + command="cmd_newMessage"/> + <menuitem id="replyMainMenu" label="&replyMsgCmd.label;" + accesskey="&replyMsgCmd.accesskey;" + key="key_reply" + command="cmd_reply"/> + <menuitem id="replyNewsgroupMainMenu" label="&replyNewsgroupCmd2.label;" + accesskey="&replyNewsgroupCmd2.accesskey;" + key="key_reply" + command="cmd_replyGroup"/> + <menuitem id="replySenderMainMenu" label="&replySenderCmd.label;" + accesskey="&replySenderCmd.accesskey;" + command="cmd_replySender"/> + <menuitem id="menu_replyToAll" label="&replyToAllMsgCmd.label;" + accesskey="&replyToAllMsgCmd.accesskey;" + key="key_replyall" + command="cmd_replyall"/> + <menuitem id="menu_replyToList" label="&replyToListMsgCmd.label;" + accesskey="&replyToListMsgCmd.accesskey;" + key="key_replylist" + command="cmd_replylist"/> + <menuitem id="menu_forwardMsg" label="&forwardMsgCmd.label;" + accesskey="&forwardMsgCmd.accesskey;" + key="key_forward" + command="cmd_forward"/> + <menu id="forwardAsMenu" label="&forwardAsMenu.label;" accesskey="&forwardAsMenu.accesskey;"> + <menupopup id="menu_forwardAsPopup"> + <menuitem id="menu_forwardAsInline" + label="&forwardAsInline.label;" + accesskey="&forwardAsInline.accesskey;" + command="cmd_forwardInline"/> + <menuitem id="menu_forwardAsAttachment" + label="&forwardAsAttachmentCmd.label;" + accesskey="&forwardAsAttachmentCmd.accesskey;" + command="cmd_forwardAttachment"/> + </menupopup> + </menu> + <menuitem id="menu_redirectMsg" + data-l10n-id="redirect-msg-menuitem" + command="cmd_redirect"/> + <menuitem id="menu_editMsgAsNew" label="&editAsNewMsgCmd.label;" + accesskey="&editAsNewMsgCmd.accesskey;" + key="key_editAsNew" + command="cmd_editAsNew"/> + <menuitem id="menu_editDraftMsg" + label="&editDraftMsgCmd.label;" + accesskey="&editDraftMsgCmd.accesskey;" + command="cmd_editDraftMsg"/> + <menuitem id="menu_newMsgFromTemplate" + label="&newMsgFromTemplateCmd.label;" + key="key_newMsgFromTemplate" + command="cmd_newMsgFromTemplate"/> + <menuitem id="menu_editTemplate" + label="&editTemplateMsgCmd.label;" + accesskey="&editTemplateMsgCmd.accesskey;" + command="cmd_editTemplateMsg"/> + <menuseparator id="messageMenuAfterCompositionCommandsSeparator"/> + <menuitem id="openMessageWindowMenuitem" label="&openMessageWindowCmd.label;" + command="cmd_openMessage" + accesskey="&openMessageWindowCmd.accesskey;" + key="key_openMessage"/> +#ifdef MAIN_WINDOW + <menuitem id="openConversationMenuitem" label="&openInConversationCmd.label;" + command="cmd_openConversation" + accesskey="&openInConversationCmd.accesskey;" + key="key_openConversation"/> +#endif + <menu id="openFeedMessage" + label="&openFeedMessage1.label;" + accesskey="&openFeedMessage1.accesskey;"> + <menupopup id="menu_openFeedMessage"> + <menuitem id="menu_openFeedWebPage" + type="radio" + name="openFeedGroup" + label="&openFeedWebPage.label;" + accesskey="&openFeedWebPage.accesskey;" + oncommand="FeedMessageHandler.onOpenPref = 0"/> + <menuitem id="menu_openFeedSummary" + type="radio" + name="openFeedGroup" + label="&openFeedSummary.label;" + accesskey="&openFeedSummary.accesskey;" + oncommand="FeedMessageHandler.onOpenPref = 1"/> + <menuitem id="menu_openFeedWebPageInMessagePane" + type="radio" + name="openFeedGroup" + label="&openFeedWebPageInMP.label;" + accesskey="&openFeedWebPageInMP.accesskey;" + oncommand="FeedMessageHandler.onOpenPref = 2"/> + </menupopup> + </menu> +#ifdef MAIN_WINDOW + <menuseparator id="messageAfterOpenMsgSeparator"/> +#endif + <menu id="msgAttachmentMenu" + label="&openAttachmentListCmd.label;" + accesskey="&openAttachmentListCmd.accesskey;" + disabled="true"> + <menupopup id="attachmentMenuList" + onpopupshowing="fillAttachmentListPopup(event);"> + <menuseparator/> + <menuitem id="menu-openAllAttachments" + label="&openAllAttachmentsCmd.label;" + accesskey="&openAllAttachmentsCmd.accesskey;" + command="cmd_openAllAttachments"/> + <menuitem id="menu-saveAllAttachments" + label="&saveAllAttachmentsCmd.label;" + accesskey="&saveAllAttachmentsCmd.accesskey;" + command="cmd_saveAllAttachments"/> + <menuitem id="menu-detachAllAttachments" + label="&detachAllAttachmentsCmd.label;" + accesskey="&detachAllAttachmentsCmd.accesskey;" + command="cmd_detachAllAttachments"/> + <menuitem id="menu-deleteAllAttachments" + label="&deleteAllAttachmentsCmd.label;" + accesskey="&deleteAllAttachmentsCmd.accesskey;" + command="cmd_deleteAllAttachments"/> + </menupopup> + </menu> + <menuseparator id="messageAfterAttachmentMenuSeparator"/> + <menu id="tagMenu" label="&tagMenu.label;" accesskey="&tagMenu.accesskey;" command="cmd_tag"> + <menupopup id="tagMenu-tagpopup" + onpopupshowing="InitMessageTags(this);"> + <menuitem id="tagMenu-addNewTag" + label="&addNewTag.label;" + accesskey="&addNewTag.accesskey;" + command="cmd_addTag"/> + <menuitem id="tagMenu-manageTags" + label="&manageTags.label;" + accesskey="&manageTags.accesskey;" + command="cmd_manageTags"/> + <menuseparator id="tagMenu-sep-afterTagAddNew"/> + <menuitem id="tagMenu-tagRemoveAll" + command="cmd_removeTags"/> + <menuseparator id="tagMenuAfterRemoveSeparator"/> + </menupopup> + </menu> + <menu id="markMenu" label="&markMenu.label;" accesskey="&markMenu.accesskey;"> + <menupopup id="menu_MarkPopup" onpopupshowing="InitMessageMark()"> + <menuitem id="markReadMenuItem" label="&markAsReadCmd.label;" + accesskey="&markAsReadCmd.accesskey;" + key="key_toggleRead" + command="cmd_markAsRead"/> + <menuitem id="markUnreadMenuItem" label="&markAsUnreadCmd.label;" + accesskey="&markAsUnreadCmd.accesskey;" + key="key_toggleRead" + command="cmd_markAsUnread"/> + <menuitem id="menu_markThreadAsRead" + label="&markThreadAsReadCmd.label;" + accesskey="&markThreadAsReadCmd.accesskey;" + command="cmd_markThreadAsRead" + key="key_markThreadAsRead"/> + <menuitem id="menu_markReadByDate" + label="&markReadByDateCmd.label;" + accesskey="&markReadByDateCmd.accesskey;" + command="cmd_markReadByDate" + key="key_markReadByDate"/> + <menuitem id="menu_markAllRead" + label="&markAllReadCmd.label;" + key="key_markAllRead" + accesskey="&markAllReadCmd.accesskey;" + command="cmd_markAllRead"/> + <menuseparator id="markMenuAfterAllReadSeparator"/> + <menuitem id="markFlaggedMenuItem" + type="checkbox" + label="&markStarredCmd.label;" + accesskey="&markStarredCmd.accesskey;" + command="cmd_markAsFlagged" + key="key_toggleFlagged"/> + <menuseparator id="markMenuAfterFlaggedSeparator"/> + <menuitem id="menu_markAsJunk" label="&markAsJunkCmd.label;" + accesskey="&markAsJunkCmd.accesskey;" + command="cmd_markAsJunk" + key="key_markJunk"/> + <menuitem id="menu_markAsNotJunk" label="&markAsNotJunkCmd.label;" + key="key_markNotJunk" + accesskey="&markAsNotJunkCmd.accesskey;" + command="cmd_markAsNotJunk"/> + <menuitem id="menu_recalculateJunkScore" + label="&recalculateJunkScoreCmd.label;" + accesskey="&recalculateJunkScoreCmd.accesskey;" + command="cmd_recalculateJunkScore"/> + </menupopup> + </menu> + <menuseparator id="messageMenuAfterMarkSeparator"/> + <menuitem id="archiveMainMenu" label="&archiveMsgCmd.label;" + accesskey="&archiveMsgCmd.accesskey;" + key="key_archive" + command="cmd_archive"/> + <menuitem id="menu_cancel" command="cmd_cancel" + label="&cancelNewsMsgCmd.label;" + accesskey="&cancelNewsMsgCmd.accesskey;"/> + <menu id="moveMenu" + label="&moveMsgToMenu.label;" + accesskey="&moveMsgToMenu.accesskey;" + oncommand="goDoCommand('cmd_moveMessage', event.target._folder)"> + <menupopup is="folder-menupopup" + mode="filing" + showFileHereLabel="true" + showRecent="true" + recentLabel="&moveCopyMsgRecentMenu.label;" + recentAccessKey="&moveCopyMsgRecentMenu.accesskey;" + showFavorites="true" + favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;" + favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/> + </menu> + <menu id="copyMenu" + label="©MsgToMenu.label;" + accesskey="©MsgToMenu.accesskey;" + oncommand="goDoCommand('cmd_copyMessage', event.target._folder)"> + <menupopup is="folder-menupopup" + mode="filing" + showFileHereLabel="true" + showRecent="true" + recentLabel="&moveCopyMsgRecentMenu.label;" + recentAccessKey="&moveCopyMsgRecentMenu.accesskey;" + showFavorites="true" + favoritesLabel="&contextMoveCopyMsgFavoritesMenu.label;" + favoritesAccessKey="&contextMoveCopyMsgFavoritesMenu.accesskey;"/> + </menu> + <menuitem id="moveToFolderAgain" key="key_moveToFolderAgain" command="cmd_moveToFolderAgain" + label="&moveToFolderAgain.label;" accesskey="&moveToFolderAgain.accesskey;"/> + <menuseparator id="messageMenuAfterMoveCommandsSeparator"/> + <menuitem id="createFilter" label="&createFilter.label;" + accesskey="&createFilter.accesskey;" + command="cmd_createFilterFromMenu"/> + <menuseparator id="threadItemsSeparator"/> + <menuitem id="killThread" + label="&killThreadMenu.label;" + accesskey="&killThreadMenu.accesskey;" + command="cmd_killThread" + type="checkbox" + key="key_killThread"/> + <menuitem id="killSubthread" + label="&killSubthreadMenu.label;" + accesskey="&killSubthreadMenu.accesskey;" + type="checkbox" + command="cmd_killSubthread" + key="key_killSubthread"/> + <menuitem id="watchThread" + label="&watchThreadMenu.label;" + accesskey="&watchThreadMenu.accesskey;" + type="checkbox" + command="cmd_watchThread" + key="key_watchThread"/> + </menupopup> +</menu> + +#ifdef MAIN_WINDOW +#include ../../../calendar/base/content/calendar-menu-events-tasks.inc.xhtml +#endif + +<!-- Tools --> +<menu id="tasksMenu" label="&tasksMenu.label;" accesskey="&tasksMenu.accesskey;"> + <menupopup id="taskPopup" onpopupshowing="document.commandDispatcher.updateCommands('create-menu-tasks')"> +#ifndef XP_MACOSX + <menuitem hidden="true" accesskey="&messengerCmd.accesskey;" label="&messengerCmd.label;" + key="key_mail" oncommand="toMessengerWindow();" id="tasksMenuMail"/> + <menuitem id="addressBook" + label="&addressBookCmd.label;" + accesskey="&addressBookCmd.accesskey;" + key="key_addressbook" + oncommand="toAddressBook();"/> + <menuseparator id="devToolsSeparator"/> +#endif + <menuitem id="menu_openSavedFilesWnd" label="&savedFiles.label;" + accesskey="&savedFiles.accesskey;" + key="key_savedFiles" + oncommand="openSavedFilesWnd();"/> + <menuitem id="addonsManager" + data-l10n-id="menu-addons-and-themes" + oncommand="openAddonsMgr();"/> + <menuitem id="activityManager" label="&activitymanager.label;" + accesskey="&activitymanager.accesskey;" + oncommand="openActivityMgr();"/> + <menu id="imAccountsStatus" label="&imAccountsStatus.label;" + accesskey="&imAccountsStatus.accesskey;" + command="cmd_chatStatus"> + <menupopup id="imStatusMenupopup"> + <menuitem id="imStatusAvailable" status="available" label="&imStatus.available;" class="menuitem-iconic"/> + <menuitem id="imStatusUnavailable" status="unavailable" label="&imStatus.unavailable;" class="menuitem-iconic"/> + <menuseparator id="imStatusOfflineSeparator"/> + <menuitem id="imStatusOffline" status="offline" label="&imStatus.offline;" class="menuitem-iconic"/> + <menuseparator id="imStatusShowAccountsSeparator"/> + <menuitem id="imStatusShowAccounts" label="&imStatus.showAccounts;"/> + </menupopup> + </menu> + <menuitem id="joinChatMenuItem" + label="&joinChatCmd.label;" + accesskey="&joinChatCmd.accesskey;" + command="cmd_joinChat"/> + + <menuseparator id="devToolsSeparator"/> + <menuitem id="filtersCmd" label="&filtersCmd2.label;" + accesskey="&filtersCmd2.accesskey;" + oncommand="MsgFilters();"/> + <menuitem id="applyFilters" + label="&filtersApply.label;" + accesskey="&filtersApply.accesskey;" + command="cmd_applyFilters"/> + <menuitem id="applyFiltersToSelection" + label="&filtersApplyToMessage.label;" + accesskey="&filtersApplyToMessage.accesskey;" + command="cmd_applyFiltersToSelection"/> + <menuseparator id="tasksMenuAfterApplySeparator"/> + <menuitem id="runJunkControls" + label="&runJunkControls.label;" + accesskey="&runJunkControls.accesskey;" + command="cmd_runJunkControls"/> + <menuitem id="deleteJunk" + label="&deleteJunk.label;" + accesskey="&deleteJunk.accesskey;" + command="cmd_deleteJunk"/> + <menuseparator id="tasksMenuAfterDeleteSeparator"/> + <menuitem id="menu_import" label="&importCmd.label;" + accesskey="&importCmd.accesskey;" + oncommand="toImport();"/> + <menuitem id="menu_export" label="&exportCmd.label;" + accesskey="&exportCmd.accesskey;" + oncommand="toExport();"/> + <menuitem id="manageKeysOpenPGP" + data-l10n-id="openpgp-manage-keys-openpgp-cmd" + oncommand="openKeyManager()"/> + <menu id="devtoolsMenu" label="&devtoolsMenu.label;" accesskey="&devtoolsMenu.accesskey;"> + <menupopup id="devtoolsPopup"> + <menuitem id="devtoolsToolbox" + label="&devToolboxCmd.label;" + accesskey="&devToolboxCmd.accesskey;" + key="key_devtoolsToolbox" + oncommand="BrowserToolboxLauncher.init();"/> + <menuitem id="addonDebugging" + label="&debugAddonsCmd.label;" + accesskey="&debugAddonsCmd.accesskey;" + oncommand="openAboutDebugging('addons')"/> + <menuseparator id="debuggingSeparator"/> + <menuitem id="javascriptConsole" + label="&errorConsoleCmd.label;" + accesskey="&errorConsoleCmd.accesskey;" + key="key_errorConsole" + oncommand="toJavaScriptConsole();"/> + </menupopup> + </menu> + <menuitem id="sanitizeHistory" + label="&clearRecentHistory.label;" + accesskey="&clearRecentHistory.accesskey;" + key="key_sanitizeHistory" + oncommand="toSanitize();"/> +#ifndef XP_UNIX + <menuseparator id="prefSep"/> + <menuitem id="menu_preferences" + oncommand="openOptionsDialog()" + data-l10n-id="menu-tools-settings"/> + <menuitem id="menu_accountmgr" + label="&accountManagerCmd2.label;" + accesskey="&accountManagerCmd2.accesskey;" + oncommand="MsgAccountManager(null);"/> +#else +#ifdef XP_MACOSX + <menuseparator id="prefSep"/> + <menuitem id="menu_preferences" + data-l10n-id="menu-tools-settings" + key="key_preferencesCmdMac" + oncommand="openOptionsDialog()"/> + <menuitem id="menu_accountmgr" + label="&accountManagerCmd2.label;" + accesskey="&accountManagerCmd2.accesskey;" + oncommand="MsgAccountManager(null);"/> + <menuitem id="menu_mac_services" + label="&servicesMenuMac.label;"/> + <menuitem id="menu_mac_hide_app" + label="&hideThisAppCmdMac.label;" + key="key_hideThisAppCmdMac"/> + <menuitem id="menu_mac_hide_others" + label="&hideOtherAppsCmdMac.label;" + key="key_hideOtherAppsCmdMac"/> + <menuitem id="menu_mac_show_all" + label="&showAllAppsCmdMac.label;"/> +#endif +#endif + </menupopup> + </menu> + +#ifdef XP_MACOSX +#include macWindowMenu.inc.xhtml +#endif + + <!-- Help --> +#include helpMenu.inc.xhtml +</menubar> diff --git a/comm/mail/base/content/messenger-titlebar-items.inc.xhtml b/comm/mail/base/content/messenger-titlebar-items.inc.xhtml new file mode 100644 index 0000000000..999ad00830 --- /dev/null +++ b/comm/mail/base/content/messenger-titlebar-items.inc.xhtml @@ -0,0 +1,24 @@ +# 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/. + +<hbox class="titlebar-buttonbox-container" skipintoolbarset="true"> + <hbox class="titlebar-buttonbox titlebar-color"> + <toolbarbutton class="titlebar-button titlebar-min" + titlebar-btn="min" + oncommand="window.minimize();" + data-l10n-id="messenger-window-minimize-button"/> + <toolbarbutton class="titlebar-button titlebar-max" + titlebar-btn="max" + oncommand="window.maximize();" + data-l10n-id="messenger-window-maximize-button"/> + <toolbarbutton class="titlebar-button titlebar-restore" + titlebar-btn="max" + oncommand="window.restore();" + data-l10n-id="messenger-window-restore-down-button"/> + <toolbarbutton class="titlebar-button titlebar-close" + titlebar-btn="close" + oncommand="window.close()" + data-l10n-id="messenger-window-close-button"/> + </hbox> +</hbox> diff --git a/comm/mail/base/content/messenger.js b/comm/mail/base/content/messenger.js new file mode 100644 index 0000000000..6e11a3b538 --- /dev/null +++ b/comm/mail/base/content/messenger.js @@ -0,0 +1,1289 @@ +/** + * 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 ../../../mailnews/base/prefs/content/accountUtils.js */ +/* import-globals-from ../../components/addrbook/content/addressBookTab.js */ +/* import-globals-from ../../components/customizableui/content/panelUI.js */ +/* import-globals-from ../../components/newmailaccount/content/provisionerCheckout.js */ +/* import-globals-from ../../components/preferences/preferencesTab.js */ +/* import-globals-from glodaFacetTab.js */ +/* import-globals-from mailCore.js */ +/* import-globals-from mail-offline.js */ +/* import-globals-from mailTabs.js */ +/* import-globals-from mailWindowOverlay.js */ +/* import-globals-from messenger-customization.js */ +/* import-globals-from searchBar.js */ +/* import-globals-from spacesToolbar.js */ +/* import-globals-from specialTabs.js */ +/* import-globals-from toolbarIconColor.js */ + +/* globals CreateMailWindowGlobals, InitMsgWindow, OnMailWindowUnload */ // From mailWindow.js + +/* globals loadCalendarComponent */ + +ChromeUtils.import("resource:///modules/activity/activityModules.jsm"); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + Color: "resource://gre/modules/Color.sys.mjs", + ctypes: "resource://gre/modules/ctypes.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + BondOpenPGP: "chrome://openpgp/content/BondOpenPGP.jsm", + MailConsts: "resource:///modules/MailConsts.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + msgDBCacheManager: "resource:///modules/MsgDBCacheManager.jsm", + PeriodicFilterManager: "resource:///modules/PeriodicFilterManager.jsm", + SessionStoreManager: "resource:///modules/SessionStoreManager.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "PopupNotifications", function () { + let { PopupNotifications } = ChromeUtils.import( + "resource:///modules/GlobalPopupNotifications.jsm" + ); + try { + // Hide all notifications while the URL is being edited and the address bar + // has focus, including the virtual focus in the results popup. + // We also have to hide notifications explicitly when the window is + // minimized because of the effects of the "noautohide" attribute on Linux. + // This can be removed once bug 545265 and bug 1320361 are fixed. + let shouldSuppress = () => window.windowState == window.STATE_MINIMIZED; + return new PopupNotifications( + document.getElementById("tabmail"), + document.getElementById("notification-popup"), + document.getElementById("notification-popup-box"), + { shouldSuppress } + ); + } catch (ex) { + console.error(ex); + return null; + } +}); + +/** + * Gets the service pack and build information on Windows platforms. The initial version + * was copied from nsUpdateService.js. + * + * @returns An object containing the service pack major and minor versions, along with the + * build number. + */ +function getWindowsVersionInfo() { + const UNKNOWN_VERSION_INFO = { + servicePackMajor: null, + servicePackMinor: null, + buildNumber: null, + }; + + if (AppConstants.platform !== "win") { + return UNKNOWN_VERSION_INFO; + } + + const BYTE = ctypes.uint8_t; + const WORD = ctypes.uint16_t; + const DWORD = ctypes.uint32_t; + const WCHAR = ctypes.char16_t; + const BOOL = ctypes.int; + + // This structure is described at: + // http://msdn.microsoft.com/en-us/library/ms724833%28v=vs.85%29.aspx + const SZCSDVERSIONLENGTH = 128; + const OSVERSIONINFOEXW = new ctypes.StructType("OSVERSIONINFOEXW", [ + { dwOSVersionInfoSize: DWORD }, + { dwMajorVersion: DWORD }, + { dwMinorVersion: DWORD }, + { dwBuildNumber: DWORD }, + { dwPlatformId: DWORD }, + { szCSDVersion: ctypes.ArrayType(WCHAR, SZCSDVERSIONLENGTH) }, + { wServicePackMajor: WORD }, + { wServicePackMinor: WORD }, + { wSuiteMask: WORD }, + { wProductType: BYTE }, + { wReserved: BYTE }, + ]); + + let kernel32 = ctypes.open("kernel32"); + try { + let GetVersionEx = kernel32.declare( + "GetVersionExW", + ctypes.winapi_abi, + BOOL, + OSVERSIONINFOEXW.ptr + ); + let winVer = OSVERSIONINFOEXW(); + winVer.dwOSVersionInfoSize = OSVERSIONINFOEXW.size; + + if (0 === GetVersionEx(winVer.address())) { + throw new Error("Failure in GetVersionEx (returned 0)"); + } + + return { + servicePackMajor: winVer.wServicePackMajor, + servicePackMinor: winVer.wServicePackMinor, + buildNumber: winVer.dwBuildNumber, + }; + } catch (e) { + return UNKNOWN_VERSION_INFO; + } finally { + kernel32.close(); + } +} + +/* This is where functions related to the 3 pane window are kept */ + +// from MailNewsTypes.h +var kMailCheckOncePrefName = "mail.startup.enabledMailCheckOnce"; + +/** + * Tracks whether the right mouse button changed the selection or not. If the + * user right clicks on the selection, it stays the same. If they click outside + * of it, we alter the selection (but not the current index) to be the row they + * clicked on. + * + * The value of this variable is an object with "view" and "selection" keys + * and values. The view value is the view whose selection we saved off, and + * the selection value is the selection object we saved off. + */ +var gRightMouseButtonSavedSelection = null; +var gNewAccountToLoad = null; + +// The object in charge of managing the mail summary pane +var gSummaryFrameManager; + +/** + * Called on startup if there are no accounts. + */ +function verifyOpenAccountHubTab() { + let suppressDialogs = Services.prefs.getBoolPref( + "mail.provider.suppress_dialog_on_startup", + false + ); + + if (suppressDialogs) { + // Looks like we were in the middle of filling out an account form. We + // won't display the dialogs in that case. + Services.prefs.clearUserPref("mail.provider.suppress_dialog_on_startup"); + loadPostAccountWizard(); + return; + } + + openAccountSetupTab(); +} + +let _resolveDelayedStartup; +var delayedStartupPromise = new Promise(resolve => { + _resolveDelayedStartup = resolve; +}); + +var gMailInit = { + onBeforeInitialXULLayout() { + // Set a sane starting width/height for all resolutions on new profiles. + // Do this before the window loads. + if (!document.documentElement.hasAttribute("width")) { + const TARGET_WIDTH = 1280; + let defaultWidth = Math.min(screen.availWidth * 0.9, TARGET_WIDTH); + let defaultHeight = screen.availHeight; + + document.documentElement.setAttribute("width", defaultWidth); + document.documentElement.setAttribute("height", defaultHeight); + + // On small screens, default to maximized state. + if (defaultWidth < TARGET_WIDTH) { + document.documentElement.setAttribute("sizemode", "maximized"); + } + // Make sure we're safe at the left/top edge of screen + document.documentElement.setAttribute("screenX", screen.availLeft); + document.documentElement.setAttribute("screenY", screen.availTop); + } + + // Run menubar initialization first, to avoid TabsInTitlebar code picking + // up mutations from it and causing a reflow. + AutoHideMenubar.init(); + TabsInTitlebar.init(); + + if (AppConstants.platform == "win") { + // On Win8 set an attribute when the window frame color is too dark for black text. + if ( + window.matchMedia("(-moz-platform: windows-win8)").matches && + window.matchMedia("(-moz-windows-default-theme)").matches + ) { + let { Windows8WindowFrameColor } = ChromeUtils.importESModule( + "resource:///modules/Windows8WindowFrameColor.sys.mjs" + ); + let windowFrameColor = new Color(...Windows8WindowFrameColor.get()); + // Default to black for foreground text. + if (!windowFrameColor.isContrastRatioAcceptable(new Color(0, 0, 0))) { + document.documentElement.setAttribute("darkwindowframe", "true"); + } + } else if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) { + // 17763 is the build number of Windows 10 version 1809 + if (getWindowsVersionInfo().buildNumber < 17763) { + document.documentElement.setAttribute( + "always-use-accent-color-for-window-border", + "" + ); + } + } + } + + // Call this after we set attributes that might change toolbars' computed + // text color. + ToolbarIconColor.init(); + }, + + /** + * Called on startup to initialize various parts of the main window. + * Most of this should be moved out into _delayedStartup or only + * initialized when needed. + */ + onLoad() { + CreateMailWindowGlobals(); + + if (!Services.policies.isAllowed("devtools")) { + let devtoolsMenu = document.getElementById("devtoolsMenu"); + if (devtoolsMenu) { + devtoolsMenu.hidden = true; + } + } + + // - initialize tabmail system + // Do this before loadPostAccountWizard since that code selects the first + // folder for display, and we want gFolderDisplay setup and ready to handle + // that event chain. + // Also, we definitely need to register the tab type prior to the call to + // specialTabs.openSpecialTabsOnStartup below. + let tabmail = document.getElementById("tabmail"); + if (tabmail) { + // mailTabType is defined in mailTabs.js + tabmail.registerTabType(mailTabType); + // glodaFacetTab* in glodaFacetTab.js + tabmail.registerTabType(glodaFacetTabType); + tabmail.registerTabMonitor(GlodaSearchBoxTabMonitor); + tabmail.openFirstTab(); + } + + // This also registers the contentTabType ("contentTab") + specialTabs.openSpecialTabsOnStartup(); + tabmail.registerTabType(addressBookTabType); + tabmail.registerTabType(preferencesTabType); + // provisionerCheckoutTabType is defined in provisionerCheckout.js + tabmail.registerTabType(provisionerCheckoutTabType); + + // Depending on the pref, hide/show the gloda toolbar search widgets. + XPCOMUtils.defineLazyPreferenceGetter( + this, + "gGlodaEnabled", + "mailnews.database.global.indexer.enabled", + true, + (pref, oldVal, newVal) => { + for (let widget of document.querySelectorAll(".gloda-search-widget")) { + widget.hidden = !newVal; + } + } + ); + for (let widget of document.querySelectorAll(".gloda-search-widget")) { + widget.hidden = !this.gGlodaEnabled; + } + + window.addEventListener("AppCommand", HandleAppCommandEvent, true); + + this._boundDelayedStartup = this._delayedStartup.bind(this); + window.addEventListener("MozAfterPaint", this._boundDelayedStartup); + + // Listen for the messages sent to the main 3 pane window. + window.addEventListener("message", this._onMessageReceived); + }, + + _cancelDelayedStartup() { + window.removeEventListener("MozAfterPaint", this._boundDelayedStartup); + this._boundDelayedStartup = null; + }, + + /** + * Handle the messages sent via postMessage() method to the main 3 pane + * window. + * + * @param {Event} event - The message event. + */ + _onMessageReceived(event) { + switch (event.data) { + case "account-created": + case "account-created-in-backend": + case "account-created-from-provisioner": + // Set the pref to false in case it was previously changed. + Services.prefs.setBoolPref("app.use_without_mail_account", false); + loadPostAccountWizard(); + + // Always update the mail UI to guarantee all the panes are visible even + // if the mail tab is not the currently active tab. + updateMailPaneUI(); + break; + + case "account-setup-closed": + // The user closed the account setup after a successful run. Make sure + // to focus on the primary mail tab. + switchToMailTab(); + gSpacesToolbar.onLoad(); + // Trigger the integration dialog if necessary. + showSystemIntegrationDialog(); + break; + + case "account-setup-dismissed": + // The user closed the account setup before completing it. Be sure to + // initialize the few important areas we need. + if (!gSpacesToolbar.isLoaded) { + loadPostAccountWizard(); + } + break; + + case "open-account-setup-tab": + openAccountSetupTab(); + break; + default: + break; + } + }, + + /** + * Delayed startup happens after the first paint of the window. Anything + * that can be delayed until after paint, should be to help give the + * illusion that Thunderbird is starting faster. + * + * Note: this only runs for the main 3 pane window. + */ + _delayedStartup() { + this._cancelDelayedStartup(); + + MailOfflineMgr.init(); + + BondOpenPGP.init(); + + PanelUI.init(); + gExtensionsNotifications.init(); + + Services.search.init(); + + PeriodicFilterManager.setupFiltering(); + msgDBCacheManager.init(); + + this.delayedStartupFinished = true; + _resolveDelayedStartup(window); + Services.obs.notifyObservers(window, "browser-delayed-startup-finished"); + + // Notify observer to resolve the browserStartupPromise, which is used for the + // delayed background startup of WebExtensions. + Services.obs.notifyObservers(window, "extensions-late-startup"); + + this._loadComponentsAtStartup(); + }, + + /** + * Load all the necessary components to make Thunderbird usable before + * checking for existing accounts. + */ + async _loadComponentsAtStartup() { + updateTroubleshootMenuItem(); + // The calendar component needs to be loaded before restoring any tabs. + await loadCalendarComponent(); + + // Don't trigger the existing account verification if the user wants to use + // Thunderbird without an email account. + if (!Services.prefs.getBoolPref("app.use_without_mail_account", false)) { + // Load the Mail UI only if we already have at least one account configured + // otherwise the verifyExistingAccounts will trigger the account wizard. + if (verifyExistingAccounts()) { + switchToMailTab(); + await loadPostAccountWizard(); + } + } else { + // Run the tabs restore method here since we're skipping the loading of + // the Mail UI which would have taken care of this to properly handle + // opened folders or messages in tabs. + await atStartupRestoreTabs(false); + gSpacesToolbar.onLoad(); + } + + // Show the end of year donation appeal page. + if (this.shouldShowEOYDonationAppeal()) { + // Add a timeout to prevent opening the browser immediately at startup. + setTimeout(this.showEOYDonationAppeal, 2000); + } + }, + + /** + * Called by messenger.xhtml:onunload, the 3-pane window inside of tabs window. + * It's being unloaded! Right now! + */ + onUnload() { + Services.obs.notifyObservers(window, "mail-unloading-messenger"); + + if (gRightMouseButtonSavedSelection) { + // Avoid possible cycle leaks. + gRightMouseButtonSavedSelection.view = null; + gRightMouseButtonSavedSelection = null; + } + + SessionStoreManager.unloadingWindow(window); + TabsInTitlebar.uninit(); + ToolbarIconColor.uninit(); + gSpacesToolbar.onUnload(); + + document.getElementById("tabmail")._teardown(); + + OnMailWindowUnload(); + }, + + /** + * Check if we can trigger the opening of the donation appeal page. + * + * @returns {boolean} - True if the donation appeal page should be opened. + */ + shouldShowEOYDonationAppeal() { + let currentEOY = Services.prefs.getIntPref("app.donation.eoy.version", 1); + let viewedEOY = Services.prefs.getIntPref( + "app.donation.eoy.version.viewed", + 0 + ); + + // True if the user never saw the donation appeal, this is not a new + // profile (since users are already prompted to donate after downloading), + // and we're not running tests. + return ( + viewedEOY < currentEOY && + !specialTabs.shouldShowPolicyNotification() && + !Cu.isInAutomation + ); + }, + + /** + * Open the end of year appeal in a new web browser page. We don't open this + * in a tab due to the complexity of the donation site, and we don't want to + * handle that inside Thunderbird. + */ + showEOYDonationAppeal() { + let url = Services.prefs.getStringPref("app.donation.eoy.url"); + let protocolSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService); + protocolSvc.loadURI(Services.io.newURI(url)); + + let currentEOY = Services.prefs.getIntPref("app.donation.eoy.version", 1); + Services.prefs.setIntPref("app.donation.eoy.version.viewed", currentEOY); + }, +}; + +/** + * Called at startup to verify if we have ny existing account, even if invalid, + * and if not, it will trigger the Account Hub in a tab. + * + * @returns {boolean} - True if we have at least one existing account. + */ +function verifyExistingAccounts() { + try { + // Migrate quoting preferences from global to per account. This function + // returns true if it had to migrate, which we will use to mean this is a + // just migrated or new profile. + let newProfile = migrateGlobalQuotingPrefs( + MailServices.accounts.allIdentities + ); + + // If there are no accounts, or all accounts are "invalid" then kick off the + // account migration. Or if this is a new (to Mozilla) profile. MCD can set + // up accounts without the profile being used yet. + if (newProfile) { + // Check if MCD is configured. If not, say this is not a new profile so + // that we don't accidentally remigrate non MCD profiles. + var adminUrl = Services.prefs.getCharPref( + "autoadmin.global_config_url", + "" + ); + if (!adminUrl) { + newProfile = false; + } + } + + let accounts = MailServices.accounts.accounts; + let invalidAccounts = getInvalidAccounts(accounts); + // Trigger the new account configuration wizard only if we don't have any + // existing account, not even if we have at least one invalid account. + if ( + (newProfile && !accounts.length) || + accounts.length == invalidAccounts.length || + (invalidAccounts.length > 0 && + invalidAccounts.length == accounts.length && + invalidAccounts[0]) + ) { + verifyOpenAccountHubTab(); + return false; + } + + let localFoldersExists; + try { + localFoldersExists = MailServices.accounts.localFoldersServer; + } catch (ex) { + localFoldersExists = false; + } + + // We didn't trigger the account configuration wizard, so we need to verify + // that local folders exists. + if (!localFoldersExists && requireLocalFoldersAccount()) { + MailServices.accounts.createLocalMailAccount(); + } + + return true; + } catch (ex) { + dump(`Error verifying accounts: ${ex}`); + return false; + } +} + +/** + * Switch the view to the first Mail tab if the currently selected tab is not + * the first Mail tab. + */ +function switchToMailTab() { + let tabmail = document.getElementById("tabmail"); + if (tabmail?.selectedTab.mode.name != "folder") { + tabmail.switchToTab(0); + } +} + +/** + * Trigger the initialization of the entire UI. Called after the okCallback of + * the emailWizard during a first run, or directly from the accountProvisioner + * in case a user configures a new email account on first run. + */ +async function loadPostAccountWizard() { + InitMsgWindow(); + + MigrateJunkMailSettings(); + MigrateFolderViews(); + MigrateOpenMessageBehavior(); + + MailServices.accounts.setSpecialFolders(); + + try { + MailServices.accounts.loadVirtualFolders(); + } catch (e) { + console.error(e); + } + + // Init the mozINewMailListener service (MailNotificationManager) before + // any new mails are fetched. + // MailNotificationManager triggers mozINewMailNotificationService + // init as well. + Cc["@mozilla.org/mail/notification-manager;1"].getService( + Ci.mozINewMailListener + ); + + // Restore the previous folder selection before shutdown, or select the first + // inbox folder of a newly created account. + await selectFirstFolder(); + + gSpacesToolbar.onLoad(); +} + +/** + * Check if we need to show the system integration dialog before notifying the + * application that the startup process is completed. + */ +function showSystemIntegrationDialog() { + // Check the shell service. + let shellService; + try { + shellService = Cc["@mozilla.org/mail/shell-service;1"].getService( + Ci.nsIShellService + ); + } catch (ex) {} + let defaultAccount = MailServices.accounts.defaultAccount; + + // Load the search integration module. + let { SearchIntegration } = ChromeUtils.import( + "resource:///modules/SearchIntegration.jsm" + ); + + // Show the default client dialog only if + // EITHER: we have at least one account, and we aren't already the default + // for mail, + // OR: we have the search integration module, the OS version is suitable, + // and the first run hasn't already been completed. + // Needs to be shown outside the he normal load sequence so it doesn't appear + // before any other displays, in the wrong place of the screen. + if ( + (shellService && + defaultAccount && + shellService.shouldCheckDefaultClient && + !shellService.isDefaultClient(true, Ci.nsIShellService.MAIL)) || + (SearchIntegration && + !SearchIntegration.osVersionTooLow && + !SearchIntegration.osComponentsNotRunning && + !SearchIntegration.firstRunDone) + ) { + window.openDialog( + "chrome://messenger/content/systemIntegrationDialog.xhtml", + "SystemIntegration", + "modal,centerscreen,chrome,resizable=no" + ); + // On Windows, there seems to be a delay between setting TB as the + // default client, and the isDefaultClient check succeeding. + if (shellService.isDefaultClient(true, Ci.nsIShellService.MAIL)) { + Services.obs.notifyObservers(window, "mail:setAsDefault"); + } + } +} + +/** + * Properly select the starting folder or message header if we have one. + */ +async function selectFirstFolder() { + let startFolderURI = null; + let startMsgHdr = null; + + if ("arguments" in window && window.arguments.length > 0) { + let arg0 = window.arguments[0]; + // If the argument is a string, it is folder URI. + if (typeof arg0 == "string") { + startFolderURI = arg0; + } else if (arg0) { + // arg0 is an object + if ("wrappedJSObject" in arg0 && arg0.wrappedJSObject) { + arg0 = arg0.wrappedJSObject; + } + startMsgHdr = "msgHdr" in arg0 ? arg0.msgHdr : null; + } + } + + // Don't try to be smart with this because we need the loadStartFolder() + // method to run even if startFolderURI is null otherwise our UI won't + // properly restore. + if (startMsgHdr) { + await loadStartMsgHdr(startMsgHdr); + } else { + await loadStartFolder(startFolderURI); + } +} + +function HandleAppCommandEvent(evt) { + evt.stopPropagation(); + switch (evt.command) { + case "Back": + goDoCommand("cmd_goBack"); + break; + case "Forward": + goDoCommand("cmd_goForward"); + break; + case "Stop": + msgWindow.StopUrls(); + break; + case "Bookmarks": + toAddressBook(); + break; + case "Home": + case "Reload": + default: + break; + } +} + +/** + * Called by the session store manager periodically and at shutdown to get + * the state of this window for persistence. + */ +function getWindowStateForSessionPersistence() { + let tabmail = document.getElementById("tabmail"); + let tabsState = tabmail.persistTabs(); + return { type: "3pane", tabs: tabsState }; +} + +/** + * Attempt to restore the previous tab states. + * + * @param {boolean} aDontRestoreFirstTab - If this is true, the first tab will + * not be restored, and will continue to retain focus at the end. This is + * needed if the window was opened with a folder or a message as an argument. + * @returns true if the restoration was successful, false otherwise. + */ +async function atStartupRestoreTabs(aDontRestoreFirstTab) { + let state = await SessionStoreManager.loadingWindow(window); + if (state) { + let tabsState = state.tabs; + let tabmail = document.getElementById("tabmail"); + try { + tabmail.restoreTabs(tabsState, aDontRestoreFirstTab); + } catch (e) { + console.error(e); + } + } + + // It's now safe to load extra Tabs. + loadExtraTabs(); + + // Note: The tabs have not finished loading at this point. + SessionStoreManager._restored = true; + Services.obs.notifyObservers(window, "mail-tabs-session-restored"); + + return !!state; +} + +/** + * Loads and restores tabs upon opening a window by evaluating window.arguments[1]. + * + * The type of the object is specified by it's action property. It can be + * either "restore" or "open". "restore" invokes tabmail.restoreTab() for each + * item in the tabs array. While "open" invokes tabmail.openTab() for each item. + * + * In case a tab can't be restored it will fail silently + * + * the object need at least the following properties: + * + * { + * action = "restore" | "open" + * tabs = []; + * } + * + */ +function loadExtraTabs() { + if (!("arguments" in window) || window.arguments.length < 2) { + return; + } + + let tab = window.arguments[1]; + if (!tab || typeof tab != "object") { + return; + } + + if ("wrappedJSObject" in tab) { + tab = tab.wrappedJSObject; + } + + let tabmail = document.getElementById("tabmail"); + + // we got no action, so suppose its "legacy" code + if (!("action" in tab)) { + if ("tabType" in tab) { + tabmail.openTab(tab.tabType, tab.tabParams); + } + return; + } + + if (!("tabs" in tab)) { + return; + } + + // this is used if a tab is detached to a new window. + if (tab.action == "restore") { + for (let i = 0; i < tab.tabs.length; i++) { + tabmail.restoreTab(tab.tabs[i]); + } + + // we currently do not support opening in background or opening a + // special position. So select the last tab opened. + tabmail.switchToTab(tabmail.tabInfo[tabmail.tabInfo.length - 1]); + return; + } + + if (tab.action == "open") { + for (let i = 0; i < tab.tabs.length; i++) { + if ("tabType" in tab.tabs[i]) { + tabmail.openTab(tab.tabs[i].tabType, tab.tabs[i].tabParams); + } + } + } +} + +/** + * Loads the given message header at window open. Exactly one out of this and + * |loadStartFolder| should be called. + * + * @param aStartMsgHdr The message header to load at window open + */ +async function loadStartMsgHdr(aStartMsgHdr) { + // We'll just clobber the default tab + await atStartupRestoreTabs(true); + + MsgDisplayMessageInFolderTab(aStartMsgHdr); +} + +async function loadStartFolder(initialUri) { + var defaultServer = null; + var startFolder; + var isLoginAtStartUpEnabled = false; + + // If a URI was explicitly specified, we'll just clobber the default tab + let loadFolder = !(await atStartupRestoreTabs(!!initialUri)); + + if (initialUri) { + loadFolder = true; + } + + // First get default account + try { + if (initialUri) { + startFolder = MailUtils.getOrCreateFolder(initialUri); + } else { + let defaultAccount = MailServices.accounts.defaultAccount; + if (!defaultAccount) { + return; + } + + defaultServer = defaultAccount.incomingServer; + var rootMsgFolder = defaultServer.rootMsgFolder; + + startFolder = rootMsgFolder; + + // Enable check new mail once by turning checkmail pref 'on' to bring + // all users to one plane. This allows all users to go to Inbox. User can + // always go to server settings panel and turn off "Check for new mail at startup" + if (!Services.prefs.getBoolPref(kMailCheckOncePrefName)) { + Services.prefs.setBoolPref(kMailCheckOncePrefName, true); + defaultServer.loginAtStartUp = true; + } + + // Get the user pref to see if the login at startup is enabled for default account + isLoginAtStartUpEnabled = defaultServer.loginAtStartUp; + + // Get Inbox only if login at startup is enabled. + if (isLoginAtStartUpEnabled) { + // now find Inbox + var inboxFolder = rootMsgFolder.getFolderWithFlags( + Ci.nsMsgFolderFlags.Inbox + ); + if (!inboxFolder) { + return; + } + + startFolder = inboxFolder; + } + } + + // it is possible we were given an initial uri and we need to subscribe or try to add + // the folder. i.e. the user just clicked on a news folder they aren't subscribed to from a browser + // the news url comes in here. + + // Perform biff on the server to check for new mail, except for imap + // or a pop3 account that is deferred or deferred to, + // or the case where initialUri is non-null (non-startup) + if ( + !initialUri && + isLoginAtStartUpEnabled && + !defaultServer.isDeferredTo && + defaultServer.rootFolder == defaultServer.rootMsgFolder + ) { + defaultServer.performBiff(msgWindow); + } + if (loadFolder) { + let tab = document.getElementById("tabmail")?.tabInfo[0]; + tab.chromeBrowser.addEventListener( + "load", + () => (tab.folder = startFolder), + true + ); + } + } catch (ex) { + console.error(ex); + } + + MsgGetMessagesForAllServers(defaultServer); + + if (MailOfflineMgr.isOnline()) { + // Check if we shut down offline, and restarted online, in which case + // we may have offline events to playback. Since this is not a pref + // the user should set, it's not in mailnews.js, so we need a try catch. + let playbackOfflineEvents = Services.prefs.getBoolPref( + "mailnews.playback_offline", + false + ); + if (playbackOfflineEvents) { + Services.prefs.setBoolPref("mailnews.playback_offline", false); + MailOfflineMgr.offlineManager.goOnline(false, true, msgWindow); + } + + // If appropriate, send unsent messages. This may end up prompting the user, + // so we need to get it out of the flow of the normal load sequence. + setTimeout(function () { + if (MailOfflineMgr.shouldSendUnsentMessages()) { + SendUnsentMessages(); + } + }, 0); + } +} + +function OpenMessageInNewTab(msgHdr, tabParams = {}) { + if (!msgHdr) { + return; + } + + if (tabParams.background === undefined) { + tabParams.background = Services.prefs.getBoolPref( + "mail.tabs.loadInBackground" + ); + if (tabParams.event?.shiftKey) { + tabParams.background = !tabParams.background; + } + } + + let tabmail = document.getElementById("tabmail"); + tabmail.openTab("mailMessageTab", { + ...tabParams, + messageURI: msgHdr.folder.getUriForMsg(msgHdr), + }); +} + +function GetSelectedMsgFolders() { + let tabInfo = document.getElementById("tabmail").currentTabInfo; + if (tabInfo.mode.name == "mail3PaneTab") { + let folder = tabInfo.folder; + if (folder) { + return [folder]; + } + } + return []; +} + +function SelectFolder(folderUri) { + // TODO: Replace this. +} + +function ReloadMessage() {} + +// Some of the per account junk mail settings have been +// converted to global prefs. Let's try to migrate some +// of those settings from the default account. +function MigrateJunkMailSettings() { + var junkMailSettingsVersion = Services.prefs.getIntPref("mail.spam.version"); + if (!junkMailSettingsVersion) { + // Get the default account, check to see if we have values for our + // globally migrated prefs. + let defaultAccount = MailServices.accounts.defaultAccount; + if (defaultAccount) { + // we only care about + var prefix = "mail.server." + defaultAccount.incomingServer.key + "."; + if (Services.prefs.prefHasUserValue(prefix + "manualMark")) { + Services.prefs.setBoolPref( + "mail.spam.manualMark", + Services.prefs.getBoolPref(prefix + "manualMark") + ); + } + if (Services.prefs.prefHasUserValue(prefix + "manualMarkMode")) { + Services.prefs.setIntPref( + "mail.spam.manualMarkMode", + Services.prefs.getIntPref(prefix + "manualMarkMode") + ); + } + if (Services.prefs.prefHasUserValue(prefix + "spamLoggingEnabled")) { + Services.prefs.setBoolPref( + "mail.spam.logging.enabled", + Services.prefs.getBoolPref(prefix + "spamLoggingEnabled") + ); + } + if (Services.prefs.prefHasUserValue(prefix + "markAsReadOnSpam")) { + Services.prefs.setBoolPref( + "mail.spam.markAsReadOnSpam", + Services.prefs.getBoolPref(prefix + "markAsReadOnSpam") + ); + } + } + // bump the version so we don't bother doing this again. + Services.prefs.setIntPref("mail.spam.version", 1); + } +} + +// The first time a user runs a build that supports folder views, pre-populate the favorite folders list +// with the existing INBOX folders. +function MigrateFolderViews() { + var folderViewsVersion = Services.prefs.getIntPref( + "mail.folder.views.version" + ); + if (!folderViewsVersion) { + for (let server of MailServices.accounts.allServers) { + if (server) { + let inbox = MailUtils.getInboxFolder(server); + if (inbox) { + inbox.setFlag(Ci.nsMsgFolderFlags.Favorite); + } + } + } + Services.prefs.setIntPref("mail.folder.views.version", 1); + } +} + +// Do a one-time migration of the old mailnews.reuse_message_window pref to the +// newer mail.openMessageBehavior. This does the migration only if the old pref +// is defined. +function MigrateOpenMessageBehavior() { + let openMessageBehaviorVersion = Services.prefs.getIntPref( + "mail.openMessageBehavior.version" + ); + if (!openMessageBehaviorVersion) { + // Don't touch this if it isn't defined + if ( + Services.prefs.getPrefType("mailnews.reuse_message_window") == + Ci.nsIPrefBranch.PREF_BOOL + ) { + if (Services.prefs.getBoolPref("mailnews.reuse_message_window")) { + Services.prefs.setIntPref( + "mail.openMessageBehavior", + MailConsts.OpenMessageBehavior.EXISTING_WINDOW + ); + } else { + Services.prefs.setIntPref( + "mail.openMessageBehavior", + MailConsts.OpenMessageBehavior.NEW_TAB + ); + } + } + + Services.prefs.setIntPref("mail.openMessageBehavior.version", 1); + } +} + +function messageFlavorDataProvider() {} + +messageFlavorDataProvider.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIFlavorDataProvider"]), + + getFlavorData(aTransferable, aFlavor, aData) { + if (aFlavor !== "application/x-moz-file-promise") { + return; + } + let fileUriPrimitive = {}; + aTransferable.getTransferData( + "application/x-moz-file-promise-url", + fileUriPrimitive + ); + + let fileUriStr = fileUriPrimitive.value.QueryInterface( + Ci.nsISupportsString + ); + let fileUri = Services.io.newURI(fileUriStr.data); + let fileUrl = fileUri.QueryInterface(Ci.nsIURL); + let fileName = fileUrl.fileName.replace(/(.{74}).*(.{10})$/u, "$1...$2"); + + let destDirPrimitive = {}; + aTransferable.getTransferData( + "application/x-moz-file-promise-dir", + destDirPrimitive + ); + let destDirectory = destDirPrimitive.value.QueryInterface(Ci.nsIFile); + let file = destDirectory.clone(); + file.append(fileName); + + let messageUriPrimitive = {}; + aTransferable.getTransferData("text/x-moz-message", messageUriPrimitive); + let messageUri = messageUriPrimitive.value.QueryInterface( + Ci.nsISupportsString + ); + + let messenger = Cc["@mozilla.org/messenger;1"].createInstance( + Ci.nsIMessenger + ); + messenger.saveAs( + messageUri.data, + true, + null, + decodeURIComponent(file.path), + true + ); + }, +}; + +var TabsInTitlebar = { + init() { + this._readPref(); + Services.prefs.addObserver(this._drawInTitlePref, this); + + window.addEventListener("resolutionchange", this); + window.addEventListener("resize", this); + + this._initialized = true; + this.update(); + }, + + allowedBy(condition, allow) { + if (allow) { + if (condition in this._disallowed) { + delete this._disallowed[condition]; + this.update(); + } + } else if (!(condition in this._disallowed)) { + this._disallowed[condition] = null; + this.update(); + } + }, + + get systemSupported() { + let isSupported = false; + switch (AppConstants.MOZ_WIDGET_TOOLKIT) { + case "windows": + case "cocoa": + isSupported = true; + break; + case "gtk": + isSupported = window.matchMedia("(-moz-gtk-csd-available)"); + break; + } + delete this.systemSupported; + return (this.systemSupported = isSupported); + }, + + get enabled() { + return document.documentElement.getAttribute("tabsintitlebar") == "true"; + }, + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + this._readPref(); + } + }, + + handleEvent(aEvent) { + switch (aEvent.type) { + case "resolutionchange": + if (aEvent.target == window) { + this.update(); + } + break; + case "resize": + // The spaces toolbar needs special styling for the fullscreen mode. + gSpacesToolbar.onWindowResize(); + if (window.fullScreen || aEvent.target != window) { + break; + } + // We use resize events because the window is not ready after + // sizemodechange events. However, we only care about the event when + // the sizemode is different from the last time we updated the + // appearance of the tabs in the titlebar. + let sizemode = document.documentElement.getAttribute("sizemode"); + if (this._lastSizeMode == sizemode) { + break; + } + let oldSizeMode = this._lastSizeMode; + this._lastSizeMode = sizemode; + // Don't update right now if we are leaving fullscreen, since the UI is + // still changing in the consequent "fullscreen" event. Code there will + // call this function again when everything is ready. + // See browser-fullScreen.js: FullScreen.toggle and bug 1173768. + if (oldSizeMode == "fullscreen") { + break; + } + this.update(); + break; + } + }, + + _initialized: false, + _disallowed: {}, + _drawInTitlePref: "mail.tabs.drawInTitlebar", + _lastSizeMode: null, + + _readPref() { + // check is only true when drawInTitlebar=true + let check = Services.prefs.getBoolPref(this._drawInTitlePref); + this.allowedBy("pref", check); + }, + + update() { + if (!this._initialized || window.fullScreen) { + return; + } + + let allowed = + this.systemSupported && Object.keys(this._disallowed).length == 0; + + if ( + document.documentElement.getAttribute("chromehidden")?.includes("toolbar") + ) { + // Don't draw in titlebar in case of a popup window. + allowed = false; + } + + if (allowed) { + document.documentElement.setAttribute("tabsintitlebar", "true"); + if (AppConstants.platform == "macosx") { + document.documentElement.setAttribute("chromemargin", "0,-1,-1,-1"); + document.documentElement.removeAttribute("drawtitle"); + } else { + document.documentElement.setAttribute("chromemargin", "0,2,2,2"); + } + } else { + document.documentElement.removeAttribute("tabsintitlebar"); + document.documentElement.removeAttribute("chromemargin"); + if (AppConstants.platform == "macosx") { + document.documentElement.setAttribute("drawtitle", "true"); + } + } + }, + + uninit() { + this._initialized = false; + Services.prefs.removeObserver(this._drawInTitlePref, this); + }, +}; + +var BrowserAddonUI = { + async promptRemoveExtension(addon) { + let { name } = addon; + let [title, btnTitle] = await document.l10n.formatValues([ + { + id: "addon-removal-title", + args: { name }, + }, + { + id: "addon-removal-confirmation-button", + }, + ]); + let { + BUTTON_TITLE_IS_STRING: titleString, + BUTTON_TITLE_CANCEL: titleCancel, + BUTTON_POS_0, + BUTTON_POS_1, + confirmEx, + } = Services.prompt; + let btnFlags = BUTTON_POS_0 * titleString + BUTTON_POS_1 * titleCancel; + let message = null; + + if (!Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) { + message = await document.l10n.formatValue( + "addon-removal-confirmation-message", + { + name, + } + ); + } + + let checkboxState = { value: false }; + let result = confirmEx( + window, + title, + message, + btnFlags, + btnTitle, + /* button1 */ null, + /* button2 */ null, + /* checkboxMessage */ null, + checkboxState + ); + + return { remove: result === 0, report: false }; + }, + + async removeAddon(addonId) { + let addon = addonId && (await AddonManager.getAddonByID(addonId)); + if (!addon || !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) { + return; + } + + let { remove, report } = await this.promptRemoveExtension(addon); + + if (remove) { + await addon.uninstall(report); + } + }, +}; diff --git a/comm/mail/base/content/messenger.xhtml b/comm/mail/base/content/messenger.xhtml new file mode 100644 index 0000000000..cf58784374 --- /dev/null +++ b/comm/mail/base/content/messenger.xhtml @@ -0,0 +1,671 @@ +<?xml version="1.0"?> +# 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/. + +#filter substitution +#define MAIN_WINDOW +<?xml-stylesheet href="chrome://messenger/skin/mailWindow1.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/tagColors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/glodacomplete.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/tabmail.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/popupPanel.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/chat.css" type="text/css"?> +<?xml-stylesheet href="chrome://chat/skin/imtooltip.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/messageHeader.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/icons.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/folderMenus.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/attachmentList.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/searchBox.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/panelUI.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/openpgp/inlineNotification.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/spacesToolbar.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/unifiedToolbar.css" type="text/css"?> + +<!-- Calendar CSS --> +<?xml-stylesheet href="chrome://calendar/skin/calendar.css" type="text/css"?> + +<?xml-stylesheet href="chrome://calendar/skin/calendar-event-dialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css" type="text/css"?> + +<?xml-stylesheet href="chrome://calendar/skin/today-pane.css" type="text/css"?> + +<?xml-stylesheet href="chrome://calendar/skin/calendar-unifinder.css" type="text/css"?> + +<?xml-stylesheet href="chrome://calendar/skin/calendar-task-tree.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/calendar-task-view.css" type="text/css"?> + +<?xml-stylesheet href="chrome://calendar/skin/calendar-views.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/shared/calendar-alarms.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/shared/widgets/minimonth.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/widgets/calendar-widgets.css" type="text/css"?> + +# All DTD information is stored in a separate file so that it can be shared by +# hiddenWindowMac.xhtml. +<!DOCTYPE html [ +#include messenger-doctype.inc.dtd +]> + +<!-- + - The 'what you think of when you think of thunderbird' window; + - 3-pane view inside of tabs. + --> +<html id="messengerWindow" xmlns="http://www.w3.org/1999/xhtml" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + icon="messengerWindow" + titlemodifier="&titledefault.label;@PRE_RELEASE_SUFFIX@" + titlemenuseparator="&titleSeparator.label;" + defaultTabTitle="&defaultTabTitle.label;" + windowtype="mail:3pane" + macanimationtype="document" + screenX="10" screenY="10" + scrolling="false" + persist="screenX screenY width height sizemode" + toggletoolbar="true" + lightweightthemes="true" + fullscreenbutton="true" + calendar-deactivated=""> +<head> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="messenger/shortcuts.ftl" /> + <link rel="localization" href="messenger/messenger.ftl" /> + <link rel="localization" href="toolkit/main-window/findbar.ftl" /> + <link rel="localization" href="toolkit/global/textActions.ftl" /> + <link rel="localization" href="toolkit/printing/printUI.ftl" /> + <link rel="localization" href="messenger/menubar.ftl" /> + <link rel="localization" href="messenger/appmenu.ftl" /> + <link rel="localization" href="messenger/openpgp/openpgp.ftl" /> + <link rel="localization" href="messenger/openpgp/openpgp-frontend.ftl" /> + <link rel="localization" href="messenger/openpgp/msgReadStatus.ftl"/> + <link rel="localization" href="calendar/calendar-widgets.ftl" /> + <link rel="localization" href="calendar/calendar-context-menus.ftl" /> + <link rel="localization" href="calendar/calendar-editable-item.ftl" /> + <link rel="localization" href="messenger/chat.ftl" /> + <link rel="localization" href="messenger/messageheader/headerFields.ftl" /> + <link rel="localization" href="messenger/mailWidgets.ftl" /> + <link rel="localization" href="messenger/unifiedToolbar.ftl" /> + <link rel="localization" href="messenger/unifiedToolbarItems.ftl" /> +#ifdef NIGHTLY_BUILD + <link rel="localization" href="messenger/firefoxAccounts.ftl" /> +#endif + + <title>&titledefault.label;@PRE_RELEASE_SUFFIX@</title> + + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/mailWindow.js"></script> + <script defer="defer" src="chrome://messenger/content/selectionsummaries.js"></script> + <script defer="defer" src="chrome://messenger/content/messenger.js"></script> + <script defer="defer" src="chrome://messenger/content/specialTabs.js"></script> + <script defer="defer" src="chrome://messenger/content/spacesToolbar.js"></script> + <script defer="defer" src="chrome://messenger/content/newmailaccount/provisionerCheckout.js"></script> + <script defer="defer" src="chrome://messenger/content/glodaFacetTab.js"></script> + <script defer="defer" src="chrome://messenger/content/searchBar.js"></script> + <script defer="defer" src="chrome://messenger/content/mail3PaneWindowCommands.js"></script> + <script defer="defer" src="chrome://global/content/contentAreaUtils.js"></script> + <script defer="defer" src="chrome://messenger/content/browserPopups.js"></script> + <script defer="defer" src="chrome://messenger/content/accountUtils.js"></script> + <script defer="defer" src="chrome://communicator/content/contentAreaClick.js"></script> + <script defer="defer" src="chrome://messenger/content/toolbarIconColor.js"></script> + <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/chat-messenger.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/imStatusSelector.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/imContextMenu.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/chat-conversation.js"></script> + <script defer="defer" src="chrome://messenger/content/addressbook/addressBookTab.js"></script> + <script defer="defer" src="chrome://messenger/content/preferences/preferencesTab.js"></script> + <script defer="defer" src="chrome://messenger/content/mailCore.js"></script> + <script defer="defer" src="chrome://messenger/content/mailCommands.js"></script> + <script defer="defer" src="chrome://messenger/content/mailWindowOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/mailTabs.js"></script> + <script defer="defer" src="chrome://messenger-newsblog/content/newsblogOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/mail-offline.js"></script> + <script defer="defer" src="chrome://messenger/content/msgViewPickerOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/viewZoomOverlay.js"></script> + <script defer="defer" src="chrome://communicator/content/utilityOverlay.js"></script> + <script defer="defer" src="chrome://messenger/content/newmailaccount/uriListener.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/chat-conversation-info.js"></script> + <script defer="defer" src="chrome://gloda/content/autocomplete-richlistitem.js"></script> + <script defer="defer" src="chrome://gloda/content/glodacomplete.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/chat-contact.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/chat-group.js"></script> + <script defer="defer" src="chrome://messenger/content/chat/chat-imconv.js"></script> + <script defer="defer" src="chrome://messenger/content/tabmail-tab.js"></script> + <script defer="defer" src="chrome://messenger/content/tabmail-tabs.js"></script> + <script defer="defer" src="chrome://messenger/content/tabmail.js"></script> + <script defer="defer" src="chrome://messenger/content/messenger-customization.js"></script> + <script defer="defer" src="chrome://messenger/content/customizable-toolbar.js"></script> +#ifdef NIGHTLY_BUILD + <script defer="defer" src="chrome://messenger/content/sync.js"></script> +#endif + <!-- panelUI.js is for the appmenus. --> + <script defer="defer" src="chrome://messenger/content/panelUI.js"></script> +#ifdef XP_MACOSX + <script defer="defer" src="chrome://messenger/content/macMessengerMenu.js"></script> + <script defer="defer" src="chrome://global/content/macWindowMenu.js"></script> +#endif +#ifdef XP_WIN + <script defer="defer" src="chrome://messenger/content/minimizeToTray.js"></script> +#endif + <!-- calendar-management.js also needed for multiple calendar support and today pane --> + <script defer="defer" src="chrome://calendar/content/calendar-management.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-tabs.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-modes.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-day-label.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-clipboard.js"></script> + + <script defer="defer" src="chrome://calendar/content/import-export.js"></script> + + <script defer="defer" src="chrome://calendar/content/publish.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-item-editing.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-chrome-startup.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/mouseoverPreviews.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-views-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-filter.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-base-view.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-dnd-widgets.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-editable-item.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-month-view.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-multiday-view.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-views.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-dnd-listener.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-statusbar.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-invitation-panel.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-minidate.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-modebox.js"></script> + + <!-- NEEDED FOR TASK VIEW/LIST SUPPORT --> + <script defer="defer" src="chrome://calendar/content/calendar-task-editing.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-extract.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-invitations-manager.js"></script> + + <!-- NEEDED FOR EVENT/TASK IN A TAB --> + <script defer="defer" src="chrome://calendar/content/calendar-item-panel.js"></script> + + <script defer="defer" src="chrome://calendar/content/calendar-command-controller.js"></script> + + <!-- NEEDED FOR EVENTS VIEW (UNIFINDER) --> + <script defer="defer" src="chrome://calendar/content/calendar-unifinder.js"></script> + + <!-- NEEDED FOR TODAY PANE AND TASKS VIEW --> + + <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-task-tree-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/today-pane-agenda.js"></script> + <script defer="defer" src="chrome://calendar/content/today-pane.js"></script> + + <!-- NEEDED FOR TASK VIEW --> + <script defer="defer" src="chrome://calendar/content/calendar-task-tree-view.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-task-tree.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-task-view.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-menus.js"></script> + + <!-- NEEDED FOR MIGRATION CHECK AT INSTALL --> + <script defer="defer" src="chrome://calendar/content/calendar-migration.js"></script> + <script defer="defer" src="chrome://messenger/content/shortcutsOverlay.js"></script> + + <script defer="defer" src="chrome://messenger/content/accountcreation/accountHub.js"></script> + + <!-- Unified toolbar --> + <script type="module" defer="defer" src="chrome://messenger/content/unifiedtoolbar/unified-toolbar.mjs"></script> + + <script> + window.onload = gMailInit.onLoad.bind(gMailInit); + window.onunload = gMailInit.onUnload.bind(gMailInit); + + window.addEventListener("MozBeforeInitialXULLayout", + gMailInit.onBeforeInitialXULLayout.bind(gMailInit), { once: true }); + </script> + + <!-- Color customization for the folder pane. --> + <style id="folderColorsStyle"></style> + <style id="folderColorsStylePreview"></style> +</head> +<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/> +<stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/> + +<commandset id="mailCommands"> +#include mainCommandSet.inc.xhtml + <commandset id="mailSearchMenuItems"/> + <commandset id="globalEditMenuItems" + commandupdater="true" + events="create-menu-edit" + oncommandupdate="goUpdateGlobalEditMenuItems()"/> + <commandset id="selectEditMenuItems" + commandupdater="true" + events="create-menu-edit" + oncommandupdate="goUpdateSelectEditMenuItems()"/> + <commandset id="undoEditMenuItems" + commandupdater="true" + events="undo" + oncommandupdate="goUpdateUndoEditMenuItems()"/> + <commandset id="clipboardEditMenuItems" + commandupdater="true" + events="clipboard" + oncommandupdate="goUpdatePasteMenuItems()"/> + <commandset id="webSearchItems"/> + <commandset id="browserCommands"> + <!-- Browsing back and forth inside the add-on manager and on content tabs --> + <command id="Browser:Back" + oncommand="goDoCommand('Browser:Back');"/> + <command id="Browser:Forward" + oncommand="goDoCommand('Browser:Forward');"/> + </commandset> + <commandset id="attachmentCommands"> + <command id="cmd_openAllAttachments" + oncommand="goDoCommand('cmd_openAllAttachments');"/> + <command id="cmd_saveAllAttachments" + oncommand="goDoCommand('cmd_saveAllAttachments');"/> + <command id="cmd_detachAllAttachments" + oncommand="goDoCommand('cmd_detachAllAttachments');"/> + <command id="cmd_deleteAllAttachments" + oncommand="goDoCommand('cmd_deleteAllAttachments');"/> + </commandset> + <commandset id="tasksCommands"> + <command id="cmd_newMessage" oncommand="goOpenNewMessage();"/> + <command id="cmd_newCard" oncommand="openNewCardDialog()"/> + </commandset> + <command id="cmd_close" oncommand="CloseTabOrWindow();"/> + <command id="cmd_CustomizeMailToolbar" + oncommand="customizeMailToolbarForTabType()"/> +</commandset> + +#include ../../../calendar/base/content/calendar-commands.inc.xhtml + +<keyset id="browserKeys"> +#ifdef XP_MACOSX + <key id="key_goBackKb" keycode="VK_LEFT" oncommand="goDoCommand('Browser:Back');" modifiers="accel"/> + <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="goDoCommand('Browser:Forward');" modifiers="accel"/> +#else + <key id="key_goBackKb" keycode="VK_LEFT" oncommand="goDoCommand('Browser:Back');" modifiers="alt" /> + <key id="key_goForwardKb" keycode="VK_RIGHT" oncommand="goDoCommand('Browser:Forward');" modifiers="alt" /> +#endif +</keyset> +<keyset id="mailKeys"> + <!-- Tab/F6 Keys --> + <key keycode="VK_TAB" oncommand="SwitchPaneFocus(event);" modifiers="control,shift"/> + <key keycode="VK_TAB" oncommand="SwitchPaneFocus(event);" modifiers="control"/> + <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="control,shift"/> + <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="control"/> + <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);" modifiers="shift"/> + <key keycode="VK_F6" oncommand="SwitchPaneFocus(event);"/> +#include mainKeySet.inc.xhtml + <keyset id="tasksKeys"> +#ifdef XP_MACOSX + <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage" + modifiers="accel,shift"/> + <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage" + modifiers="accel"/> +#else + <key id="key_newMessage" key="&newMessageCmd.key;" command="cmd_newMessage" + modifiers="accel"/> + <key id="key_newMessage2" key="&newMessageCmd2.key;" command="cmd_newMessage" + modifiers="accel"/> +#endif + </keyset> +</keyset> + +#include ../../../calendar/base/content/calendar-keys.inc.xhtml + +<popupset id="mainPopupSet"> +#include widgets/browserPopups.inc.xhtml +#include widgets/toolbarContext.inc.xhtml + <menupopup id="aboutPagesContext" + onpopupshowing="goUpdateCommand('cmd_copy'); goUpdateCommand('cmd_paste'); goUpdateCommand('cmd_selectAll');"> + <menuitem id="aboutPagesContext-copy" + data-l10n-id="text-action-copy" + command="cmd_copy"/> + <menuitem id="aboutPagesContext-paste" + data-l10n-id="text-action-paste" + command="cmd_paste"/> + <menuitem id="aboutPagesContext-selectall" + data-l10n-id="text-action-select-all" + command="cmd_selectAll"/> + </menupopup> + +<!-- The panelUI is for the appmenu. --> +#include ../../components/customizableui/content/panelUI.inc.xhtml +#include ../../components/unifiedtoolbar/content/unifiedToolbarPopups.inc.xhtml + <panel is="glodacomplete-rich-result-popup" + id="PopupGlodaAutocomplete" + noautofocus="true"/> + + <tooltip id="attachmentListTooltip"/> + + <!-- We want to be able to do the following: + + 1) Open the tabContextMenu by right-clicking on individual tab selectors + 2) Open the mail-toolbox customize context menu when right-clicking on + the empty space of the tab selector. + + In order to do that, we make the tabContextMenu available in the main + document, and refer to it via the context attributes of each newly spawned + tab selector. We also make the context attribute of the tab strip default + to the mail-toolbox customization popup. + + So, when right-clicking on a tab, the tabContextMenu opens up, and stops + the click event from propagating - but when the strip is right-clicked + outside of any tabs, the mail-toolbox context menu opens, as desired. + --> + + <menupopup id="tabContextMenu"> + <menuitem id="tabContextMenuOpenInWindow" + label="&moveToNewWindow.label;" + accesskey="&moveToNewWindow.accesskey;"/> + <menuseparator /> + <menuitem id="tabContextMenuCloseOtherTabs" + label="&closeOtherTabsCmd2.label;" + accesskey="&closeOtherTabsCmd2.accesskey;"/> + <menuseparator /> + <menu id="tabContextMenuRecentlyClosed" + label="&recentlyClosedTabsCmd.label;" + accesskey="&recentlyClosedTabsCmd.accesskey;"> + <menupopup /> + </menu> + <menuitem id="tabContextMenuClose" + label="&closeTabCmd2.label;" + accesskey="&closeTabCmd2.accesskey;"/> + </menupopup> + + <tooltip id="aHTMLTooltip" page="true"/> + + <panel id="notification-popup" + position="after_end" + orient="vertical" + noautofocus="true" + role="alert"/> + + <popupnotification id="addon-progress-notification" hasicon="true" hidden="true"> + <popupnotificationcontent orient="vertical"> + <html:progress id="addon-progress-notification-progressmeter" max="100"/> + <label id="addon-progress-notification-progresstext" crop="end"/> + </popupnotificationcontent> + </popupnotification> + + <popupnotification id="addon-install-confirmation-notification" hasicon="true" hidden="true"> + <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/> + </popupnotification> + + <popupnotification id="addon-webext-permissions-notification" hasicon="true" hidden="true"> + <popupnotificationcontent class="addon-webext-perm-notification-content" orient="vertical"> + <description id="addon-webext-perm-text" class="addon-webext-perm-text"/> + <label id="addon-webext-perm-intro" class="addon-webext-perm-text"/> + <label id="addon-webext-perm-single-entry" class="addon-webext-perm-single-entry"/> + <html:ul id="addon-webext-perm-list" class="addon-webext-perm-list"/> + <description id="addon-webext-experiment-warning" class="addon-webext-experiment-warning"/> + <hbox> + <label id="addon-webext-perm-info" is="text-link" class="popup-notification-learnmore-link"/> + </hbox> + </popupnotificationcontent> + </popupnotification> + + <popupnotification id="addon-installed-notification" hasicon="true" hidden="true"> + <popupnotificationcontent class="addon-installed-notification-content" orient="vertical"> + <html:ul id="addon-installed-list" class="addon-installed-list"/> + </popupnotificationcontent> + </popupnotification> + + <popupnotification id="addon-install-blocked-notification" hasicon="true" hidden="true"> + <popupnotificationcontent id="addon-install-blocked-content" orient="vertical"> + <description id="addon-install-blocked-message" class="popup-notification-description"></description> + <hbox> + <label id="addon-install-blocked-info" class="popup-notification-learnmore-link" is="text-link"/> + </hbox> + </popupnotificationcontent> + </popupnotification> + +#include ../../components/im/content/chat-menu.inc.xhtml +</popupset> +#ifdef XP_MACOSX +<popupset> + <menupopup id="menu_mac_dockmenu"> + <menuitem label="&writeNewMessageDock.label;" id="tasksWriteNewMessage" + oncommand="writeNewMessageDock();"/> + <menuitem label="&openAddressBookDock.label;" id="tasksOpenAddressBook" + oncommand="openAddressBookDock();"/> + <menuitem label="&dockOptions.label;" id="tasksMenuDockOptions" + oncommand="openDockOptions();"/> + </menupopup> +</popupset> +#endif + +#include ../../../calendar/base/content/calendar-context-menus-and-tooltips.inc.xhtml +#include ../../components/unifiedtoolbar/content/unifiedToolbarTemplates.inc.xhtml + +#include spacesToolbar.inc.xhtml + +<!-- + GTK needs to draw behind the lightweight theme toolbox backgrounds, thus the + extra box. Also this box allows a negative margin-top to slide the toolbox off + screen in fullscreen layout. +--> +<box id="navigation-toolbox-background"> + <toolbox id="navigation-toolbox" flex="1" labelalign="end" defaultlabelalign="end"> + + <vbox id="titlebar"> + <html:unified-toolbar></html:unified-toolbar> + <!-- Menu --> + <toolbar id="toolbar-menubar" + class="chromeclass-menubar themeable-full" + type="menubar" +#ifdef XP_MACOSX + autohide="true" +#endif +#ifndef XP_MACOSX + data-l10n-id="toolbar-context-menu-menu-bar" + data-l10n-attrs="toolbarname" +#endif + context="toolbar-context-menu" + mode="icons" + insertbefore="tabs-toolbar" + prependmenuitem="true"> +# The entire main menubar is placed into messenger-menubar.inc.xhtml, so that it +# can be shared with other top level windows. +#include messenger-menubar.inc.xhtml + </toolbar> + + <toolbar id="tabs-toolbar" class="chromeclass-toolbar"> + <tabs is="tabmail-tabs" id="tabmail-tabs" + flex="1" + align="end" + setfocus="false" + alltabsbutton="alltabs-button" + context="toolbar-context-menu" + collapsetoolbar="tabs-toolbar"> + <html:img class="tab-drop-indicator" + src="chrome://messenger/skin/icons/tab-drag-indicator.svg" + alt="" + hidden="hidden" /> + <arrowscrollbox id="tabmail-arrowscrollbox" + orient="horizontal" + flex="1" + clicktoscroll="true" + style="min-width: 1px;"> + <tab is="tabmail-tab" selected="true" + class="tabmail-tab" crop="end"/> + </arrowscrollbox> + </tabs> + + <toolbarbutton class="toolbarbutton-1 tabs-alltabs-button" + id="alltabs-button" + type="menu" + hidden="true" + tooltiptext="&listAllTabs.label;"> + <menupopup is="tabmail-alltabs-menupopup" id="alltabs-popup" + position="after_end" + tabcontainer="tabmail-tabs"/> + </toolbarbutton> + + </toolbar> + + </vbox> + + </toolbox> +</box> + +<vbox id="messengerBody"> + <!-- XXX This extension point (tabmail-container) is only temporary! + Horizontal space shouldn't be wasted if it isn't absolutely critical. + A mechanism for adding sidebar panes will be added in bug 476154. --> + <hbox id="tabmail-container" flex="1"> + <!-- Beware! Do NOT use overlays to append nodes directly to tabmail (children + of tabmail is OK though). This will break Ctrl-tab switching because + the Custom Element will choke when it finds a child of tabmail that is + not a tabpanels node. --> + <tabmail id="tabmail" + class="printPreviewStack" + flex="1" + panelcontainer="tabpanelcontainer" + tabcontainer="tabmail-tabs"> + <tabbox id="tabmail-tabbox" flex="1" eventnode="document" tabcontainer="tabmail-tabs"> + <tabpanels id="tabpanelcontainer" flex="1" class="plain" selectedIndex="0"> +#include ../../components/im/content/chat-messenger.inc.xhtml +#include ../../../calendar/base/content/calendar-tab-panels.inc.xhtml +#include ../../../calendar/base/content/item-editing/calendar-item-panel.inc.xhtml + </tabpanels> + </tabbox> + <html:template id="mail3PaneTabTemplate"> + <stack flex="1"> + <browser flex="1" + src="about:3pane" + autocompletepopup="PopupAutoComplete" + messagemanagergroup="single-page"/> + </stack> + </html:template> + <html:template id="mailMessageTabTemplate"> + <stack flex="1"> + <browser flex="1" + src="about:message" + autocompletepopup="PopupAutoComplete" + messagemanagergroup="single-page"/> + </stack> + </html:template> +#include ../../../calendar/base/content/widgets/calendar-invitation-panel.xhtml +#include ../../../calendar/base/content/widgets/calendar-minidate.xhtml + <!-- Hidden browser used for printing documents without displaying them. --> + <browser id="hiddenPrintContent" + type="content" + nodefaultsrc="true" + maychangeremoteness="true" + hidden="true"/> + </tabmail> +#include ../../../calendar/base/content/calendar-today-pane.inc.xhtml + <vbox id="contentTab" collapsed="true"> + <vbox flex="1" class="contentTabInstance"> + <vbox id="dummycontenttoolbox" class="contentTabToolbox themeable-full"> + <hbox id="dummycontenttoolbar" class="contentTabToolbar"> + <toolbarbutton class="back-btn nav-button" + tooltiptext="&browseBackButton.tooltip;" + disabled="true"/> + <toolbarbutton class="forward-btn nav-button" + tooltiptext="&browseForwardButton.tooltip;" + disabled="true"/> + <toolbaritem class="contentTabAddress" flex="1"> + <html:img class="contentTabSecurity" /> + <html:input class="contentTabUrlInput themeableSearchBox" + readonly="readonly" /> + </toolbaritem> + </hbox> + </vbox> + <stack flex="1"><!-- Insert browser here. --></stack> + </vbox> + </vbox> + <vbox id="glodaTab" collapsed="true"> + <vbox flex="1" class="chromeTabInstance"> + <vbox class="contentTabToolbox themeable-full"> + <hbox class="glodaTabToolbar inline-toolbar chromeclass-toolbar" flex="1"> + <spacer flex="1" /> + <spacer flex="1" /> + <hbox flex="1" class="remote-gloda-search-container"> + <html:img class="search-icon" alt="" + src="chrome://global/skin/icons/search-textbox.svg" /> + <html:input is="gloda-autocomplete-input" + type="text" + class="remote-gloda-search searchBox gloda-search" + searchbutton="true" + autocompletesearch="gloda" + autocompletepopup="PopupGlodaAutocomplete" + autocompletesearchparam="global" + timeout="200" + maxlength="192" + placeholder="" + emptytextbase="&search.label.base1;" + keyLabelNonMac="&search.keyLabel.nonmac;" + keyLabelMac="&search.keyLabel.mac;"/> + </hbox> + </hbox> + </vbox> + <iframe flex="1"/> + </vbox> + </vbox> + <vbox id="preferencesTab" collapsed="true"> + <vbox flex="1"> + <hbox flex="1"> + <browser id="preferencesbrowser" + type="content" + flex="1" + disablehistory="true" + autocompletepopup="PopupAutoComplete" + messagemanagergroup="single-site" + onclick="return contentAreaClick(event);"/> + </hbox> + </vbox> + </vbox> + </hbox> + <panel id="customizeToolbarSheetPopup" noautohide="true"> + <iframe id="customizeToolbarSheetIFrame" + style="&dialog.dimensions;" + hidden="true"/> + </panel> + + <vbox id="messenger-notification-bottom"> + <!-- notificationbox will be added here lazily. --> + </vbox> + <statuspanel id="statusbar-display"/> + <hbox id="status-bar" class="statusbar chromeclass-status"> + <html:button type="button" id="spacesToolbarReveal" + onclick="gSpacesToolbar.toggleToolbar(false);" + data-l10n-id="spaces-toolbar-button-show" + class="plain spaces-toolbar-statusbar-button" + hidden="hidden"> + <html:img src="chrome://messenger/skin/icons/new/compact/collapse.svg" alt="" /> + </html:button> + <!-- We put the role="status" only around the information that is actually + - status information for the mail tabs. Specifically, we exclude the + - Spaces toolbar button and the calendar status bar (which is used when + - editing events in a tab and for the today pane button). --> + <hbox role="status" aria-live="off" flex="1"> +#include mainStatusbar.inc.xhtml + <hbox id="calendar-invitations-panel" class="statusbarpanel" hidden="true"> + <label id="calendar-invitations-label" + class="text-link" + onclick="openInvitationsDialog()" + onkeypress="if (event.key == 'Enter') { openInvitationsDialog(); }"/> + </hbox> + </hbox> +#include ../../../calendar/base/content/calendar-status-bar.inc.xhtml + </hbox> +</vbox><!-- Closing #messengerBody. --> + +#include tabDialogs.inc.xhtml +#include ../../components/accountcreation/templates/accountHubTemplate.inc.xhtml +</html:body> +</html> diff --git a/comm/mail/base/content/migrationProgress.js b/comm/mail/base/content/migrationProgress.js new file mode 100644 index 0000000000..d75536a137 --- /dev/null +++ b/comm/mail/base/content/migrationProgress.js @@ -0,0 +1,64 @@ +/* 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 { MigrationTasks } = ChromeUtils.import( + "resource:///modules/MailMigrator.jsm" +); + +window.addEventListener("load", async function () { + let list = document.getElementById("tasks"); + let itemTemplate = document.getElementById("taskItem"); + let progress = document.querySelector("progress"); + let l10nElements = []; + + for (let task of MigrationTasks.tasks) { + if (!task.fluentID) { + continue; + } + + let item = itemTemplate.content.firstElementChild.cloneNode(true); + item.classList.add(task.status); + + let name = item.querySelector(".task-name"); + document.l10n.setAttributes(name, task.fluentID); + l10nElements.push(name); + + if (task.status == "running") { + if (task.subTasks.length) { + progress.value = task.subTasks.filter( + t => t.status == "finished" + ).length; + progress.max = task.subTasks.length; + progress.style.visibility = null; + } else { + progress.style.visibility = "hidden"; + } + } + + list.appendChild(item); + + task.on("status-change", (event, status) => { + item.classList.remove("pending", "running", "finished"); + item.classList.add(status); + + if (status == "running") { + // Always hide the progress bar when starting a task. If there are + // sub-tasks, it will be shown by a progress event. + progress.style.visibility = "hidden"; + } + }); + task.on("progress", (event, value, max) => { + progress.value = value; + progress.max = max; + progress.style.visibility = null; + }); + } + + await document.l10n.translateElements(l10nElements); + window.sizeToContent(); + window.moveTo( + (screen.width - window.outerWidth) / 2, + (screen.height - window.outerHeight) / 2 + ); +}); diff --git a/comm/mail/base/content/migrationProgress.xhtml b/comm/mail/base/content/migrationProgress.xhtml new file mode 100644 index 0000000000..c196d92668 --- /dev/null +++ b/comm/mail/base/content/migrationProgress.xhtml @@ -0,0 +1,39 @@ +<?xml version="1.0"?> +<!-- 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/. --> +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta charset="utf-8" /> + <title data-l10n-id="migration-progress-header"></title> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="messenger/migration.ftl" /> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + <link + rel="stylesheet" + href="chrome://messenger/skin/migrationProgress.css" + /> + <script + defer="defer" + src="chrome://messenger/content/migrationProgress.js" + ></script> + </head> + <body> + <img + src="chrome://branding/content/icon256.png" + width="256" + height="256" + alt="" + /> + <h1 data-l10n-id="migration-progress-header"></h1> + <ol id="tasks"></ol> + <progress value="0"></progress> + <template id="taskItem"> + <li> + <div class="task-icon"></div> + <span class="task-name"></span> + </li> + </template> + </body> +</html> diff --git a/comm/mail/base/content/minimizeToTray.js b/comm/mail/base/content/minimizeToTray.js new file mode 100644 index 0000000000..f65fcc7b43 --- /dev/null +++ b/comm/mail/base/content/minimizeToTray.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* globals docShell, Services, windowState */ + +addEventListener("sizemodechange", () => { + if ( + windowState == window.STATE_MINIMIZED && + Services.prefs.getBoolPref("mail.minimizeToTray", false) + ) { + setTimeout(() => { + var bw = docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow); + Cc["@mozilla.org/messenger/osintegration;1"] + .getService(Ci.nsIMessengerWindowsIntegration) + .hideWindow(bw); + }); + } +}); diff --git a/comm/mail/base/content/modules/thread-pane-columns.mjs b/comm/mail/base/content/modules/thread-pane-columns.mjs new file mode 100644 index 0000000000..2361379509 --- /dev/null +++ b/comm/mail/base/content/modules/thread-pane-columns.mjs @@ -0,0 +1,385 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "USE_CORRESPONDENTS", + "mail.threadpane.use_correspondents", + true +); +XPCOMUtils.defineLazyModuleGetters(lazy, { + FeedUtils: "resource:///modules/FeedUtils.jsm", + DBViewWrapper: "resource:///modules/DBViewWrapper.jsm", +}); + +/** + * The array of columns for the table layout. This must be kept in sync with + * the row template #threadPaneRowTemplate in about3Pane.xhtml. + * + * @type {Array} + */ +const DEFAULT_COLUMNS = [ + { + id: "selectCol", + l10n: { + header: "threadpane-column-header-select", + menuitem: "threadpane-column-label-select", + }, + ordinal: 1, + select: true, + icon: true, + resizable: false, + sortable: false, + hidden: true, + }, + { + id: "threadCol", + l10n: { + header: "threadpane-column-header-thread", + menuitem: "threadpane-column-label-thread", + }, + ordinal: 2, + thread: true, + icon: true, + resizable: false, + sortable: false, + }, + { + id: "flaggedCol", + l10n: { + header: "threadpane-column-header-flagged", + menuitem: "threadpane-column-label-flagged", + }, + ordinal: 3, + sortKey: "byFlagged", + star: true, + icon: true, + resizable: false, + }, + { + id: "attachmentCol", + l10n: { + header: "threadpane-column-header-attachments", + menuitem: "threadpane-column-label-attachments", + }, + ordinal: 4, + sortKey: "byAttachments", + icon: true, + resizable: false, + }, + { + id: "subjectCol", + l10n: { + header: "threadpane-column-header-subject", + menuitem: "threadpane-column-label-subject", + }, + ordinal: 5, + picker: false, + sortKey: "bySubject", + }, + { + id: "unreadButtonColHeader", + l10n: { + header: "threadpane-column-header-unread-button", + menuitem: "threadpane-column-label-unread-button", + }, + ordinal: 6, + sortKey: "byUnread", + icon: true, + resizable: false, + unread: true, + }, + { + id: "senderCol", + l10n: { + header: "threadpane-column-header-sender", + menuitem: "threadpane-column-label-sender", + }, + ordinal: 7, + sortKey: "byAuthor", + hidden: true, + }, + { + id: "recipientCol", + l10n: { + header: "threadpane-column-header-recipient", + menuitem: "threadpane-column-label-recipient", + }, + ordinal: 8, + sortKey: "byRecipient", + hidden: true, + }, + { + id: "correspondentCol", + l10n: { + header: "threadpane-column-header-correspondents", + menuitem: "threadpane-column-label-correspondents", + }, + ordinal: 9, + sortKey: "byCorrespondent", + }, + { + id: "junkStatusCol", + l10n: { + header: "threadpane-column-header-spam", + menuitem: "threadpane-column-label-spam", + }, + ordinal: 10, + sortKey: "byJunkStatus", + spam: true, + icon: true, + resizable: false, + }, + { + id: "dateCol", + l10n: { + header: "threadpane-column-header-date", + menuitem: "threadpane-column-label-date", + }, + ordinal: 11, + sortKey: "byDate", + }, + { + id: "receivedCol", + l10n: { + header: "threadpane-column-header-received", + menuitem: "threadpane-column-label-received", + }, + ordinal: 12, + sortKey: "byReceived", + hidden: true, + }, + { + id: "statusCol", + l10n: { + header: "threadpane-column-header-status", + menuitem: "threadpane-column-label-status", + }, + ordinal: 13, + sortKey: "byStatus", + hidden: true, + }, + { + id: "sizeCol", + l10n: { + header: "threadpane-column-header-size", + menuitem: "threadpane-column-label-size", + }, + ordinal: 14, + sortKey: "bySize", + hidden: true, + }, + { + id: "tagsCol", + l10n: { + header: "threadpane-column-header-tags", + menuitem: "threadpane-column-label-tags", + }, + ordinal: 15, + sortKey: "byTags", + hidden: true, + }, + { + id: "accountCol", + l10n: { + header: "threadpane-column-header-account", + menuitem: "threadpane-column-label-account", + }, + ordinal: 16, + sortKey: "byAccount", + hidden: true, + }, + { + id: "priorityCol", + l10n: { + header: "threadpane-column-header-priority", + menuitem: "threadpane-column-label-priority", + }, + ordinal: 17, + sortKey: "byPriority", + hidden: true, + }, + { + id: "unreadCol", + l10n: { + header: "threadpane-column-header-unread", + menuitem: "threadpane-column-label-unread", + }, + ordinal: 18, + sortable: false, + hidden: true, + }, + { + id: "totalCol", + l10n: { + header: "threadpane-column-header-total", + menuitem: "threadpane-column-label-total", + }, + ordinal: 19, + sortable: false, + hidden: true, + }, + { + id: "locationCol", + l10n: { + header: "threadpane-column-header-location", + menuitem: "threadpane-column-label-location", + }, + ordinal: 20, + sortKey: "byLocation", + hidden: true, + }, + { + id: "idCol", + l10n: { + header: "threadpane-column-header-id", + menuitem: "threadpane-column-label-id", + }, + ordinal: 21, + sortKey: "byId", + hidden: true, + }, + { + id: "deleteCol", + l10n: { + header: "threadpane-column-header-delete", + menuitem: "threadpane-column-label-delete", + }, + ordinal: 22, + delete: true, + icon: true, + resizable: false, + sortable: false, + hidden: true, + }, +]; + +/** + * Check if the current folder is a special Outgoing folder. + * + * @param {nsIMsgFolder} folder - The message folder. + * @returns {boolean} True if the folder is Outgoing. + */ +export const isOutgoing = folder => { + return folder.isSpecialFolder( + lazy.DBViewWrapper.prototype.OUTGOING_FOLDER_FLAGS, + true + ); +}; + +/** + * Generate the correct default array of columns, accounting for different views + * and folder states. + * + * @param {?nsIMsgFolder} folder - The currently viewed folder if available. + * @param {boolean} [isSynthetic=false] - If the current view is synthetic, + * meaning we are not visualizing a real folder, but rather + * the gloda results list. + * @returns {object[]} + */ +export function getDefaultColumns(folder, isSynthetic = false) { + // Create a clone we can edit. + let updatedColumns = DEFAULT_COLUMNS.map(column => ({ ...column })); + + if (isSynthetic) { + // Synthetic views usually can contain messages from multiple folders. + // Folder for the selected message will still be set. + for (let c of updatedColumns) { + switch (c.id) { + case "correspondentCol": + // Don't show the correspondent if is not wanted. + c.hidden = !lazy.USE_CORRESPONDENTS; + break; + case "senderCol": + // Hide the sender if correspondent is enabled. + c.hidden = lazy.USE_CORRESPONDENTS; + break; + case "attachmentCol": + case "unreadButtonColHeader": + case "junkStatusCol": + // Hide all the columns we don't want in a default gloda view. + c.hidden = true; + break; + case "locationCol": + // Always show the location by default in a gloda view. + c.hidden = false; + break; + } + } + return updatedColumns; + } + + if (!folder) { + // We don't have a folder yet. Use defaults. + return updatedColumns; + } + + for (let c of updatedColumns) { + switch (c.id) { + case "correspondentCol": + // Don't show the correspondent for news or RSS. + c.hidden = lazy.USE_CORRESPONDENTS + ? !folder.getFlag(Ci.nsMsgFolderFlags.Mail) || + lazy.FeedUtils.isFeedFolder(folder) + : true; + break; + case "senderCol": + // Show the sender even if correspondent is enabled for news and feeds. + c.hidden = lazy.USE_CORRESPONDENTS + ? !folder.getFlag(Ci.nsMsgFolderFlags.Newsgroup) && + !lazy.FeedUtils.isFeedFolder(folder) + : isOutgoing(folder); + break; + case "recipientCol": + // No recipient column if we use correspondent. Otherwise hide it if is + // not an outgoing folder. + c.hidden = lazy.USE_CORRESPONDENTS ? true : !isOutgoing(folder); + break; + case "junkStatusCol": + // No ability to mark newsgroup or feed messages as spam. + c.hidden = + folder.getFlag(Ci.nsMsgFolderFlags.Newsgroup) || + lazy.FeedUtils.isFeedFolder(folder); + break; + } + } + return updatedColumns; +} + +/** + * Find the proper column to use as sender field for the cards view. + * + * @param {?nsIMsgFolder} folder - The currently viewed folder if available. + * @returns {string} - The name of the column to use as sender field. + */ +function getProperSenderForCardsView(folder) { + // Default to correspondent as it's the safest choice most of the times. + if (!folder) { + return "correspondentCol"; + } + + // Show the recipient for outgoing folders. + if (isOutgoing(folder)) { + return "recipientCol"; + } + + // Show the sender for any other scenario, including news and feeds folders. + return "senderCol"; +} + +/** + * Get the default array of columns to fetch data for the cards view. + * + * @param {?nsIMsgFolder} folder - The currently viewed folder if available. + * @returns {string[]} + */ +export function getDefaultColumnsForCardsView(folder) { + const sender = getProperSenderForCardsView(folder); + return ["subjectCol", sender, "dateCol", "tagsCol"]; +} diff --git a/comm/mail/base/content/msgAttachmentView.inc.xhtml b/comm/mail/base/content/msgAttachmentView.inc.xhtml new file mode 100644 index 0000000000..934ff7bc18 --- /dev/null +++ b/comm/mail/base/content/msgAttachmentView.inc.xhtml @@ -0,0 +1,102 @@ +# 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/. + + <!-- the message pane consists of 4 'boxes'. Box #4 is the attachment + box which can be toggled into a slim or an expanded view --> + <hbox align="center" id="attachmentBar" + context="attachment-toolbar-context-menu" + onclick="if (event.button == 0) { toggleAttachmentList(undefined, true); }"> + <button type="checkbox" id="attachmentToggle" + onmousedown="event.preventDefault();" + onclick="event.stopPropagation();" + oncommand="toggleAttachmentList(this.checked, true);"/> + <hbox align="center" id="attachmentInfo"> + <html:img id="attachmentIcon" + src="chrome://messenger/skin/icons/attach.svg" + alt="" /> + <label id="attachmentCount"/> + <label id="attachmentName" crop="center" + role="button" + tooltiptext="&openAttachment.tooltip;" + tooltiptextopen="&openAttachment.tooltip;" + onclick="OpenAttachmentFromBar(event);" + ondragstart="attachmentNameDNDObserver.onDragStart(event);"/> + <label id="attachmentSize"/> + </hbox> + <spacer flex="1"/> + + <vbox id="attachment-view-toolbox" class="inline-toolbox"> + <hbox id="attachment-view-toolbar" + class="toolbar themeable-brighttext" + context="attachment-toolbar-context-menu"> + <toolbaritem id="attachmentSaveAll" + title="&saveAllAttachmentsButton1.label;"> + <toolbarbutton is="toolbarbutton-menu-button" id="attachmentSaveAllSingle" + type="menu" + class="toolbarbutton-1 message-header-view-button" + label="&saveAttachmentButton1.label;" + tooltiptext="&saveAttachmentButton1.tooltip;" + onclick="event.stopPropagation();" + oncommand="TryHandleAllAttachments('saveAs');" + hidden="true"> + <menupopup id="attachmentSaveAllSingleMenu" + onpopupshowing="onShowSaveAttachmentMenuSingle();"> + <menuitem id="button-openAttachment" + oncommand="TryHandleAllAttachments('open'); event.stopPropagation();" + label="&openAttachmentCmd.label;" + accesskey="&openAttachmentCmd.accesskey;"/> + <menuitem id="button-saveAttachment" + oncommand="TryHandleAllAttachments('saveAs'); event.stopPropagation();" + label="&saveAsAttachmentCmd.label;" + accesskey="&saveAsAttachmentCmd.accesskey;"/> + <menuseparator id="button-menu-separator"/> + <menuitem id="button-detachAttachment" + oncommand="TryHandleAllAttachments('detach'); event.stopPropagation();" + label="&detachAttachmentCmd.label;" + accesskey="&detachAttachmentCmd.accesskey;"/> + <menuitem id="button-deleteAttachment" + oncommand="TryHandleAllAttachments('delete'); event.stopPropagation();" + label="&deleteAttachmentCmd.label;" + accesskey="&deleteAttachmentCmd.accesskey;"/> + </menupopup> + </toolbarbutton> + <toolbarbutton is="toolbarbutton-menu-button" id="attachmentSaveAllMultiple" + type="menu" + class="toolbarbutton-1 message-header-view-button" + label="&saveAllAttachmentsButton1.label;" + tooltiptext="&saveAllAttachmentsButton1.tooltip;" + onclick="event.stopPropagation();" + oncommand="TryHandleAllAttachments('save');"> + <menupopup id="attachmentSaveAllMultipleMenu" + onpopupshowing="onShowSaveAttachmentMenuMultiple();"> + <menuitem id="button-openAllAttachments" + oncommand="TryHandleAllAttachments('open'); event.stopPropagation();" + label="&openAllAttachmentsCmd.label;" + accesskey="&openAllAttachmentsCmd.accesskey;"/> + <menuitem id="button-saveAllAttachments" + oncommand="TryHandleAllAttachments('save'); event.stopPropagation();" + label="&saveAllAttachmentsCmd.label;" + accesskey="&saveAllAttachmentsCmd.accesskey;"/> + <menuseparator id="button-menu-separator-all"/> + <menuitem id="button-detachAllAttachments" + oncommand="TryHandleAllAttachments('detach'); event.stopPropagation();" + label="&detachAllAttachmentsCmd.label;" + accesskey="&detachAllAttachmentsCmd.accesskey;"/> + <menuitem id="button-deleteAllAttachments" + oncommand="TryHandleAllAttachments('delete'); event.stopPropagation();" + label="&deleteAllAttachmentsCmd.label;" + accesskey="&deleteAllAttachmentsCmd.accesskey;"/> + </menupopup> + </toolbarbutton> + </toolbaritem> + </hbox> + </vbox> + </hbox> + <richlistbox is="attachment-list" id="attachmentList" + class="attachmentList" + seltype="multiple" + context="attachmentListContext" + itemcontext="attachmentItemContext" + role="listbox" + ondragstart="attachmentListDNDObserver.onDragStart(event);"/> diff --git a/comm/mail/base/content/msgHdrPopup.inc.xhtml b/comm/mail/base/content/msgHdrPopup.inc.xhtml new file mode 100644 index 0000000000..3c0b9826bb --- /dev/null +++ b/comm/mail/base/content/msgHdrPopup.inc.xhtml @@ -0,0 +1,224 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + <menupopup id="messageIdContext"> + <menuitem id="messageIdContext-messageIdTarget" disabled="true"/> + <menuseparator id="messageIdContext-separator"/> + <menuitem id="messageIdContext-openMessageForMsgId" + label="&OpenMessageForMsgId.label;" + accesskey="&OpenMessageForMsgId.accesskey;" + oncommand="gMessageHeader.openMessage(event);"/> + <menuitem id="messageIdContext-openBrowserWithMsgId" + label="&OpenBrowserWithMsgId.label;" + accesskey="&OpenBrowserWithMsgId.accesskey;" + oncommand="gMessageHeader.openBrowser(event);"/> + <menuitem id="messageIdContext-copyMessageId" + label="&CopyMessageId.label;" + accesskey="&CopyMessageId.accesskey;" + oncommand="gMessageHeader.copyMessageId(event);"/> + </menupopup> + + <menupopup id="attachmentItemContext" + onpopupshowing="return onShowAttachmentItemContextMenu();" + onpopuphiding="return onHideAttachmentItemContextMenu();"> + <menuitem id="context-openAttachment" + label="&openAttachmentCmd.label;" + accesskey="&openAttachmentCmd.accesskey;" + oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'open');"/> + <menuitem id="context-saveAttachment" + label="&saveAsAttachmentCmd.label;" + accesskey="&saveAsAttachmentCmd.accesskey;" + oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'saveAs');"/> + <menuseparator id="context-menu-separator"/> + <menuitem id="context-detachAttachment" + label="&detachAttachmentCmd.label;" + accesskey="&detachAttachmentCmd.accesskey;" + oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'detach');"/> + <menuitem id="context-deleteAttachment" + label="&deleteAttachmentCmd.label;" + accesskey="&deleteAttachmentCmd.accesskey;" + oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'delete');"/> + <menuseparator id="context-menu-copyurl-separator"/> + <menuitem id="context-copyAttachmentUrl" + label="©LinkCmd.label;" + accesskey="©LinkCmd.accesskey;" + oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'copyUrl');"/> + <menuitem id="context-openFolder" +#ifdef XP_MACOSX + label="&detachedAttachmentFolder.showMac.label;" + accesskey="&detachedAttachmentFolder.showMac.accesskey;" +#else + label="&detachedAttachmentFolder.show.label;" + accesskey="&detachedAttachmentFolder.show.accesskey;" +#endif + oncommand="HandleMultipleAttachments(this.parentNode.attachments, 'openFolder');"/> +#include ../../extensions/openpgp/content/ui/attachmentItemContext.inc.xhtml + </menupopup> + + <menupopup id="attachmentListContext" + onpopupshowing="goUpdateAttachmentCommands();"> + <menuitem id="context-openAllAttachments" + label="&openAllAttachmentsCmd.label;" + accesskey="&openAllAttachmentsCmd.accesskey;" + command="cmd_openAllAttachments"/> + <menuitem id="context-saveAllAttachments" + label="&saveAllAttachmentsCmd.label;" + accesskey="&saveAllAttachmentsCmd.accesskey;" + command="cmd_saveAllAttachments"/> + <menuseparator id="context-menu-separator-all"/> + <menuitem id="context-detachAllAttachments" + label="&detachAllAttachmentsCmd.label;" + accesskey="&detachAllAttachmentsCmd.accesskey;" + command="cmd_detachAllAttachments"/> + <menuitem id="context-deleteAllAttachments" + label="&deleteAllAttachmentsCmd.label;" + accesskey="&deleteAllAttachmentsCmd.accesskey;" + command="cmd_deleteAllAttachments"/> + </menupopup> + + <menupopup id="attachment-toolbar-context-menu" + onpopupshowing="return onShowAttachmentToolbarContextMenu(event);"> + <menuitem id="context-expandAttachmentBar" + type="checkbox" + label="&startExpandedCmd.label;" + accesskey="&startExpandedCmd.accesskey;" + oncommand="Services.prefs.setBoolPref('mailnews.attachments.display.start_expanded', this.getAttribute('checked'));"/> + </menupopup> + + <menupopup id="emailAddressPopup" + position="after_start" + class="no-icon-menupopup"> + <menuitem id="emailAddressPlaceHolder" + class="menuitem-iconic" + disabled="true"/> + <menuseparator/> + <menuitem id="addToAddressBookItem" + label="&AddDirectlyToAddressBook.label;" + accesskey="&AddDirectlyToAddressBook.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.addContact(event);"/> + <menuitem id="editContactItem" label="&EditContact1.label;" hidden="true" + accesskey="&EditContact1.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.showContactEdit(event);"/> + <menuitem id="viewContactItem" label="&ViewContact.label;" hidden="true" + accesskey="&ViewContact.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.showContactEdit(event);"/> + <menuitem id="sendMailToItem" label="&SendMessageTo.label;" + accesskey="&SendMessageTo.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.composeMessage(event);"/> + <menuitem id="copyEmailAddressItem" label="&CopyEmailAddress.label;" + accesskey="&CopyEmailAddress.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.copyAddress(event)"/> + <menuitem id="copyNameAndEmailAddressItem" label="&CopyNameAndEmailAddress.label;" + accesskey="&CopyNameAndEmailAddress.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.copyAddress(event, true);"/> + <menuseparator/> + <menuitem id="searchKeysOpenPGP" data-l10n-id="openpgp-search-keys-openpgp" + class="menuitem-iconic" + oncommand="Enigmail.msg.searchKeysOnInternet(event)"/> + <menuseparator/> + <menuitem id="createFilterFrom" label="&CreateFilterFrom.label;" + accesskey="&CreateFilterFrom.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.createFilter(event);"/> + </menupopup> + + <menupopup id="copyPopup" class="no-icon-menupopup"> + <menuitem id="copyMenuitem" + data-l10n-id="text-action-copy" + class="menuitem-iconic" + oncommand="gMessageHeader.copyString(event);"/> + <menuitem id="copyCreateFilterFrom" + label="&CreateFilterFrom.label;" + accesskey="&CreateFilterFrom.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.createFilter(event);"/> + </menupopup> + + <menupopup id="copyUrlPopup" + popupanchor="bottomleft"> + <menuitem label="©LinkCmd.label;" + accesskey="©LinkCmd.accesskey;" + oncommand="gMessageHeader.copyWebsiteUrl(event);"/> + </menupopup> + + <menupopup id="simpleCopyPopup" class="no-icon-menupopup"> + <menuitem id="copyMenuitem" + data-l10n-id="text-action-copy" + class="menuitem-iconic" + oncommand="gMessageHeader.copyString(event);"/> + </menupopup> + + <menupopup id="newsgroupPopup" + position="after_start" + class="newsgroupPopup no-icon-menupopup"> + <menuitem id="newsgroupPlaceHolder" + class="menuitem-iconic" + disabled="true"/> + <menuseparator/> + <menuitem id="sendMessageToNewsgroupItem" + label="&SendMessageTo.label;" + accesskey="&SendMessageTo.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.composeMessage(event);"/> + <menuitem id="copyNewsgroupNameItem" + label="&CopyNewsgroupName.label;" + accesskey="&CopyNewsgroupName.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.copyAddress(event);"/> + <menuitem id="copyNewsgroupURLItem" + label="&CopyNewsgroupURL.label;" + accesskey="&CopyNewsgroupURL.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.copyNewsgroupURL(event);"/> + <menuseparator id="subscribeToNewsgroupSeparator"/> + <menuitem id="subscribeToNewsgroupItem" + label="&SubscribeToNewsgroup.label;" + accesskey="&SubscribeToNewsgroup.accesskey;" + class="menuitem-iconic" + oncommand="gMessageHeader.subscribeToNewsgroup(event)"/> + </menupopup> + + <menupopup id="remoteContentOptions" value="" + onpopupshowing="onRemoteContentOptionsShowing(event);"> + <menuitem id="remoteContentOptionAllowForMsg" + label="&remoteContentOptionsAllowForMsg.label;" + accesskey="&remoteContentOptionsAllowForMsg.accesskey;" + oncommand="LoadMsgWithRemoteContent();"/> + <menuseparator id="remoteContentSettingsMenuSeparator"/> + <menuitem id="editRemoteContentSettings" +#ifdef XP_WIN + label="&editRemoteContentSettings.label;" + accesskey="&editRemoteContentSettings.accesskey;" +#else + label="&editRemoteContentSettingsUnix.label;" + accesskey="&editRemoteContentSettingsUnix.accesskey;" +#endif + oncommand="editRemoteContentSettings();"/> + <menuseparator id="remoteContentOriginsMenuSeparator"/> + <menuseparator id="remoteContentAllMenuSeparator"/> + <menuitem id="remoteContentOptionAllowAll" + oncommand="allowRemoteContentForAll(this.parentNode);"/> + </menupopup> + + <menupopup id="phishingOptions"> + <menuitem id="phishingOptionIgnore" + label="&phishingOptionIgnore.label;" + accesskey="&phishingOptionIgnore.accesskey;" + oncommand="IgnorePhishingWarning();"/> + <menuitem id="phishingOptionSettings" +#ifdef XP_WIN + label="&phishingOptionSettings.label;" + accesskey="&phishingOptionSettings.accesskey;" +#else + label="&phishingOptionSettingsUnix.label;" + accesskey="&phishingOptionSettingsUnix.accesskey;" +#endif + oncommand="OpenPhishingSettings();"/> + </menupopup> diff --git a/comm/mail/base/content/msgHdrView.inc.xhtml b/comm/mail/base/content/msgHdrView.inc.xhtml new file mode 100644 index 0000000000..fa9565d7cc --- /dev/null +++ b/comm/mail/base/content/msgHdrView.inc.xhtml @@ -0,0 +1,559 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +<menupopup id="header-toolbar-context-menu" + onpopupshowing="ToolbarContextMenu.updateExtension(this);"> + <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-manage-extension" + class="customize-context-manageExtension"/> + <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-remove-extension" + class="customize-context-removeExtension"/> +</menupopup> + +<!-- Header container --> +<html:header id="messageHeader" + class="message-header-container message-header-large-subject message-header-hide-label-column message-header-show-recipient-avatar message-header-show-sender-full-address"> + <!-- From + buttons --> + <html:div id="headerSenderToolbarContainer" + class="message-header-row header-row-reverse message-header-wrap items-center"> + <html:div id="header-view-toolbox" role="toolbar" class="header-buttons-container"> + <!-- NOTE: Temporarily keep the hbox to allow extensions to add custom sets of + toolbar buttons. This can be later removed once a new API that doesn't rely + on XUL currentset is in place. --> + <hbox id="header-view-toolbar" class="header-buttons-container themeable-brighttext"> + <toolbarbutton id="hdrReplyToSenderButton" label="&hdrReplyButton1.label;" + tooltiptext="&hdrReplyButton2.tooltip;" + oncommand="MsgReplySender(event);" + class="toolbarbutton-1 message-header-view-button hdrReplyToSenderButton"/> + + <toolbaritem id="hdrSmartReplyButton" label="&hdrSmartReplyButton1.label;"> + <!-- This button is a dummy and should only be shown when customizing + the toolbar to distinguish the smart reply button from the reply + to sender button. --> + <toolbarbutton id="hdrDummyReplyButton" label="&hdrSmartReplyButton1.label;" + hidden="true" + class="toolbarbutton-1 message-header-view-button hdrDummyReplyButton"/> + + <toolbarbutton id="hdrReplyButton" label="&hdrReplyButton1.label;" + tooltiptext="&hdrReplyButton2.tooltip;" + oncommand="MsgReplySender(event);" + class="toolbarbutton-1 message-header-view-button hdrReplyButton"/> + + <toolbarbutton is="toolbarbutton-menu-button" id="hdrReplyAllButton" + type="menu" + label="&hdrReplyAllButton1.label;" + tooltiptext="&hdrReplyAllButton1.tooltip;" + oncommand="MsgReplyToAllMessage(event);" + class="toolbarbutton-1 message-header-view-button hdrReplyButton hdrReplyAllButton" + hidden="true"> + <menupopup id="hdrReplyAllDropdown" + class="no-icon-menupopup"> + <menuitem id="hdrReplyAll_ReplyAllSubButton" + class="menuitem-iconic" + label="&hdrReplyAllButton1.label;" + tooltiptext="&hdrReplyAllButton1.tooltip;"/> + <toolbarseparator id="hdrReplyAllSubSeparator"/> + <menuitem id="hdrReplySubButton" + class="menuitem-iconic" + label="&hdrReplyButton1.label;" + tooltiptext="&hdrReplyButton2.tooltip;" + oncommand="MsgReplySender(event); event.stopPropagation();"/> + </menupopup> + </toolbarbutton> + + <toolbarbutton is="toolbarbutton-menu-button" id="hdrReplyListButton" + type="menu" + label="&hdrReplyListButton1.label;" + tooltiptext="&hdrReplyListButton1.tooltip;" + oncommand="MsgReplyToListMessage(event);" + class="toolbarbutton-1 message-header-view-button hdrReplyButton hdrReplyListButton" + hidden="true"> + <menupopup id="hdrReplyListDropdown" + class="no-icon-menupopup"> + <menuitem id="hdrReplyList_ReplyListSubButton" + class="menuitem-iconic" + label="&hdrReplyListButton1.label;" + tooltiptext="&hdrReplyListButton1.tooltip;"/> + <toolbarseparator id="hdrReplyListSubSeparator"/> + <menuitem id="hdrRelplyList_ReplyAllSubButton" + class="menuitem-iconic" + label="&hdrReplyAllButton1.label;" + tooltiptext="&hdrReplyAllButton1.tooltip;" + oncommand="MsgReplyToAllMessage(event); event.stopPropagation();"/> + <menuitem id="hdrReplyList_ReplySubButton" + class="menuitem-iconic" + label="&hdrReplyButton1.label;" + tooltiptext="&hdrReplyButton2.tooltip;" + oncommand="MsgReplySender(event); event.stopPropagation();"/> + </menupopup> + </toolbarbutton> + + <toolbarbutton is="toolbarbutton-menu-button" id="hdrFollowupButton" + label="&hdrFollowupButton1.label;" + type="menu" + tooltiptext="&hdrFollowupButton1.tooltip;" + oncommand="MsgReplyGroup(event);" + class="toolbarbutton-1 message-header-view-button hdrFollowupButton"> + <menupopup id="hdrFollowupDropdown" + class="no-icon-menupopup"> + <menuitem id="hdrFollowup_FollowupSubButton" + class="menuitem-iconic" + label="&hdrFollowupButton1.label;" + tooltiptext="&hdrFollowupButton1.tooltip;"/> + <toolbarseparator id="hdrFollowupSubSeparator"/> + <menuitem id="hdrFollowup_ReplyAllSubButton" + class="menuitem-iconic" + label="&hdrReplyAllButton1.label;" + tooltiptext="&hdrReplyAllButton1.tooltip;" + oncommand="MsgReplyToAllMessage(event); event.stopPropagation();"/> + <menuitem id="hdrFollowup_ReplySubButton" + class="menuitem-iconic" + label="&hdrReplyButton1.label;" + tooltiptext="&hdrReplyButton2.tooltip;" + oncommand="MsgReplySender(event); event.stopPropagation();"/> + </menupopup> + </toolbarbutton> + </toolbaritem> + + <toolbarbutton id="hdrForwardButton" + label="&hdrForwardButton1.label;" + tooltiptext="&hdrForwardButton1.tooltip;" + oncommand="MsgForwardMessage(event);" + class="toolbarbutton-1 message-header-view-button hdrForwardButton"/> + <toolbarbutton id="hdrArchiveButton" + label="&hdrArchiveButton1.label;" + tooltiptext="&hdrArchiveButton1.tooltip;" + oncommand="goDoCommand('cmd_archive');" + class="toolbarbutton-1 message-header-view-button hdrArchiveButton"/> + <toolbarbutton id="hdrJunkButton" label="&hdrJunkButton1.label;" + tooltiptext="&hdrJunkButton1.tooltip;" + observes="cmd_markAsJunk" + class="toolbarbutton-1 message-header-view-button hdrJunkButton" + oncommand="goDoCommand('cmd_markAsJunk');"/> + <toolbarbutton id="hdrTrashButton" + label="&hdrTrashButton1.label;" + tooltiptext="&hdrTrashButton1.tooltip;" + observes="cmd_delete" + class="toolbarbutton-1 message-header-view-button hdrTrashButton" + oncommand="goDoCommand(event.shiftKey ? 'cmd_shiftDelete' : 'cmd_delete');"/> + <toolbarbutton id="otherActionsButton" + type="menu" + wantdropmarker="true" + label="&otherActionsButton2.label;" + tooltiptext="&otherActionsButton.tooltip;" + class="toolbarbutton-1 message-header-view-button"> + <menupopup id="otherActionsPopup" + class="no-icon-menupopup" + onpopupshowing="onShowOtherActionsPopup();"> + <menuitem id="otherActionsRedirect" + class="menuitem-iconic" + data-l10n-id="other-action-redirect-msg" + oncommand="MsgRedirectMessage();"/> +#ifdef MAIN_WINDOW + <menuseparator id="otherActionsRedirectSeparator"/> + <menuitem id="otherActionsOpenConversation" + class="menuitem-iconic" + label="&otherActionsOpenConversation1.label;" + accesskey="&otherActionsOpenConversation1.accesskey;" + oncommand="new ConversationOpener(window).openConversationForMessages(gFolderDisplay.selectedMessages);"/> + <menuitem id="otherActionsOpenInNewWindow" + class="menuitem-iconic" + label="&otherActionsOpenInNewWindow1.label;" + accesskey="&otherActionsOpenInNewWindow1.accesskey;" + oncommand="MsgOpenNewWindowForMessage();"/> + <menuitem id="otherActionsOpenInNewTab" + class="menuitem-iconic" + label="&otherActionsOpenInNewTab1.label;" + accesskey="&otherActionsOpenInNewTab1.accesskey;" + oncommand="OpenMessageInNewTab(gFolderDisplay.selectedMessage, { event });"/> +#endif + <menuseparator id="otherActionsSeparator"/> + <menu id="otherActionsTag" + class="menu-iconic" + label="&tagMenu.label;" + accesskey="&tagMenu.accesskey;"> + <menupopup id="hdrTagDropdown" + onpopupshowing="window.top.InitMessageTags(this);"> + <menuitem id="hdrTagDropdown-addNewTag" + label="&addNewTag.label;" + accesskey="&addNewTag.accesskey;" + oncommand="goDoCommand('cmd_addTag');"/> + <menuitem id="manageTags" + label="&manageTags.label;" + accesskey="&manageTags.accesskey;" + oncommand="goDoCommand('cmd_manageTags');"/> + <menuseparator id="hdrTagDropdown-sep-afterAddNewTag"/> + <menuitem id="hdrTagDropdown-tagRemoveAll" + oncommand="goDoCommand('cmd_removeTags');"/> + <menuseparator id="hdrTagDropdown-sep-afterTagRemoveAll"/> + </menupopup> + </menu> + <menuitem id="markAsReadMenuItem" + class="menuitem-iconic" + label="&markAsReadMenuItem1.label;" + accesskey="&markAsReadMenuItem1.accesskey;" + oncommand="MsgMarkMsgAsRead();"/> + <menuitem id="markAsUnreadMenuItem" + class="menuitem-iconic" + label="&markAsUnreadMenuItem1.label;" + accesskey="&markAsUnreadMenuItem1.accesskey;" + oncommand="MsgMarkMsgAsRead();"/> + <menuitem id="saveAsMenuItem" + class="menuitem-iconic" + label="&saveAsMenuItem1.label;" + accesskey="&saveAsMenuItem1.accesskey;" + oncommand="goDoCommand('cmd_saveAsFile')"/> + <menuitem id="otherActionsPrint" + class="menuitem-iconic" + label="&otherActionsPrint1.label;" + accesskey="&otherActionsPrint1.accesskey;" + oncommand="goDoCommand('cmd_print');"/> + <menu id="otherActions-calendar-convert-menu" + class="menu-iconic" + label="&calendar.context.convertmenu.label;" + accesskey="&calendar.context.convertmenu.accesskey.mail;"> + <menupopup id="otherActions-calendar-convert-menupopup" + class="no-icon-menupopup"> + <menuitem id="otherActions-calendar-convert-event-menuitem" + class="menuitem-iconic" + label="&calendar.context.convertmenu.event.label;" + accesskey="&calendar.context.convertmenu.event.accesskey;" + oncommand="convertToEventOrTask(true);"/> + <menuitem id="otherActions-calendar-convert-task-menuitem" + class="menuitem-iconic" + label="&calendar.context.convertmenu.task.label;" + accesskey="&calendar.context.convertmenu.task.accesskey;" + oncommand="convertToEventOrTask(false);"/> + </menupopup> + </menu> + <menuseparator id="otherActionsComposeSeparator"/> + <menuitem id="viewSourceMenuItem" + class="menuitem-iconic" + label="&viewSourceMenuItem1.label;" + accesskey="&viewSourceMenuItem1.accesskey;" + oncommand="goDoCommand('cmd_viewPageSource');"/> + <menuitem id="charsetRepairMenuitem" + class="menuitem-iconic" + data-l10n-id="repair-text-encoding-button" + oncommand="MailSetCharacterSet()"/> + <menu id="otherActionsMessageBodyAs" + class="menu-iconic" + label="&bodyMenu.label;"> + <menupopup id="hdrMessageBodyAsDropdown" + class="menu-iconic" + onpopupshowing="InitOtherActionsViewBodyMenu();"> + <menuitem id="otherActionsMenu_bodyAllowHTML" + class="menuitem-iconic" + type="radio" + name="bodyPlaintextVsHTMLPref" + label="&bodyAllowHTML.label;" + oncommand="top.MsgBodyAllowHTML()"/> + <menuitem id="otherActionsMenu_bodySanitized" + class="menuitem-iconic" + type="radio" + name="bodyPlaintextVsHTMLPref" + label="&bodySanitized.label;" + oncommand="top.MsgBodySanitized()"/> + <menuitem id="otherActionsMenu_bodyAsPlaintext" + class="menuitem-iconic" + type="radio" + name="bodyPlaintextVsHTMLPref" + label="&bodyAsPlaintext.label;" + oncommand="top.MsgBodyAsPlaintext()"/> + <menuitem id="otherActionsMenu_bodyAllParts" + class="menuitem-iconic" + type="radio" + name="bodyPlaintextVsHTMLPref" + label="&bodyAllParts.label;" + oncommand="top.MsgBodyAllParts()"/> + </menupopup> + </menu> + <menu id="otherActionsFeedBodyAs" + class="menu-iconic" + label="&bodyMenu.label;"> + <menupopup id="hdrFeedBodyAsDropdown" + class="menu-iconic" + onpopupshowing="InitOtherActionsViewBodyMenu();"> + <menuitem id="otherActionsMenu_bodyFeedGlobalWebPage" + class="menuitem-iconic" + type="radio" + name="viewFeedSummaryGroup" + label="&viewFeedWebPage.label;" + observes="bodyFeedGlobalWebPage" + oncommand="FeedMessageHandler.onSelectPref = 0"/> + <menuitem id="otherActionsMenu_bodyFeedGlobalSummary" + class="menuitem-iconic" + type="radio" + name="viewFeedSummaryGroup" + label="&viewFeedSummary.label;" + observes="bodyFeedGlobalSummary" + oncommand="FeedMessageHandler.onSelectPref = 1"/> + <menuitem id="otherActionsMenu_bodyFeedPerFolderPref" + class="menuitem-iconic" + type="radio" + name="viewFeedSummaryGroup" + label="&viewFeedSummaryFeedPropsPref.label;" + observes="bodyFeedPerFolderPref" + oncommand="FeedMessageHandler.onSelectPref = 2"/> + <menuseparator id="otherActionsMenu_viewFeedSummarySeparator"/> + <menuitem id="otherActionsMenu_bodyFeedSummaryAllowHTML" + class="menuitem-iconic" + type="radio" + name="viewFeedBodyHTMLGroup" + label="&bodyAllowHTML.label;" + oncommand="top.MsgFeedBodyRenderPrefs(false, 0, 0)"/> + <menuitem id="otherActionsMenu_bodyFeedSummarySanitized" + class="menuitem-iconic" + type="radio" + name="viewFeedBodyHTMLGroup" + label="&bodySanitized.label;" + oncommand="top.MsgFeedBodyRenderPrefs(false, 3, gDisallow_classes_no_html)"/> + <menuitem id="otherActionsMenu_bodyFeedSummaryAsPlaintext" + class="menuitem-iconic" + type="radio" + name="viewFeedBodyHTMLGroup" + label="&bodyAsPlaintext.label;" + oncommand="top.MsgFeedBodyRenderPrefs(true, 1, gDisallow_classes_no_html)"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem id="messageHeaderMoreMenuCustomize" + class="menuitem-iconic" + data-l10n-id="menuitem-customize-label" + oncommand="gHeaderCustomize.showPanel()"/> + </menupopup> + </toolbarbutton> + <html:button id="starMessageButton" role="checkbox" aria-checked="false" + class="plain-button email-action-button email-action-flagged" + data-l10n-id="message-header-msg-flagged"> + <html:img src="chrome://messenger/skin/icons/new/compact/star.svg" alt="" /> + </html:button> + </hbox> + </html:div> + + <html:div id="expandedfromRow" class="header-row-grow"> + <label id="expandedfromLabel" class="message-header-label header-pill-label" + value="&fromField4.label;" + valueFrom="&fromField4.label;" valueAuthor="&author.label;"/> + <html:div id="expandedfromBox" is="multi-recipient-row" + data-header-name="from" data-show-all="true"></html:div> + </html:div> + </html:div> + + <!-- To recipients + date --> + <html:div id="expandedtoRow" class="message-header-row"> + <html:div class="header-row-grow"> + <label id="expandedtoLabel" class="message-header-label header-pill-label" + value="&toField4.label;"/> + <html:div id="expandedtoBox" is="multi-recipient-row" + data-header-name="to"></html:div> + </html:div> + <html:time id="dateLabel" + class="message-header-datetime" + aria-readonly="true"></html:time> + </html:div> + + <!-- Cc recipients --> + <html:div id="expandedccRow" class="message-header-row" hidden="hidden"> + <label id="expandedccLabel" class="message-header-label header-pill-label" + value="&ccField4.label;"/> + <html:div id="expandedccBox" is="multi-recipient-row" + data-header-name="cc"></html:div> + </html:div> + + <!-- Bcc recipients --> + <html:div id="expandedbccRow" class="message-header-row" hidden="hidden"> + <label id="expandedbccLabel" class="message-header-label header-pill-label" + value="&bccField4.label;"/> + <html:div id="expandedbccBox" is="multi-recipient-row" + data-header-name="bcc"></html:div> + </html:div> + + <!-- Reply-to --> + <html:div id="expandedreply-toRow" class="message-header-row" hidden="hidden"> + <label id="expandedreply-toLabel" class="message-header-label header-pill-label" + value="&replyToField4.label;"/> + <html:div id="expandedreply-toBox" is="multi-recipient-row" + data-header-name="reply-to"></html:div> + </html:div> + + <!-- Organization --> + <html:div id="expandedorganizationRow" class="message-header-row" hidden="hidden"> + <label id="expandedorganizationLabel" class="message-header-label" + value="&organizationField4.label;"/> + <html:div id="expandedorganizationBox" is="simple-header-row" + data-header-name="organization"></html:div> + </html:div> + + <!-- Sender --> + <html:div id="expandedsenderRow" class="message-header-row" hidden="hidden"> + <label id="expandedsenderLabel" class="message-header-label header-pill-label" + value="&senderField4.label;"/> + <html:div id="expandedsenderBox" is="multi-recipient-row" + data-header-name="sender"></html:div> + </html:div> + + <!-- Newsgroups --> + <html:div id="expandednewsgroupsRow" class="message-header-row" hidden="hidden"> + <label id="expandednewsgroupsLabel" class="message-header-label header-pill-label" + value="&newsgroupsField4.label;"/> + <html:div id="expandednewsgroupsBox" is="header-newsgroups-row" + data-header-name="newsgroups"/> + </html:div> + + <!-- Follow up --> + <html:div id="expandedfollowup-toRow" class="message-header-row" hidden="hidden"> + <label id="expandedfollowup-toLabel" class="message-header-label header-pill-label" + value="&followupToField4.label;"/> + <html:div id="expandedfollowup-toBox" + is="header-newsgroups-row" + data-header-name="followup-to"/> + </html:div> + + <!-- Subject + security info + extra date label for hidden To variation --> + <html:div id="headerSubjectSecurityContainer" class="message-header-row"> + <html:div id="expandedsubjectRow" class="header-row-grow"> + <label id="expandedsubjectLabel" class="message-header-label" + value="&subjectField4.label;"/> + <html:div id="expandedsubjectBox" is="simple-header-row" + data-header-name="subject"></html:div> + </html:div> + <html:div id="cryptoBox" hidden="hidden"> + <html:button id="encryptionTechBtn" + class="toolbarbutton-1 crypto-button themeable-brighttext button-focusable" + data-l10n-id="message-security-button"> + <html:span class="crypto-label"></html:span> + <html:img id="encryptedHdrIcon" hidden="hidden" alt="" /> + <html:img id="signedHdrIcon" hidden="hidden" alt="" /> + </html:button> + </html:div> + <html:time id="dateLabelSubject" + class="message-header-datetime" + aria-readonly="true" + hidden="hidden"></html:time> + </html:div> + + <!-- Tags --> + <html:div id="expandedtagsRow" class="message-header-row" hidden="hidden"> + <label id="expandedtagsLabel" class="message-header-label" + value="&tagsHdr4.label;"/> + <html:div id="expandedtagsBox" is="header-tags-row" + data-header-name="tags"></html:div> + </html:div> + + <!-- Date --> + <html:div id="expandeddateRow" class="message-header-row" hidden="hidden"> + <label id="expandeddateLabel" class="message-header-label" + value="&dateField4.label;"/> + <html:div id="expandeddateBox" is="simple-header-row" + data-header-name="date"></html:div> + </html:div> + + <!-- Message ID --> + <html:div id="expandedmessage-idRow" class="message-header-row" hidden="hidden"> + <label id="expandedmessage-idLabel" class="message-header-label header-pill-label" + value="&messageIdField4.label;"/> + <html:div id="expandedmessage-idBox" is="multi-message-ids-row" + data-header-name="message-id"></html:div> + </html:div> + + <!-- In reply to --> + <html:div id="expandedin-reply-toRow" class="message-header-row" hidden="hidden"> + <label id="expandedin-reply-toLabel" class="message-header-label header-pill-label" + value="&inReplyToField4.label;"/> + <html:div id="expandedin-reply-toBox" is="multi-message-ids-row" + data-header-name="in-reply-to"></html:div> + </html:div> + + <!-- Reference --> + <html:div id="expandedreferencesRow" class="message-header-row" hidden="hidden"> + <label id="expandedreferencesLabel" class="message-header-label header-pill-label" + value="&referencesField4.label;"/> + <html:div id="expandedreferencesBox" is="multi-message-ids-row" + data-header-name="references"></html:div> + </html:div> + + <!-- Content base --> + <html:div id="expandedcontent-baseRow" class="message-header-row" hidden="hidden"> + <label id="expandedcontent-baseLabel" class="message-header-label" + value="&originalWebsite4.label;"/> + <html:div id="expandedcontent-baseBox" is="url-header-row" + data-header-name="content-base"></html:div> + </html:div> + + <!-- User agent --> + <html:div id="expandeduser-agentRow" class="message-header-row" hidden="hidden"> + <label id="expandeduser-agentLabel" class="message-header-label" + value="&userAgentField4.label;"/> + <html:div id="expandeduser-agentBox" is="simple-header-row" + data-header-name="user-agent"></html:div> + </html:div> + + <!-- All extra headers will be dynamically added here. --> + <html:div id="extraHeadersArea" class="message-header-extra-container"></html:div> + +</html:header> +<!-- END Header container --> + +<panel id="messageHeaderCustomizationPanel" + type="arrow" + orient="vertical" + class="cui-widget-panel popup-panel panel-no-padding" + onpopupshowing="gHeaderCustomize.onPanelShowing();" + onpopuphidden="gHeaderCustomize.onPanelHidden();"> + <html:div class="popup-panel-body"> + <html:h3 data-l10n-id="message-header-customize-panel-title"></html:h3> + + <html:div class="popup-panel-options-grid"> + <label data-l10n-id="message-header-customize-button-style" + control="headerButtonStyle"/> + <menulist id="headerButtonStyle" + oncommand="gHeaderCustomize.updateButtonStyle(event);" + crop="none"> + <menupopup> + <menuitem value="default" data-l10n-id="message-header-button-style-default"/> + <menuitem value="only-text" data-l10n-id="message-header-button-style-text"/> + <menuitem value="only-icons" data-l10n-id="message-header-button-style-icons"/> + </menupopup> + </menulist> + + <html:div class="popup-panel-column-container"> + <checkbox id="headerShowAvatar" + data-l10n-id="message-header-show-recipient-avatar" + oncommand="gHeaderCustomize.toggleAvatar(event);"/> + + <checkbox id="headerShowBigAvatar" class="indent" + data-l10n-id="message-header-show-big-avatar" + oncommand="gHeaderCustomize.toggleBigAvatar(event);"/> + + <checkbox id="headerShowFullAddress" + data-l10n-id="message-header-show-sender-full-address" + oncommand="gHeaderCustomize.toggleSenderAddress(event);"/> + <html:span class="checkbox-description" + data-l10n-id="message-header-show-sender-full-address-description"></html:span> + + <checkbox id="headerHideLabels" + data-l10n-id="message-header-hide-label-column" + oncommand="gHeaderCustomize.toggleLabelColumn(event);"/> + + <checkbox id="headerSubjectLarge" + data-l10n-id="message-header-large-subject" + oncommand="gHeaderCustomize.updateSubjectStyle(event);"/> + + <checkbox id="headerViewAllHeaders" + data-l10n-id="message-header-all-headers" + oncommand="gHeaderCustomize.toggleAllHeaders(event);"/> + </html:div> + </html:div> + + <html:div class="popup-panel-buttons-container"> + <html:button type="button" + data-l10n-id="customize-panel-button-save" + onclick="gHeaderCustomize.closePanel()" + class="primary"> + </html:button> + </html:div> + </html:div> +</panel> diff --git a/comm/mail/base/content/msgHdrView.js b/comm/mail/base/content/msgHdrView.js new file mode 100644 index 0000000000..1579cddbfe --- /dev/null +++ b/comm/mail/base/content/msgHdrView.js @@ -0,0 +1,4501 @@ +/* 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/. */ + +/** + * Functions related to displaying the headers for a selected message in the + * message pane. + */ + +/* import-globals-from ../../../../toolkit/content/contentAreaUtils.js */ +/* import-globals-from ../../../calendar/base/content/imip-bar.js */ +/* import-globals-from ../../../mailnews/extensions/newsblog/newsblogOverlay.js */ +/* import-globals-from ../../extensions/smime/content/msgHdrViewSMIMEOverlay.js */ +/* import-globals-from aboutMessage.js */ +/* import-globals-from editContactPanel.js */ +/* import-globals-from globalOverlay.js */ +/* import-globals-from mailContext.js */ +/* import-globals-from mail-offline.js */ +/* import-globals-from mailCore.js */ +/* import-globals-from msgSecurityPane.js */ + +/* globals MozElements */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +ChromeUtils.defineESModuleGetters(this, { + AttachmentInfo: "resource:///modules/AttachmentInfo.sys.mjs", + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + calendarDeactivator: + "resource:///modules/calendar/calCalendarDeactivator.jsm", + Gloda: "resource:///modules/gloda/GlodaPublic.jsm", + GlodaUtils: "resource:///modules/gloda/GlodaUtils.jsm", + MailUtils: "resource:///modules/MailUtils.jsm", + MessageArchiver: "resource:///modules/MessageArchiver.jsm", + PgpSqliteDb2: "chrome://openpgp/content/modules/sqliteDb.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gDbService", + "@mozilla.org/msgDatabase/msgDBService;1", + "nsIMsgDBService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "gMIMEService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "gHandlerService", + "@mozilla.org/uriloader/handler-service;1", + "nsIHandlerService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "gEncryptedSMIMEURIsService", + "@mozilla.org/messenger-smime/smime-encrypted-uris-service;1", + Ci.nsIEncryptedSMIMEURIsService +); + +// Warning: It's critical that the code in here for displaying the message +// headers for a selected message remain as fast as possible. In particular, +// right now, we only introduce one reflow per message. i.e. if you click on +// a message in the thread pane, we batch up all the changes for displaying +// the header pane (to, cc, attachments button, etc.) and we make a single +// pass to display them. It's critical that we maintain this one reflow per +// message view in the message header pane. + +var gViewAllHeaders = false; +var gMinNumberOfHeaders = 0; +var gDummyHeaderIdIndex = 0; +var gBuildAttachmentsForCurrentMsg = false; +var gBuiltExpandedView = false; +var gHeadersShowReferences = false; + +/** + * Show the friendly display names for people I know, + * instead of the name + email address. + */ +var gShowCondensedEmailAddresses; + +/** + * Other components may listen to on start header & on end header notifications + * for each message we display: to do that you need to add yourself to our + * gMessageListeners array with an object that supports the three properties: + * onStartHeaders, onEndHeaders and onEndAttachments. + * + * Additionally, if your object has an onBeforeShowHeaderPane() method, it will + * be called at the appropriate time. This is designed to give add-ons a + * chance to examine and modify the currentHeaderData array before it gets + * displayed. + */ +var gMessageListeners = []; + +/** + * List fo common headers that need to be populated. + * + * For every possible "view" in the message pane, you need to define the header + * names you want to see in that view. In addition, include information + * describing how you want that header field to be presented. We'll then use + * this static table to dynamically generate header view entries which + * manipulate the UI. + * + * @param {string} name - The name of the header. i.e. "to", "subject". This + * must be in lower case and the name of the header is used to help + * dynamically generate ids for objects in the document. + * @param {Function} outputFunction - This is a method which takes a headerEntry + * (see the definition below) and a header value. This allows to provide a + * unique methods for determining how the header value is displayed. Defaults + * to updateHeaderValue which just sets the header value on the text node. + */ +const gExpandedHeaderList = [ + { name: "subject" }, + { name: "from", outputFunction: outputEmailAddresses }, + { name: "reply-to", outputFunction: outputEmailAddresses }, + { name: "to", outputFunction: outputEmailAddresses }, + { name: "cc", outputFunction: outputEmailAddresses }, + { name: "bcc", outputFunction: outputEmailAddresses }, + { name: "newsgroups", outputFunction: outputNewsgroups }, + { name: "references", outputFunction: outputMessageIds }, + { name: "followup-to", outputFunction: outputNewsgroups }, + { name: "content-base" }, + { name: "tags", outputFunction: outputTags }, +]; + +/** + * These are all the items that use a multi-recipient-row widget and + * therefore may require updating if the address book changes. + */ +var gEmailAddressHeaderNames = [ + "from", + "reply-to", + "to", + "cc", + "bcc", + "toCcBcc", +]; + +/** + * Now, for each view the message pane can generate, we need a global table of + * headerEntries. These header entry objects are generated dynamically based on + * the static data in the header lists (see above) and elements we find in the + * DOM based on properties in the header lists. + */ +var gExpandedHeaderView = {}; + +/** + * This is an array of header name and value pairs for the currently displayed + * message. It's purely a data object and has no view information. View + * information is contained in the view objects. + * For a given entry in this array you can ask for: + * .headerName name of the header (i.e. 'to'). Always stored in lower case + * .headerValue value of the header "johndoe@example.com" + */ +var currentHeaderData = {}; + +/** + * CurrentAttachments is an array of AttachmentInfo objects. + */ +var currentAttachments = []; + +/** + * The character set of the message, according to the MIME parser. + */ +var currentCharacterSet = ""; + +/** + * Folder database listener object. This is used alongside the + * nsIDBChangeListener implementation in order to listen for the changes of the + * messages' flags that don't trigger a messageHeaderSink.processHeaders(). + * For now, it's used only for the flagged/marked/starred flag, but it could be + * extended to handle other flags changes and remove the full header reload. + */ +var gFolderDBListener = null; + +// Timer to mark read, if the user has configured the app to mark a message as +// read if it is viewed for more than n seconds. +var gMarkViewedMessageAsReadTimer = null; + +// Per message header flags to keep track of whether the user is allowing remote +// content for a particular message. +// if you change or add more values to these constants, be sure to modify +// the corresponding definitions in nsMsgContentPolicy.cpp +var kNoRemoteContentPolicy = 0; +var kBlockRemoteContent = 1; +var kAllowRemoteContent = 2; + +class FolderDBListener { + constructor(folder) { + // Keep a record of the currently selected folder to check when the + // selection changes to avoid initializing the DBListener in case the same + // folder is selected. + this.selectedFolder = folder; + this.isRegistered = false; + } + + register() { + gDbService.registerPendingListener(this.selectedFolder, this); + this.isRegistered = true; + } + + unregister() { + gDbService.unregisterPendingListener(this); + this.isRegistered = false; + } + + /** @implements {nsIDBChangeListener} */ + onHdrFlagsChanged(hdrChanged, oldFlags, newFlags, instigator) { + // Bail out if the changed message isn't the one currently displayed. + if (hdrChanged != gMessage) { + return; + } + + // Check if the flagged/marked/starred state was changed. + if ( + newFlags & Ci.nsMsgMessageFlags.Marked || + oldFlags & Ci.nsMsgMessageFlags.Marked + ) { + updateStarButton(); + } + } + onHdrDeleted(hdrChanged, parentKey, flags, instigator) {} + onHdrAdded(hdrChanged, parentKey, flags, instigator) {} + onParentChanged(keyChanged, oldParent, newParent, instigator) {} + onAnnouncerGoingAway(instigator) {} + onReadChanged(instigator) {} + onJunkScoreChanged(instigator) {} + onHdrPropertyChanged(hdrToChange, property, preChange, status, instigator) { + // Not interested before a change, or if the message isn't the one displayed, + // or an .eml file from disk or an attachment. + if (preChange || gMessage != hdrToChange) { + return; + } + switch (property) { + case "keywords": + OnTagsChange(); + break; + case "junkscore": + HandleJunkStatusChanged(hdrToChange); + break; + } + } + onEvent(db, event) {} +} + +/** + * Initialize the nsIDBChangeListener when a new folder is selected in order to + * listen for any flags change happening in the currently displayed messages. + */ +function initFolderDBListener() { + // Bail out if we don't have a selected message, or we already have a + // DBListener initialized and the folder didn't change. + if ( + !gFolder || + (gFolderDBListener?.isRegistered && + gFolderDBListener.selectedFolder == gFolder) + ) { + return; + } + + // Clearly we are viewing a different message in a different folder, so clear + // any remaining of the old DBListener. + clearFolderDBListener(); + + gFolderDBListener = new FolderDBListener(gFolder); + gFolderDBListener.register(); +} + +/** + * Unregister the listener and clear the object if we already have one, meaning + * the user just changed folder or deselected all messages. + */ +function clearFolderDBListener() { + if (gFolderDBListener?.isRegistered) { + gFolderDBListener.unregister(); + gFolderDBListener = null; + } +} + +/** + * Our class constructor method which creates a header Entry based on an entry + * in one of the header lists. A header entry is different from a header list. + * A header list just describes how you want a particular header to be + * presented. The header entry actually has knowledge about the DOM + * and the actual DOM elements associated with the header. + * + * @param prefix the name of the view (e.g. "expanded") + * @param headerListInfo entry from a header list. + */ +class MsgHeaderEntry { + constructor(prefix, headerListInfo) { + this.enclosingBox = document.getElementById( + `${prefix}${headerListInfo.name}Box` + ); + this.enclosingRow = this.enclosingBox.closest(".message-header-row"); + this.isNewHeader = false; + this.valid = false; + this.outputFunction = headerListInfo.outputFunction || updateHeaderValue; + } +} + +function initializeHeaderViewTables() { + // Iterate over each header in our header list arrays and create header entries + // for each one. These header entries are then stored in the appropriate header + // table. + for (let header of gExpandedHeaderList) { + gExpandedHeaderView[header.name] = new MsgHeaderEntry("expanded", header); + } + + let extraHeaders = Services.prefs + .getCharPref("mailnews.headers.extraExpandedHeaders") + .split(" "); + for (let extraHeaderName of extraHeaders) { + if (!extraHeaderName.trim()) { + continue; + } + gExpandedHeaderView[extraHeaderName.toLowerCase()] = new HeaderView( + extraHeaderName, + extraHeaderName + ); + } + + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + + for (let otherHeaderName of otherHeaders) { + gExpandedHeaderView[otherHeaderName.toLowerCase()] = new HeaderView( + otherHeaderName, + otherHeaderName + ); + } + + if (Services.prefs.getBoolPref("mailnews.headers.showOrganization")) { + var organizationEntry = { + name: "organization", + outputFunction: updateHeaderValue, + }; + gExpandedHeaderView[organizationEntry.name] = new MsgHeaderEntry( + "expanded", + organizationEntry + ); + } + + if (Services.prefs.getBoolPref("mailnews.headers.showUserAgent")) { + var userAgentEntry = { + name: "user-agent", + outputFunction: updateHeaderValue, + }; + gExpandedHeaderView[userAgentEntry.name] = new MsgHeaderEntry( + "expanded", + userAgentEntry + ); + } + + if (Services.prefs.getBoolPref("mailnews.headers.showMessageId")) { + var messageIdEntry = { + name: "message-id", + outputFunction: outputMessageIds, + }; + gExpandedHeaderView[messageIdEntry.name] = new MsgHeaderEntry( + "expanded", + messageIdEntry + ); + } + + if (Services.prefs.getBoolPref("mailnews.headers.showSender")) { + let senderEntry = { + name: "sender", + outputFunction: outputEmailAddresses, + }; + gExpandedHeaderView[senderEntry.name] = new MsgHeaderEntry( + "expanded", + senderEntry + ); + } +} + +async function OnLoadMsgHeaderPane() { + // Load any preferences that at are global with regards to + // displaying a message... + gMinNumberOfHeaders = Services.prefs.getIntPref( + "mailnews.headers.minNumHeaders" + ); + gShowCondensedEmailAddresses = Services.prefs.getBoolPref( + "mail.showCondensedAddresses" + ); + gHeadersShowReferences = Services.prefs.getBoolPref( + "mailnews.headers.showReferences" + ); + + Services.obs.addObserver(MsgHdrViewObserver, "remote-content-blocked"); + Services.prefs.addObserver("mail.showCondensedAddresses", MsgHdrViewObserver); + Services.prefs.addObserver( + "mailnews.headers.showReferences", + MsgHdrViewObserver + ); + + initializeHeaderViewTables(); + + // Add the keyboard shortcut event listener for the message header. + // Ctrl+Alt+S / Cmd+Control+S. We don't use the Alt/Option key on macOS + // because it alters the pressed key to an ASCII character. See bug 1692263. + let shortcut = await document.l10n.formatValue( + "message-header-show-security-info-key" + ); + document.addEventListener("keypress", event => { + if ( + event.ctrlKey && + (event.altKey || event.metaKey) && + event.key.toLowerCase() == shortcut.toLowerCase() + ) { + showMessageReadSecurityInfo(); + } + }); + + headerToolbarNavigation.init(); + + // Set up event listeners for the encryption technology button and panel. + document + .getElementById("encryptionTechBtn") + .addEventListener("click", showMessageReadSecurityInfo); + let panel = document.getElementById("messageSecurityPanel"); + panel.addEventListener("popuphidden", onMessageSecurityPopupHidden); + + // Set the flag/star button on click listener. + document + .getElementById("starMessageButton") + .addEventListener("click", MsgMarkAsFlagged); + + // Dispatch an event letting any listeners know that we have loaded + // the message pane. + let headerViewElement = document.getElementById("msgHeaderView"); + headerViewElement.loaded = true; + headerViewElement.dispatchEvent( + new Event("messagepane-loaded", { bubbles: false, cancelable: true }) + ); + + getMessagePaneBrowser().addProgressListener( + messageProgressListener, + Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + + gHeaderCustomize.init(); +} + +function OnUnloadMsgHeaderPane() { + let headerViewElement = document.getElementById("msgHeaderView"); + if (!headerViewElement.loaded) { + // We're unloading, but we never loaded. + return; + } + + Services.obs.removeObserver(MsgHdrViewObserver, "remote-content-blocked"); + Services.prefs.removeObserver( + "mail.showCondensedAddresses", + MsgHdrViewObserver + ); + Services.prefs.removeObserver( + "mailnews.headers.showReferences", + MsgHdrViewObserver + ); + + clearFolderDBListener(); + + // Dispatch an event letting any listeners know that we have unloaded + // the message pane. + headerViewElement.dispatchEvent( + new Event("messagepane-unloaded", { bubbles: false, cancelable: true }) + ); +} + +var MsgHdrViewObserver = { + observe(subject, topic, data) { + // verify that we're changing the mail pane config pref + if (topic == "nsPref:changed") { + // We don't need to call ReloadMessage() in either of these conditions + // because a preference observer for these preferences already does it. + if (data == "mail.showCondensedAddresses") { + gShowCondensedEmailAddresses = Services.prefs.getBoolPref( + "mail.showCondensedAddresses" + ); + } else if (data == "mailnews.headers.showReferences") { + gHeadersShowReferences = Services.prefs.getBoolPref( + "mailnews.headers.showReferences" + ); + } + } else if (topic == "remote-content-blocked") { + let browser = getMessagePaneBrowser(); + if ( + browser.browsingContext.id == data || + browser.browsingContext == BrowsingContext.get(data)?.top + ) { + gMessageNotificationBar.setRemoteContentMsg( + null, + subject, + !gEncryptedSMIMEURIsService.isEncrypted(browser.currentURI.spec) + ); + } + } + }, +}; + +/** + * Receives a message's headers as we display the message through our mime converter. + * + * @see {nsIMailChannel} + * @implements {nsIMailProgressListener} + * @implements {nsIWebProgressListener} + * @implements {nsISupportsWeakReference} + */ +var messageProgressListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIMailProgressListener", + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + /** + * Step 1: A message has started loading (if the flags include STATE_START). + * + * @param {nsIWebProgress} webProgress + * @param {nsIRequest} request + * @param {integer} stateFlags + * @param {nsresult} status + * @see {nsIWebProgressListener} + */ + onStateChange(webProgress, request, stateFlags, status) { + if ( + !(request instanceof Ci.nsIMailChannel) || + !(stateFlags & Ci.nsIWebProgressListener.STATE_START) + ) { + return; + } + + // Clear the previously displayed message. + const previousDocElement = + getMessagePaneBrowser().contentDocument?.documentElement; + if (previousDocElement) { + previousDocElement.style.display = "none"; + } + ClearAttachmentList(); + gMessageNotificationBar.clearMsgNotifications(); + + request.listener = this; + request.smimeHeaderSink = smimeHeaderSink; + this.onStartHeaders(); + }, + + /** + * Step 2: The message headers are available on the channel. + * + * @param {nsIMailChannel} mailChannel + * @see {nsIMailProgressListener} + */ + onHeadersComplete(mailChannel) { + const domWindow = getMessagePaneBrowser().docShell.DOMWindow; + domWindow.addEventListener( + "DOMContentLoaded", + event => this.onDOMContentLoaded(event), + { once: true } + ); + this.processHeaders(mailChannel.headerNames, mailChannel.headerValues); + }, + + /** + * Step 3: The parser has finished reading the body of the message. + * + * @param {nsIMailChannel} mailChannel + * @see {nsIMailProgressListener} + */ + onBodyComplete(mailChannel) { + autoMarkAsRead(); + }, + + /** + * Step 4: The attachment information is available on the channel. + * + * @param {nsIMailChannel} mailChannel + * @see {nsIMailProgressListener} + */ + onAttachmentsComplete(mailChannel) { + for (const attachment of mailChannel.attachments) { + this.handleAttachment( + attachment.getProperty("contentType"), + attachment.getProperty("url"), + attachment.getProperty("displayName"), + attachment.getProperty("uri"), + attachment.getProperty("notDownloaded") + ); + for (const key of [ + "X-Mozilla-PartURL", + "X-Mozilla-PartSize", + "X-Mozilla-PartDownloaded", + "Content-Description", + "Content-Type", + "Content-Encoding", + ]) { + if (attachment.hasKey(key)) { + this.addAttachmentField(key, attachment.getProperty(key)); + } + } + } + }, + + /** + * Step 5: The message HTML is complete, but external resources such as may + * not have loaded yet. The docShell will handle them – for our purposes, + * message loading has finished. + */ + onDOMContentLoaded(event) { + const { docShell } = event.target.ownerGlobal; + if (!docShell.isTopLevelContentDocShell) { + return; + } + + const channel = docShell.currentDocumentChannel; + channel.QueryInterface(Ci.nsIMailChannel); + currentCharacterSet = channel.mailCharacterSet; + channel.smimeHeaderSink = null; + if (channel.imipItem) { + calImipBar.showImipBar(channel.imipItem, channel.imipMethod); + } + this.onEndAllAttachments(); + const uri = channel.URI.QueryInterface(Ci.nsIMsgMailNewsUrl); + this.onEndMsgHeaders(uri); + this.onEndMsgDownload(uri); + }, + + onStartHeaders() { + // Every time we start to redisplay a message, check the view all headers + // pref... + let showAllHeadersPref = Services.prefs.getIntPref("mail.show_headers"); + if (showAllHeadersPref == 2) { + // eslint-disable-next-line no-global-assign + gViewAllHeaders = true; + } else { + if (gViewAllHeaders) { + // If we currently are in view all header mode, rebuild our header + // view so we remove most of the header data. + hideHeaderView(gExpandedHeaderView); + RemoveNewHeaderViews(gExpandedHeaderView); + gDummyHeaderIdIndex = 0; + // eslint-disable-next-line no-global-assign + gExpandedHeaderView = {}; + initializeHeaderViewTables(); + } + + // eslint-disable-next-line no-global-assign + gViewAllHeaders = false; + } + + document.title = ""; + ClearCurrentHeaders(); + gBuiltExpandedView = false; + gBuildAttachmentsForCurrentMsg = false; + ClearAttachmentList(); + gMessageNotificationBar.clearMsgNotifications(); + + // Reset the blocked hosts so we can populate it again for this message. + document.getElementById("remoteContentOptions").value = ""; + + for (let listener of gMessageListeners) { + listener.onStartHeaders(); + } + }, + + onEndHeaders() { + if (!gViewWrapper || !gMessage) { + // The view wrapper and/or message went away before we finished loading + // the message. Bail out. + return; + } + + // Give add-ons a chance to modify currentHeaderData before it actually + // gets displayed. + for (let listener of gMessageListeners) { + if ("onBeforeShowHeaderPane" in listener) { + listener.onBeforeShowHeaderPane(); + } + } + + // Load feed web page if so configured. This entry point works for + // messagepane loads in 3pane folder tab, 3pane message tab, and the + // standalone message window. + if (!FeedMessageHandler.shouldShowSummary(gMessage, false)) { + FeedMessageHandler.setContent(gMessage, false); + } + + ShowMessageHeaderPane(); + // WARNING: This is the ONLY routine inside of the message Header Sink + // that should trigger a reflow! + ClearHeaderView(gExpandedHeaderView); + + // Make sure there is a subject even if it's empty so we'll show the + // subject and the twisty. + EnsureSubjectValue(); + + // Make sure there is a from value even if empty so the header toolbar + // will show up. + EnsureFromValue(); + + // Only update the expanded view if it's actually selected and needs updating. + if (!gBuiltExpandedView) { + UpdateExpandedMessageHeaders(); + } + + gMessageNotificationBar.setDraftEditMessage(); + updateHeaderToolbarButtons(); + + for (let listener of gMessageListeners) { + listener.onEndHeaders(); + } + }, + + processHeaders(headerNames, headerValues) { + const kMailboxSeparator = ", "; + var index = 0; + for (let i = 0; i < headerNames.length; i++) { + let header = { + headerName: headerNames[i], + headerValue: headerValues[i], + }; + + // For consistency's sake, let us force all header names to be lower + // case so we don't have to worry about looking for: Cc and CC, etc. + var lowerCaseHeaderName = header.headerName.toLowerCase(); + + // If we have an x-mailer, x-mimeole, or x-newsreader string, + // put it in the user-agent slot which we know how to handle already. + if (/^x-(mailer|mimeole|newsreader)$/.test(lowerCaseHeaderName)) { + lowerCaseHeaderName = "user-agent"; + } + + // According to RFC 2822, certain headers can occur "unlimited" times. + if (lowerCaseHeaderName in currentHeaderData) { + // Sometimes, you can have multiple To or Cc lines.... + // In this case, we want to append these headers into one. + if (lowerCaseHeaderName == "to" || lowerCaseHeaderName == "cc") { + currentHeaderData[lowerCaseHeaderName].headerValue = + currentHeaderData[lowerCaseHeaderName].headerValue + + "," + + header.headerValue; + } else { + // Use the index to create a unique header name like: + // received5, received6, etc + currentHeaderData[lowerCaseHeaderName + index++] = header; + } + } else { + currentHeaderData[lowerCaseHeaderName] = header; + } + + // See RFC 5322 section 3.6 for min-max number for given header. + // If multiple headers exist we need to make sure to use the first one. + if (lowerCaseHeaderName == "subject" && !document.title) { + let fullSubject = ""; + // Use the subject from the database, which may have been put there in + // decrypted form. + if (gMessage?.subject) { + if (gMessage.flags & Ci.nsMsgMessageFlags.HasRe) { + fullSubject = "Re: "; + } + fullSubject += gMessage.mime2DecodedSubject; + } + document.title = fullSubject || header.headerValue; + currentHeaderData.subject.headerValue = document.title; + } + } // while we have more headers to parse + + // Process message tags as if they were headers in the message. + gMessageHeader.setTags(); + updateStarButton(); + + if ("from" in currentHeaderData && "sender" in currentHeaderData) { + let senderMailbox = + kMailboxSeparator + + MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData.sender.headerValue + ) + + kMailboxSeparator; + let fromMailboxes = + kMailboxSeparator + + MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData.from.headerValue + ) + + kMailboxSeparator; + if (fromMailboxes.includes(senderMailbox)) { + delete currentHeaderData.sender; + } + } + + // We don't need to show the reply-to header if its value is either + // the From field (totally pointless) or the To field (common for + // mailing lists, but not that useful). + if ( + "from" in currentHeaderData && + "to" in currentHeaderData && + "reply-to" in currentHeaderData + ) { + let replyToMailbox = + MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData["reply-to"].headerValue + ); + let fromMailboxes = + MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData.from.headerValue + ); + let toMailboxes = MailServices.headerParser.extractHeaderAddressMailboxes( + currentHeaderData.to.headerValue + ); + + if (replyToMailbox == fromMailboxes || replyToMailbox == toMailboxes) { + delete currentHeaderData["reply-to"]; + } + } + + // For content-base urls stored uri encoded, we want to decode for + // display (and encode for external link open). + if ("content-base" in currentHeaderData) { + currentHeaderData["content-base"].headerValue = decodeURI( + currentHeaderData["content-base"].headerValue + ); + } + + let expandedfromLabel = document.getElementById("expandedfromLabel"); + if (FeedUtils.isFeedMessage(gMessage)) { + expandedfromLabel.value = expandedfromLabel.getAttribute("valueAuthor"); + } else { + expandedfromLabel.value = expandedfromLabel.getAttribute("valueFrom"); + } + + this.onEndHeaders(); + }, + + handleAttachment(contentType, url, displayName, uri, isExternalAttachment) { + let newAttachment = new AttachmentInfo({ + contentType, + url, + name: displayName, + uri, + isExternalAttachment, + message: gMessage, + updateAttachmentsDisplayFn: updateAttachmentsDisplay, + }); + currentAttachments.push(newAttachment); + + if (contentType == "application/pgp-keys" || displayName.endsWith(".asc")) { + Enigmail.msg.autoProcessPgpKeyAttachment(newAttachment); + } + }, + + addAttachmentField(field, value) { + let last = currentAttachments[currentAttachments.length - 1]; + if ( + field == "X-Mozilla-PartSize" && + !last.isFileAttachment && + !last.isDeleted + ) { + let size = parseInt(value); + + if (last.isLinkAttachment) { + // Check if an external link attachment's reported size is sane. + // A size of < 2 isn't sensical so ignore such placeholder values. + // Don't accept a size with any non numerics. Also cap the number. + // We want the size to be checked again, upon user action, to make + // sure size is updated with an accurate value, so |sizeResolved| + // remains false. + if (isNaN(size) || size.toString().length != value.length || size < 2) { + last.size = -1; + } else if (size > Number.MAX_SAFE_INTEGER) { + last.size = Number.MAX_SAFE_INTEGER; + } else { + last.size = size; + } + } else { + // For internal or file (detached) attachments, save the size. + last.size = size; + // For external file attachments, we won't have a valid size. + if (!last.isFileAttachment && size > -1) { + last.sizeResolved = true; + } + } + } else if (field == "X-Mozilla-PartDownloaded" && value == "0") { + // We haven't downloaded the attachment, so any size we get from + // libmime is almost certainly inaccurate. Just get rid of it. (Note: + // this relies on the fact that PartDownloaded comes after PartSize from + // the MIME emitter.) + // Note: for imap parts_on_demand, a small size consisting of the part + // headers would have been returned above. + last.size = -1; + last.sizeResolved = false; + } + }, + + onEndAllAttachments() { + Enigmail.msg.notifyEndAllAttachments(); + + displayAttachmentsForExpandedView(); + + for (let listener of gMessageListeners) { + if ("onEndAttachments" in listener) { + listener.onEndAttachments(); + } + } + }, + + /** + * This event is generated by nsMsgStatusFeedback when it gets an + * OnStateChange event for STATE_STOP. This is the same event that + * generates the "msgLoaded" property flag change event. This best + * corresponds to the end of the streaming process. + */ + onEndMsgDownload(url) { + let browser = getMessagePaneBrowser(); + + // If we have no attachments, we hide the attachment icon in the message + // tree. + // PGP key attachments do not count as attachments for the purposes of the + // message tree, even though we still show them in the attachment list. + // Otherwise the attachment icon becomes less useful when someone receives + // lots of signed messages. + // We do the same if we only have text/vcard attachments because we + // *assume* the vcard attachment is a personal vcard (rather than an + // addressbook, or a shared contact) that is attached to every message. + // NOTE: There would be some obvious give-aways in the vcard content that + // this personal vcard assumption is incorrect (multiple contacts, or a + // contact with an address that is different from the sender address) but we + // do not have easy access to the attachment content here, so we just stick + // to the assumption. + // NOTE: If the message contains two vcard attachments (or more) then this + // would hint that one of the vcards is not personal, but we won't make an + // exception here to keep the implementation simple. + gMessage?.markHasAttachments( + currentAttachments.some( + att => + att.contentType != "text/vcard" && + att.contentType != "text/x-vcard" && + att.contentType != "application/pgp-keys" + ) + ); + + if ( + currentAttachments.length && + Services.prefs.getBoolPref("mail.inline_attachments") && + FeedUtils.isFeedMessage(gMessage) && + browser && + browser.contentDocument && + browser.contentDocument.body + ) { + for (let img of browser.contentDocument.body.getElementsByClassName( + "moz-attached-image" + )) { + for (let attachment of currentAttachments) { + let partID = img.src.split("&part=")[1]; + partID = partID ? partID.split("&")[0] : null; + if (attachment.partID && partID == attachment.partID) { + img.src = attachment.url; + break; + } + } + + img.addEventListener("load", function (event) { + if (this.clientWidth > this.parentNode.clientWidth) { + img.setAttribute("overflowing", "true"); + img.setAttribute("shrinktofit", "true"); + } + }); + } + } + + OnMsgParsed(url); + }, + + onEndMsgHeaders(url) { + if (!url.errorCode) { + // Should not mark a message as read if failed to load. + OnMsgLoaded(url); + } + }, +}; + +/** + * Update the flagged (starred) state of the currently selected message. + */ +function updateStarButton() { + if (!gMessage || !gFolder) { + // No msgHdr to update, or we're dealing with an .eml. + document.getElementById("starMessageButton").hidden = true; + return; + } + + let flagButton = document.getElementById("starMessageButton"); + flagButton.hidden = false; + + let isFlagged = gMessage.isFlagged; + flagButton.classList.toggle("flagged", isFlagged); + flagButton.setAttribute("aria-checked", isFlagged); +} + +function EnsureSubjectValue() { + if (!("subject" in currentHeaderData)) { + let foo = {}; + foo.headerValue = ""; + foo.headerName = "subject"; + currentHeaderData[foo.headerName] = foo; + } +} + +function EnsureFromValue() { + if (!("from" in currentHeaderData)) { + let foo = {}; + foo.headerValue = ""; + foo.headerName = "from"; + currentHeaderData[foo.headerName] = foo; + } +} + +function OnTagsChange() { + // rebuild the tag headers + gMessageHeader.setTags(); + + // Now update the expanded header view to rebuild the tags, + // and then show or hide the tag header box. + if (gBuiltExpandedView) { + let headerEntry = gExpandedHeaderView.tags; + if (headerEntry) { + headerEntry.valid = "tags" in currentHeaderData; + if (headerEntry.valid) { + headerEntry.outputFunction( + headerEntry, + currentHeaderData.tags.headerValue + ); + } + + // we may need to collapse or show the tag header row... + headerEntry.enclosingRow.hidden = !headerEntry.valid; + // ... and ensure that all headers remain correctly aligned + gMessageHeader.syncLabelsColumnWidths(); + } + } +} + +/** + * Flush out any local state being held by a header entry for a given table. + * + * @param aHeaderTable Table of header entries + */ +function ClearHeaderView(aHeaderTable) { + for (let name in aHeaderTable) { + let headerEntry = aHeaderTable[name]; + headerEntry.enclosingBox.clearHeaderValues?.(); + headerEntry.enclosingBox.clear?.(); + + headerEntry.valid = false; + } +} + +/** + * Make sure that any valid header entry in the table is collapsed. + * + * @param aHeaderTable Table of header entries + */ +function hideHeaderView(aHeaderTable) { + for (let name in aHeaderTable) { + let headerEntry = aHeaderTable[name]; + headerEntry.enclosingRow.hidden = true; + } +} + +/** + * Make sure that any valid header entry in the table specified is visible. + * + * @param aHeaderTable Table of header entries + */ +function showHeaderView(aHeaderTable) { + for (let name in aHeaderTable) { + let headerEntry = aHeaderTable[name]; + headerEntry.enclosingRow.hidden = !headerEntry.valid; + + // If we're hiding the To field, we need to hide the date inline and show + // the duplicate on the subject line. + if (headerEntry.enclosingRow.id == "expandedtoRow") { + let dateLabel = document.getElementById("dateLabel"); + let dateLabelSubject = document.getElementById("dateLabelSubject"); + if (!headerEntry.valid) { + dateLabelSubject.setAttribute( + "datetime", + dateLabel.getAttribute("datetime") + ); + dateLabelSubject.textContent = dateLabel.textContent; + dateLabelSubject.hidden = false; + } else { + dateLabelSubject.removeAttribute("datetime"); + dateLabelSubject.textContent = ""; + dateLabelSubject.hidden = true; + } + } + } +} + +/** + * Enumerate through the list of headers and find the number that are visible + * add empty entries if we don't have the minimum number of rows. + */ +function EnsureMinimumNumberOfHeaders(headerTable) { + // 0 means we don't have a minimum... do nothing special + if (!gMinNumberOfHeaders) { + return; + } + + var numVisibleHeaders = 0; + for (let name in headerTable) { + let headerEntry = headerTable[name]; + if (headerEntry.valid) { + numVisibleHeaders++; + } + } + + if (numVisibleHeaders < gMinNumberOfHeaders) { + // How many empty headers do we need to add? + var numEmptyHeaders = gMinNumberOfHeaders - numVisibleHeaders; + + // We may have already dynamically created our empty rows and we just need + // to make them visible. + for (let index in headerTable) { + let headerEntry = headerTable[index]; + if (index.startsWith("Dummy-Header") && numEmptyHeaders) { + headerEntry.valid = true; + numEmptyHeaders--; + } + } + + // Ok, now if we have any extra dummy headers we need to add, create a new + // header widget for them. + while (numEmptyHeaders) { + var dummyHeaderId = "Dummy-Header" + gDummyHeaderIdIndex; + gExpandedHeaderView[dummyHeaderId] = new HeaderView(dummyHeaderId, ""); + gExpandedHeaderView[dummyHeaderId].valid = true; + + gDummyHeaderIdIndex++; + numEmptyHeaders--; + } + } +} + +/** + * Make sure the appropriate fields in the expanded header view are collapsed + * or visible... + */ +function updateExpandedView() { + if (gMinNumberOfHeaders) { + EnsureMinimumNumberOfHeaders(gExpandedHeaderView); + } + showHeaderView(gExpandedHeaderView); + + // Now that we have all the headers, ensure that the name columns of both + // grids are the same size so that they don't look weird. + gMessageHeader.syncLabelsColumnWidths(); + + UpdateReplyButtons(); + updateHeaderToolbarButtons(); + updateComposeButtons(); + displayAttachmentsForExpandedView(); + + try { + AdjustHeaderView(Services.prefs.getIntPref("mail.show_headers")); + } catch (e) { + console.error(e); + } +} + +/** + * Default method for updating a header value into a header entry + * + * @param aHeaderEntry A single header from currentHeaderData + * @param aHeaderValue The new value for headerEntry + */ +function updateHeaderValue(aHeaderEntry, aHeaderValue) { + aHeaderEntry.enclosingBox.headerValue = aHeaderValue; +} + +/** + * Create the DOM nodes (aka "View") for a non-standard header and insert them + * into the grid. Create and return the corresponding headerEntry object. + * + * @param {string} headerName - name of the header we're adding, used to + * construct the element IDs (in lower case) + * @param {string} label - name of the header as displayed in the UI + */ +class HeaderView { + constructor(headerName, label) { + headerName = headerName.toLowerCase(); + let rowId = "expanded" + headerName + "Row"; + let idName = "expanded" + headerName + "Box"; + let newHeaderNode; + // If a row for this header already exists, do not create another one. + let newRowNode = document.getElementById(rowId); + if (!newRowNode) { + // Create new collapsed row. + newRowNode = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "div" + ); + newRowNode.setAttribute("id", rowId); + newRowNode.classList.add("message-header-row"); + newRowNode.hidden = true; + + // Create and append the label which contains the header name. + let newLabelNode = document.createXULElement("label"); + newLabelNode.setAttribute("id", "expanded" + headerName + "Label"); + newLabelNode.setAttribute("value", label); + newLabelNode.setAttribute("class", "message-header-label"); + + newRowNode.appendChild(newLabelNode); + + // Create and append the new header value. + newHeaderNode = document.createElement("div", { + is: "simple-header-row", + }); + newHeaderNode.setAttribute("id", idName); + newHeaderNode.dataset.prettyHeaderName = label; + newHeaderNode.dataset.headerName = headerName; + newRowNode.appendChild(newHeaderNode); + + // Add the new row to the extra headers container. + document.getElementById("extraHeadersArea").appendChild(newRowNode); + this.isNewHeader = true; + } else { + newRowNode.hidden = true; + newHeaderNode = document.getElementById(idName); + this.isNewHeader = false; + } + + this.enclosingBox = newHeaderNode; + this.enclosingRow = newRowNode; + this.valid = false; + this.outputFunction = updateHeaderValue; + } +} + +/** + * Removes all non-predefined header nodes from the view. + * + * @param aHeaderTable Table of header entries. + */ +function RemoveNewHeaderViews(aHeaderTable) { + for (let name in aHeaderTable) { + let headerEntry = aHeaderTable[name]; + if (headerEntry.isNewHeader) { + headerEntry.enclosingRow.remove(); + } + } +} + +/** + * UpdateExpandedMessageHeaders: Iterate through all the current header data + * we received from mime for this message for the expanded header entry table, + * and see if we have a corresponding entry for that header (i.e. + * whether the expanded header view cares about this header value) + * If so, then call updateHeaderEntry + */ +function UpdateExpandedMessageHeaders() { + // Iterate over each header we received and see if we have a matching entry + // in each header view table... + var headerName; + + // Remove the height attr so that it redraws correctly. Works around a problem + // that attachment-splitter causes if it's moved high enough to affect + // the header box: + document.getElementById("msgHeaderView").removeAttribute("height"); + // This height attribute may be set by toggleWrap() if the user clicked + // the "more" button" in the header. + // Remove it so that the height is determined automatically. + + for (headerName in currentHeaderData) { + var headerField = currentHeaderData[headerName]; + var headerEntry = null; + + if (headerName in gExpandedHeaderView) { + headerEntry = gExpandedHeaderView[headerName]; + } + + if (!headerEntry && gViewAllHeaders) { + // For view all headers, if we don't have a header field for this + // value, cheat and create one then fill in a headerEntry. + if (headerName == "message-id" || headerName == "in-reply-to") { + var messageIdEntry = { + name: headerName, + outputFunction: outputMessageIds, + }; + gExpandedHeaderView[headerName] = new MsgHeaderEntry( + "expanded", + messageIdEntry + ); + } else if (headerName != "x-mozilla-localizeddate") { + // Don't bother showing X-Mozilla-LocalizedDate, since that value is + // displayed below the message header toolbar. + gExpandedHeaderView[headerName] = new HeaderView( + headerName, + currentHeaderData[headerName].headerName + ); + } + + headerEntry = gExpandedHeaderView[headerName]; + } + + if (headerEntry) { + if ( + headerName == "references" && + !( + gViewAllHeaders || + gHeadersShowReferences || + gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false) + ) + ) { + // Hide references header if view all headers mode isn't selected, the + // pref show references is deactivated and the currently displayed + // message isn't a newsgroup posting. + headerEntry.valid = false; + } else { + // Set the row element visible before populating the field with addresses. + headerEntry.enclosingRow.hidden = false; + headerEntry.outputFunction(headerEntry, headerField.headerValue); + headerEntry.valid = true; + } + } + } + + let otherHeaders = Services.prefs + .getCharPref("mail.compose.other.header", "") + .split(",") + .map(h => h.trim()) + .filter(Boolean); + + for (let otherHeaderName of otherHeaders) { + let toLowerCaseHeaderName = otherHeaderName.toLowerCase(); + let headerEntry = gExpandedHeaderView[toLowerCaseHeaderName]; + let headerData = currentHeaderData[toLowerCaseHeaderName]; + + if (headerEntry && headerData) { + headerEntry.outputFunction(headerEntry, headerData.headerValue); + headerEntry.valid = true; + } + } + + let dateLabel = document.getElementById("dateLabel"); + dateLabel.hidden = true; + if ( + "x-mozilla-localizeddate" in currentHeaderData && + currentHeaderData["x-mozilla-localizeddate"].headerValue + ) { + dateLabel.textContent = + currentHeaderData["x-mozilla-localizeddate"].headerValue; + let date = new Date(currentHeaderData.date.headerValue); + if (!isNaN(date)) { + dateLabel.setAttribute("datetime", date.toISOString()); + dateLabel.hidden = false; + } + } + + gBuiltExpandedView = true; + + // Now update the view to make sure the right elements are visible. + updateExpandedView(); +} + +function ClearCurrentHeaders() { + gSecureMsgProbe = {}; + // eslint-disable-next-line no-global-assign + currentHeaderData = {}; + // eslint-disable-next-line no-global-assign + currentAttachments = []; + currentCharacterSet = ""; +} + +function ShowMessageHeaderPane() { + document.getElementById("msgHeaderView").collapsed = false; + document.getElementById("mail-notification-top").collapsed = false; + + // Initialize the DBListener if we don't have one. This might happen when the + // message pane is hidden or no message was selected before, which caused the + // clearing of the the DBListener. + initFolderDBListener(); +} + +function HideMessageHeaderPane() { + let header = document.getElementById("msgHeaderView"); + header.collapsed = true; + document.getElementById("mail-notification-top").collapsed = true; + + // Disable the attachment box. + document.getElementById("attachmentView").collapsed = true; + document.getElementById("attachment-splitter").collapsed = true; + + gMessageNotificationBar.clearMsgNotifications(); + // Clear the DBListener since we don't have any visible UI to update. + clearFolderDBListener(); + + // Now let interested listeners know the pane has been hidden. + header.dispatchEvent(new Event("message-header-pane-hidden")); +} + +/** + * Take a string of newsgroups separated by commas, split it into newsgroups and + * add them to the corresponding header-newsgroups-row element. + * + * @param {MsgHeaderEntry} headerEntry - The data structure for this header. + * @param {string} headerValue - The string of newsgroups from the message. + */ +function outputNewsgroups(headerEntry, headerValue) { + headerValue + .split(",") + .forEach(newsgroup => headerEntry.enclosingBox.addNewsgroup(newsgroup)); + headerEntry.enclosingBox.buildView(); +} + +/** + * Take a string of tags separated by space, split them and add them to the + * corresponding header-tags-row element. + * + * @param {MsgHeaderEntry} headerEntry - The data structure for this header. + * @param {string} headerValue - The string of tags from the message. + */ +function outputTags(headerEntry, headerValue) { + headerEntry.enclosingBox.buildTags(headerValue.split(" ")); +} + +/** + * Take a string of message-ids separated by whitespace, split it and send them + * to the corresponding header-message-ids-row element. + * + * @param {MsgHeaderEntry} headerEntry - The data structure for this header. + * @param {string} headerValue - The string of message IDs from the message. + */ +function outputMessageIds(headerEntry, headerValue) { + headerEntry.enclosingBox.clear(); + + for (let id of headerValue.split(/\s+/)) { + headerEntry.enclosingBox.addId(id); + } + + headerEntry.enclosingBox.buildView(); +} + +/** + * Take a string of addresses separated by commas, split it into separated + * recipient objects and add them to the related parent container row. + * + * @param {MsgHeaderEntry} headerEntry - The data structure for this header. + * @param {string} emailAddresses - The string of addresses from the message. + */ +function outputEmailAddresses(headerEntry, emailAddresses) { + if (!emailAddresses) { + return; + } + + // The email addresses are still RFC2047 encoded but libmime has already + // converted from "raw UTF-8" to "wide" (UTF-16) characters. + let addresses = MailServices.headerParser.parseEncodedHeaderW(emailAddresses); + + // Make sure we start clean. + headerEntry.enclosingBox.clear(); + + // No addresses and a colon, so an empty group like "undisclosed-recipients: ;". + // Add group name so at least something displays. + if (!addresses.length && emailAddresses.includes(":")) { + let address = { displayName: emailAddresses }; + headerEntry.enclosingBox.addRecipient(address); + } + + for (let addr of addresses) { + // If we want to include short/long toggle views and we have a long view, + // always add it. If we aren't including a short/long view OR if we are and + // we haven't parsed enough addresses to reach the cutoff valve yet then add + // it to the default (short) div. + let address = {}; + address.emailAddress = addr.email; + address.fullAddress = addr.toString(); + address.displayName = addr.name; + headerEntry.enclosingBox.addRecipient(address); + } + + headerEntry.enclosingBox.buildView(); +} + +/** + * Return true if possible attachments in the currently loaded message can be + * deleted/detached. + */ +function CanDetachAttachments() { + var canDetach = + !gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false) && + (!gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.ImapBox, false) || + MailOfflineMgr.isOnline()) && + gFolder; // We can't detach from loaded eml files yet. + if (canDetach && "content-type" in currentHeaderData) { + canDetach = !ContentTypeIsSMIME( + currentHeaderData["content-type"].headerValue + ); + } + if (canDetach) { + canDetach = Enigmail.hdrView.enigCanDetachAttachments(); + } + + return canDetach; +} + +/** + * Return true if the content type is an S/MIME one. + */ +function ContentTypeIsSMIME(contentType) { + // S/MIME is application/pkcs7-mime and application/pkcs7-signature + // - also match application/x-pkcs7-mime and application/x-pkcs7-signature. + return /application\/(x-)?pkcs7-(mime|signature)/.test(contentType); +} + +function onShowAttachmentToolbarContextMenu() { + let expandBar = document.getElementById("context-expandAttachmentBar"); + let expanded = Services.prefs.getBoolPref( + "mailnews.attachments.display.start_expanded" + ); + expandBar.setAttribute("checked", expanded); +} + +/** + * Set up the attachment item context menu, showing or hiding the appropriate + * menu items. + */ +function onShowAttachmentItemContextMenu() { + let attachmentList = document.getElementById("attachmentList"); + let attachmentInfo = document.getElementById("attachmentInfo"); + let attachmentName = document.getElementById("attachmentName"); + let contextMenu = document.getElementById("attachmentItemContext"); + let openMenu = document.getElementById("context-openAttachment"); + let saveMenu = document.getElementById("context-saveAttachment"); + let detachMenu = document.getElementById("context-detachAttachment"); + let deleteMenu = document.getElementById("context-deleteAttachment"); + let copyUrlMenuSep = document.getElementById( + "context-menu-copyurl-separator" + ); + let copyUrlMenu = document.getElementById("context-copyAttachmentUrl"); + let openFolderMenu = document.getElementById("context-openFolder"); + + // If we opened the context menu from the attachment info area (the paperclip, + // "1 attachment" label, filename, or file size, just grab the first (and + // only) attachment as our "selected" attachments. + var selectedAttachments; + if ( + contextMenu.triggerNode == attachmentInfo || + contextMenu.triggerNode.parentNode == attachmentInfo + ) { + selectedAttachments = [attachmentList.getItemAtIndex(0).attachment]; + if (contextMenu.triggerNode == attachmentName) { + attachmentName.setAttribute("selected", true); + } + } else { + selectedAttachments = [...attachmentList.selectedItems].map( + item => item.attachment + ); + } + contextMenu.attachments = selectedAttachments; + + var allSelectedDetached = selectedAttachments.every(function (attachment) { + return attachment.isExternalAttachment; + }); + var allSelectedDeleted = selectedAttachments.every(function (attachment) { + return !attachment.hasFile; + }); + var canDetachSelected = + CanDetachAttachments() && !allSelectedDetached && !allSelectedDeleted; + let allSelectedHttp = selectedAttachments.every(function (attachment) { + return attachment.isLinkAttachment; + }); + let allSelectedFile = selectedAttachments.every(function (attachment) { + return attachment.isFileAttachment; + }); + + openMenu.disabled = allSelectedDeleted; + saveMenu.disabled = allSelectedDeleted; + detachMenu.disabled = !canDetachSelected; + deleteMenu.disabled = !canDetachSelected; + copyUrlMenuSep.hidden = copyUrlMenu.hidden = !( + allSelectedHttp || allSelectedFile + ); + openFolderMenu.hidden = !allSelectedFile; + openFolderMenu.disabled = allSelectedDeleted; + + Enigmail.hdrView.onShowAttachmentContextMenu(); +} + +/** + * Close the attachment item context menu, performing any cleanup as necessary. + */ +function onHideAttachmentItemContextMenu() { + let attachmentName = document.getElementById("attachmentName"); + let contextMenu = document.getElementById("attachmentItemContext"); + + // If we opened the context menu from the attachmentName label, we need to + // get rid of the "selected" attribute. + if (contextMenu.triggerNode == attachmentName) { + attachmentName.removeAttribute("selected"); + } +} + +/** + * Enable/disable menu items as appropriate for the single-attachment save all + * toolbar button. + */ +function onShowSaveAttachmentMenuSingle() { + let openItem = document.getElementById("button-openAttachment"); + let saveItem = document.getElementById("button-saveAttachment"); + let detachItem = document.getElementById("button-detachAttachment"); + let deleteItem = document.getElementById("button-deleteAttachment"); + + let detached = currentAttachments[0].isExternalAttachment; + let deleted = !currentAttachments[0].hasFile; + let canDetach = CanDetachAttachments() && !deleted && !detached; + + openItem.disabled = deleted; + saveItem.disabled = deleted; + detachItem.disabled = !canDetach; + deleteItem.disabled = !canDetach; +} + +/** + * Enable/disable menu items as appropriate for the multiple-attachment save all + * toolbar button. + */ +function onShowSaveAttachmentMenuMultiple() { + let openAllItem = document.getElementById("button-openAllAttachments"); + let saveAllItem = document.getElementById("button-saveAllAttachments"); + let detachAllItem = document.getElementById("button-detachAllAttachments"); + let deleteAllItem = document.getElementById("button-deleteAllAttachments"); + + let allDetached = currentAttachments.every(function (attachment) { + return attachment.isExternalAttachment; + }); + let allDeleted = currentAttachments.every(function (attachment) { + return !attachment.hasFile; + }); + let canDetach = CanDetachAttachments() && !allDeleted && !allDetached; + + openAllItem.disabled = allDeleted; + saveAllItem.disabled = allDeleted; + detachAllItem.disabled = !canDetach; + deleteAllItem.disabled = !canDetach; +} + +/** + * This is our oncommand handler for the attachment list items. A double click + * or enter press in an attachmentitem simulates "opening" the attachment. + * + * @param event the event object + */ +function attachmentItemCommand(event) { + HandleSelectedAttachments("open"); +} + +var AttachmentListController = { + supportsCommand(command) { + switch (command) { + case "cmd_selectAll": + case "cmd_delete": + case "cmd_shiftDelete": + case "cmd_saveAsFile": + return true; + default: + return false; + } + }, + + isCommandEnabled(command) { + switch (command) { + case "cmd_selectAll": + case "cmd_delete": + case "cmd_shiftDelete": + case "cmd_saveAsFile": + return true; + default: + return false; + } + }, + + doCommand(command) { + // If the user invoked a key short cut then it is possible that we got here + // for a command which is really disabled. kick out if the command should + // be disabled. + if (!this.isCommandEnabled(command)) { + return; + } + + var attachmentList = document.getElementById("attachmentList"); + + switch (command) { + case "cmd_selectAll": + attachmentList.selectAll(); + return; + case "cmd_delete": + case "cmd_shiftDelete": + HandleSelectedAttachments("delete"); + return; + case "cmd_saveAsFile": + HandleSelectedAttachments("saveAs"); + } + }, + + onEvent(event) {}, +}; + +var AttachmentMenuController = { + canDetachFiles() { + let someNotDetached = currentAttachments.some(function (aAttachment) { + return !aAttachment.isExternalAttachment; + }); + + return ( + CanDetachAttachments() && someNotDetached && this.someFilesAvailable() + ); + }, + + someFilesAvailable() { + return currentAttachments.some(function (aAttachment) { + return aAttachment.hasFile; + }); + }, + + supportsCommand(aCommand) { + return aCommand in this.commands; + }, +}; + +function goUpdateAttachmentCommands() { + for (let action of ["open", "save", "detach", "delete"]) { + goUpdateCommand(`cmd_${action}AllAttachments`); + } +} + +async function displayAttachmentsForExpandedView() { + var bundle = document.getElementById("bundle_messenger"); + var numAttachments = currentAttachments.length; + var attachmentView = document.getElementById("attachmentView"); + var attachmentSplitter = document.getElementById("attachment-splitter"); + document + .getElementById("attachmentIcon") + .setAttribute("src", "chrome://messenger/skin/icons/attach.svg"); + + if (numAttachments <= 0) { + attachmentView.collapsed = true; + attachmentSplitter.collapsed = true; + } else if (!gBuildAttachmentsForCurrentMsg) { + attachmentView.collapsed = false; + + var attachmentList = document.getElementById("attachmentList"); + + attachmentList.controllers.appendController(AttachmentListController); + + toggleAttachmentList(false); + + for (let attachment of currentAttachments) { + // Create a new attachment widget + var displayName = SanitizeAttachmentDisplayName(attachment); + var item = attachmentList.appendItem(attachment, displayName); + item.setAttribute("tooltiptext", attachment.name); + item.addEventListener("command", attachmentItemCommand); + + // Get a detached file's size. For link attachments, the user must always + // initiate the fetch for privacy reasons. + if (attachment.isFileAttachment) { + await attachment.isEmpty(); + } + } + + if ( + Services.prefs.getBoolPref("mailnews.attachments.display.start_expanded") + ) { + toggleAttachmentList(true); + } + + let attachmentInfo = document.getElementById("attachmentInfo"); + let attachmentCount = document.getElementById("attachmentCount"); + let attachmentName = document.getElementById("attachmentName"); + let attachmentSize = document.getElementById("attachmentSize"); + + if (numAttachments == 1) { + let count = bundle.getString("attachmentCountSingle"); + let name = SanitizeAttachmentDisplayName(currentAttachments[0]); + + attachmentInfo.setAttribute("contextmenu", "attachmentItemContext"); + attachmentCount.setAttribute("value", count); + attachmentName.hidden = false; + attachmentName.setAttribute("value", name); + } else { + let words = bundle.getString("attachmentCount"); + let count = PluralForm.get(currentAttachments.length, words).replace( + "#1", + currentAttachments.length + ); + + attachmentInfo.setAttribute("contextmenu", "attachmentListContext"); + attachmentCount.setAttribute("value", count); + attachmentName.hidden = true; + } + + attachmentSize.value = getAttachmentsTotalSizeStr(); + + // Extra candy for external attachments. + displayAttachmentsForExpandedViewExternal(); + + // Show the appropriate toolbar button and label based on the number of + // attachments. + updateSaveAllAttachmentsButton(); + + gBuildAttachmentsForCurrentMsg = true; + } +} + +function displayAttachmentsForExpandedViewExternal() { + let bundleMessenger = document.getElementById("bundle_messenger"); + let attachmentName = document.getElementById("attachmentName"); + let attachmentList = document.getElementById("attachmentList"); + + // Attachment bar single. + let firstAttachment = attachmentList.firstElementChild.attachment; + let isExternalAttachment = firstAttachment.isExternalAttachment; + let displayUrl = isExternalAttachment ? firstAttachment.displayUrl : ""; + let tooltiptext = + isExternalAttachment || firstAttachment.isDeleted + ? "" + : attachmentName.getAttribute("tooltiptextopen"); + let externalAttachmentNotFound = bundleMessenger.getString( + "externalAttachmentNotFound" + ); + + attachmentName.textContent = displayUrl; + attachmentName.tooltipText = tooltiptext; + attachmentName.setAttribute( + "tooltiptextexternalnotfound", + externalAttachmentNotFound + ); + attachmentName.addEventListener("mouseover", () => + top.MsgStatusFeedback.setOverLink(displayUrl) + ); + attachmentName.addEventListener("mouseout", () => + top.MsgStatusFeedback.setOverLink("") + ); + attachmentName.addEventListener("focus", () => + top.MsgStatusFeedback.setOverLink(displayUrl) + ); + attachmentName.addEventListener("blur", () => + top.MsgStatusFeedback.setOverLink("") + ); + attachmentName.classList.remove("text-link"); + attachmentName.classList.remove("notfound"); + + if (firstAttachment.isDeleted) { + attachmentName.classList.add("notfound"); + } + + if (isExternalAttachment) { + attachmentName.classList.add("text-link"); + + if (!firstAttachment.hasFile) { + attachmentName.setAttribute("tooltiptext", externalAttachmentNotFound); + attachmentName.classList.add("notfound"); + } + } + + // Expanded attachment list. + let index = 0; + for (let attachmentitem of attachmentList.children) { + let attachment = attachmentitem.attachment; + if (attachment.isDeleted) { + attachmentitem.classList.add("notfound"); + } + + if (attachment.isExternalAttachment) { + displayUrl = attachment.displayUrl; + attachmentitem.setAttribute("tooltiptext", ""); + attachmentitem.addEventListener("mouseover", () => + top.MsgStatusFeedback.setOverLink(displayUrl) + ); + attachmentitem.addEventListener("mouseout", () => + top.MsgStatusFeedback.setOverLink("") + ); + attachmentitem.addEventListener("focus", () => + top.MsgStatusFeedback.setOverLink(displayUrl) + ); + attachmentitem.addEventListener("blur", () => + top.MsgStatusFeedback.setOverLink("") + ); + + attachmentitem + .querySelector(".attachmentcell-name") + .classList.add("text-link"); + attachmentitem + .querySelector(".attachmentcell-extension") + .classList.add("text-link"); + + if (attachment.isLinkAttachment) { + if (index == 0) { + attachment.size = currentAttachments[index].size; + } + } + + if (!attachment.hasFile) { + attachmentitem.setAttribute("tooltiptext", externalAttachmentNotFound); + attachmentitem.classList.add("notfound"); + } + } + + index++; + } +} + +/** + * Update the "save all attachments" button in the attachment pane, showing + * the proper button and enabling/disabling it as appropriate. + */ +function updateSaveAllAttachmentsButton() { + let saveAllSingle = document.getElementById("attachmentSaveAllSingle"); + let saveAllMultiple = document.getElementById("attachmentSaveAllMultiple"); + + // If we can't find the buttons, they're not on the toolbar, so bail out! + if (!saveAllSingle || !saveAllMultiple) { + return; + } + + let allDeleted = currentAttachments.every(function (attachment) { + return !attachment.hasFile; + }); + let single = currentAttachments.length == 1; + + saveAllSingle.hidden = !single; + saveAllMultiple.hidden = single; + saveAllSingle.disabled = saveAllMultiple.disabled = allDeleted; +} + +/** + * Update the attachments display info after a particular attachment's + * existence has been verified. + * + * @param {AttachmentInfo} attachmentInfo + * @param {boolean} isFetching + */ +function updateAttachmentsDisplay(attachmentInfo, isFetching) { + if (attachmentInfo.isExternalAttachment) { + let attachmentList = document.getElementById("attachmentList"); + let attachmentIcon = document.getElementById("attachmentIcon"); + let attachmentName = document.getElementById("attachmentName"); + let attachmentSize = document.getElementById("attachmentSize"); + let attachmentItem = attachmentList.findItemForAttachment(attachmentInfo); + let index = attachmentList.getIndexOfItem(attachmentItem); + + if (isFetching) { + // Set elements busy to show the user this is potentially a long network + // fetch for the link attachment. + attachmentList.setAttachmentLoaded(attachmentItem, false); + return; + } + + if (attachmentInfo.message != gMessage) { + // The user changed messages while fetching, reset the bar and exit; + // the listitems are torn down/rebuilt on each message load. + attachmentIcon.setAttribute( + "src", + "chrome://messenger/skin/icons/attach.svg" + ); + return; + } + + if (index == -1) { + // The user changed messages while fetching, then came back to the same + // message. The reset of busy state has already happened and anyway the + // item has already been torn down so the index will be invalid; exit. + return; + } + + currentAttachments[index].size = attachmentInfo.size; + let tooltiptextExternalNotFound = attachmentName.getAttribute( + "tooltiptextexternalnotfound" + ); + + let sizeStr; + let bundle = document.getElementById("bundle_messenger"); + if (attachmentInfo.size < 1) { + sizeStr = bundle.getString("attachmentSizeUnknown"); + } else { + sizeStr = top.messenger.formatFileSize(attachmentInfo.size); + } + + // The attachment listitem. + attachmentList.setAttachmentLoaded(attachmentItem, true); + attachmentList.setAttachmentSize( + attachmentItem, + attachmentInfo.hasFile ? sizeStr : "" + ); + + // FIXME: The UI logic for this should be moved to the attachment list or + // item itself. + if (attachmentInfo.hasFile) { + attachmentItem.removeAttribute("tooltiptext"); + attachmentItem.classList.remove("notfound"); + } else { + attachmentItem.setAttribute("tooltiptext", tooltiptextExternalNotFound); + attachmentItem.classList.add("notfound"); + } + + // The attachmentbar. + updateSaveAllAttachmentsButton(); + attachmentSize.value = getAttachmentsTotalSizeStr(); + if (attachmentList.isLoaded()) { + attachmentIcon.setAttribute( + "src", + "chrome://messenger/skin/icons/attach.svg" + ); + } + + // If it's the first one (and there's only one). + if (index == 0) { + if (attachmentInfo.hasFile) { + attachmentName.removeAttribute("tooltiptext"); + attachmentName.classList.remove("notfound"); + } else { + attachmentName.setAttribute("tooltiptext", tooltiptextExternalNotFound); + attachmentName.classList.add("notfound"); + } + } + + // Reset widths since size may have changed; ensure no false cropping of + // the attachment item name. + attachmentList.setOptimumWidth(); + } +} + +/** + * Calculate the total size of all attachments in the message as emitted to + * |currentAttachments| and return a pretty string. + * + * @returns {string} - Description of the attachment size (e.g. 123 KB or 3.1MB) + */ +function getAttachmentsTotalSizeStr() { + let bundle = document.getElementById("bundle_messenger"); + let totalSize = 0; + let lastPartID; + let unknownSize = false; + for (let attachment of currentAttachments) { + // Check if this attachment's part ID is a child of the last attachment + // we counted. If so, skip it, since we already accounted for its size + // from its parent. + if (!lastPartID || attachment.partID.indexOf(lastPartID) != 0) { + lastPartID = attachment.partID; + if (attachment.size != -1) { + totalSize += Number(attachment.size); + } else if (!attachment.isDeleted) { + unknownSize = true; + } + } + } + + let sizeStr = top.messenger.formatFileSize(totalSize); + if (unknownSize) { + if (totalSize == 0) { + sizeStr = bundle.getString("attachmentSizeUnknown"); + } else { + sizeStr = bundle.getFormattedString("attachmentSizeAtLeast", [sizeStr]); + } + } + + return sizeStr; +} + +/** + * Expand/collapse the attachment list. When expanding it, automatically resize + * it to an appropriate height (1/4 the message pane or smaller). + * + * @param expanded True if the attachment list should be expanded, false + * otherwise. If |expanded| is not specified, toggle the state. + * @param updateFocus (optional) True if the focus should be updated, focusing + * on the attachmentList when expanding, or the messagepane + * when collapsing (but only when the attachmentList was + * originally focused). + */ +function toggleAttachmentList(expanded, updateFocus) { + var attachmentView = document.getElementById("attachmentView"); + var attachmentBar = document.getElementById("attachmentBar"); + var attachmentToggle = document.getElementById("attachmentToggle"); + var attachmentList = document.getElementById("attachmentList"); + var attachmentSplitter = document.getElementById("attachment-splitter"); + var bundle = document.getElementById("bundle_messenger"); + + if (expanded === undefined) { + expanded = !attachmentToggle.checked; + } + + attachmentToggle.checked = expanded; + + if (expanded) { + attachmentList.collapsed = false; + if (!attachmentView.collapsed) { + attachmentSplitter.collapsed = false; + } + attachmentBar.setAttribute( + "tooltiptext", + bundle.getString("collapseAttachmentPaneTooltip") + ); + + attachmentList.setOptimumWidth(); + + // By design, attachmentView should not take up more than 1/4 of the message + // pane space + attachmentView.setAttribute( + "height", + Math.min( + attachmentList.preferredHeight, + document.getElementById("messagepanebox").getBoundingClientRect() + .height / 4 + ) + ); + + if (updateFocus) { + attachmentList.focus(); + } + } else { + attachmentList.collapsed = true; + attachmentSplitter.collapsed = true; + attachmentBar.setAttribute( + "tooltiptext", + bundle.getString("expandAttachmentPaneTooltip") + ); + attachmentView.removeAttribute("height"); + + if (updateFocus && document.activeElement == attachmentList) { + // TODO + } + } +} + +/** + * Open an attachment from the attachment bar. + * + * @param event the event that triggered this action + */ +function OpenAttachmentFromBar(event) { + if (event.button == 0) { + // Only open on the first click; ignore double-clicks so that the user + // doesn't end up with the attachment opened multiple times. + if (event.detail == 1) { + TryHandleAllAttachments("open"); + } + event.stopPropagation(); + } +} + +/** + * Handle all the attachments in this message (save them, open them, etc). + * + * @param action one of "open", "save", "saveAs", "detach", or "delete" + */ +function HandleAllAttachments(action) { + HandleMultipleAttachments(currentAttachments, action); +} + +/** + * Try to handle all the attachments in this message (save them, open them, + * etc). If the action fails for whatever reason, catch the error and report it. + * + * @param action one of "open", "save", "saveAs", "detach", or "delete" + */ +function TryHandleAllAttachments(action) { + try { + HandleAllAttachments(action); + } catch (e) { + console.error(e); + } +} + +/** + * Handle the currently-selected attachments in this message (save them, open + * them, etc). + * + * @param action one of "open", "save", "saveAs", "detach", or "delete" + */ +function HandleSelectedAttachments(action) { + let attachmentList = document.getElementById("attachmentList"); + let selectedAttachments = []; + for (let item of attachmentList.selectedItems) { + selectedAttachments.push(item.attachment); + } + + HandleMultipleAttachments(selectedAttachments, action); +} + +/** + * Perform an action on multiple attachments (e.g. open or save) + * + * @param attachments an array of AttachmentInfo objects to work with + * @param action one of "open", "save", "saveAs", "detach", or "delete" + */ +function HandleMultipleAttachments(attachments, action) { + // Feed message link attachments save handling. + if ( + FeedUtils.isFeedMessage(gMessage) && + (action == "save" || action == "saveAs") + ) { + saveLinkAttachmentsToFile(attachments); + return; + } + + // convert our attachment data into some c++ friendly structs + var attachmentContentTypeArray = []; + var attachmentUrlArray = []; + var attachmentDisplayUrlArray = []; + var attachmentDisplayNameArray = []; + var attachmentMessageUriArray = []; + + // populate these arrays.. + var actionIndex = 0; + for (let attachment of attachments) { + // Exclude attachment which are 1) deleted, or 2) detached with missing + // external files, unless copying urls. + if (!attachment.hasFile && action != "copyUrl") { + continue; + } + + attachmentContentTypeArray[actionIndex] = attachment.contentType; + attachmentUrlArray[actionIndex] = attachment.url; + attachmentDisplayUrlArray[actionIndex] = attachment.displayUrl; + attachmentDisplayNameArray[actionIndex] = encodeURI(attachment.name); + attachmentMessageUriArray[actionIndex] = attachment.uri; + ++actionIndex; + } + + // The list has been built. Now call our action code... + switch (action) { + case "save": + top.messenger.saveAllAttachments( + attachmentContentTypeArray, + attachmentUrlArray, + attachmentDisplayNameArray, + attachmentMessageUriArray + ); + return; + case "detach": + // "detach" on a multiple selection of attachments is so far not really + // supported. As a workaround, resort to normal detach-"all". See also + // the comment on 'detaching a multiple selection of attachments' below. + if (attachments.length == 1) { + attachments[0].detach(top.messenger, true); + } else { + top.messenger.detachAllAttachments( + attachmentContentTypeArray, + attachmentUrlArray, + attachmentDisplayNameArray, + attachmentMessageUriArray, + true // save + ); + } + return; + case "delete": + top.messenger.detachAllAttachments( + attachmentContentTypeArray, + attachmentUrlArray, + attachmentDisplayNameArray, + attachmentMessageUriArray, + false // don't save + ); + return; + case "open": + // XXX hack alert. If we sit in tight loop and open multiple + // attachments, we get chrome errors in layout as we start loading the + // first helper app dialog then before it loads, we kick off the next + // one and the next one. Subsequent helper app dialogs were failing + // because we were still loading the chrome files for the first attempt + // (error about the xul cache being empty). For now, work around this by + // doing the first helper app dialog right away, then waiting a bit + // before we launch the rest. + let actionFunction = function (aAttachment) { + aAttachment.open(getMessagePaneBrowser().browsingContext); + }; + + for (let i = 0; i < attachments.length; i++) { + if (i == 0) { + actionFunction(attachments[i]); + } else { + setTimeout(actionFunction, 100, attachments[i]); + } + } + return; + case "saveAs": + // Show one save dialog at a time, which allows to adjust the file name + // and folder path for each attachment. For added convenience, we remember + // the folder path of each file for the save dialog of the next one. + let saveAttachments = function (attachments) { + if (attachments.length > 0) { + attachments[0].save(top.messenger).then(function () { + saveAttachments(attachments.slice(1)); + }); + } + }; + + saveAttachments(attachments); + return; + case "copyUrl": + // Copy external http url(s) to clipboard. The menuitem is hidden unless + // all selected attachment urls are http. + navigator.clipboard.writeText(attachmentDisplayUrlArray.join("\n")); + return; + case "openFolder": + for (let attachment of attachments) { + setTimeout(() => attachment.openFolder()); + } + return; + default: + throw new Error("unknown HandleMultipleAttachments action: " + action); + } +} + +/** + * Link attachments are passed as an array of AttachmentInfo objects. This + * is meant to download http link content using the browser method. + * + * @param {AttachmentInfo[]} aAttachmentInfoArray - Array of attachmentInfo. + */ +async function saveLinkAttachmentsToFile(aAttachmentInfoArray) { + for (let attachment of aAttachmentInfoArray) { + if (!attachment.hasFile || attachment.message != gMessage) { + continue; + } + + let empty = await attachment.isEmpty(); + if (empty) { + continue; + } + + // internalSave() is part of saveURL() internals... + internalSave( + attachment.url, // aURL, + null, // aOriginalUrl, + undefined, // aDocument, + attachment.name, // aDefaultFileName, + undefined, // aContentDisposition, + undefined, // aContentType, + undefined, // aShouldBypassCache, + undefined, // aFilePickerTitleKey, + undefined, // aChosenData, + undefined, // aReferrer, + undefined, // aCookieJarSettings, + document, // aInitiatingDocument, + undefined, // aSkipPrompt, + undefined, // aCacheKey, + undefined // aIsContentWindowPrivate + ); + } +} + +function ClearAttachmentList() { + // clear selection + var list = document.getElementById("attachmentList"); + list.clearSelection(); + + while (list.hasChildNodes()) { + list.lastChild.remove(); + } +} + +// See attachmentBucketDNDObserver, which should have the same logic. +let attachmentListDNDObserver = { + onDragStart(event) { + // NOTE: Starting a drag on an attachment item will normally also select + // the attachment item before this method is called. But this is not + // necessarily the case. E.g. holding Shift when starting the drag + // operation. When it isn't selected, we just don't transfer. + if (event.target.matches(".attachmentItem[selected]")) { + // Also transfer other selected attachment items. + let attachments = Array.from( + document.querySelectorAll("#attachmentList .attachmentItem[selected]"), + item => item.attachment + ); + setupDataTransfer(event, attachments); + } + event.stopPropagation(); + }, +}; + +let attachmentNameDNDObserver = { + onDragStart(event) { + let attachmentList = document.getElementById("attachmentList"); + setupDataTransfer(event, [attachmentList.getItemAtIndex(0).attachment]); + event.stopPropagation(); + }, +}; + +function onShowOtherActionsPopup() { + // Enable/disable the Open Conversation button. + let glodaEnabled = Services.prefs.getBoolPref( + "mailnews.database.global.indexer.enabled" + ); + + let openConversation = document.getElementById( + "otherActionsOpenConversation" + ); + // Check because this menuitem element is not present in messageWindow.xhtml. + if (openConversation) { + openConversation.disabled = !( + glodaEnabled && Gloda.isMessageIndexed(gMessage) + ); + } + + let isDummyMessage = !gViewWrapper.isSynthetic && !gMessage.folder; + let tagsItem = document.getElementById("otherActionsTag"); + let markAsReadItem = document.getElementById("markAsReadMenuItem"); + let markAsUnreadItem = document.getElementById("markAsUnreadMenuItem"); + + if (isDummyMessage) { + tagsItem.disabled = true; + markAsReadItem.disabled = true; + markAsReadItem.removeAttribute("hidden"); + markAsUnreadItem.setAttribute("hidden", true); + } else { + tagsItem.disabled = false; + markAsReadItem.disabled = false; + if (SelectedMessagesAreRead()) { + markAsReadItem.setAttribute("hidden", true); + markAsUnreadItem.removeAttribute("hidden"); + } else { + markAsReadItem.removeAttribute("hidden"); + markAsUnreadItem.setAttribute("hidden", true); + } + } + + document.getElementById("otherActions-calendar-convert-menu").hidden = + isDummyMessage || !calendarDeactivator.isCalendarActivated; + + // Check if the current message is feed or not. + let isFeed = FeedUtils.isFeedMessage(gMessage); + document.getElementById("otherActionsMessageBodyAs").hidden = isFeed; + document.getElementById("otherActionsFeedBodyAs").hidden = !isFeed; +} + +function InitOtherActionsViewBodyMenu() { + let html_as = Services.prefs.getIntPref("mailnews.display.html_as"); + let prefer_plaintext = Services.prefs.getBoolPref( + "mailnews.display.prefer_plaintext" + ); + let disallow_classes = Services.prefs.getIntPref( + "mailnews.display.disallow_mime_handlers" + ); + let isFeed = false; // TODO + const kDefaultIDs = [ + "otherActionsMenu_bodyAllowHTML", + "otherActionsMenu_bodySanitized", + "otherActionsMenu_bodyAsPlaintext", + "otherActionsMenu_bodyAllParts", + ]; + const kRssIDs = [ + "otherActionsMenu_bodyFeedSummaryAllowHTML", + "otherActionsMenu_bodyFeedSummarySanitized", + "otherActionsMenu_bodyFeedSummaryAsPlaintext", + ]; + let menuIDs = isFeed ? kRssIDs : kDefaultIDs; + + if (disallow_classes > 0) { + window.top.gDisallow_classes_no_html = disallow_classes; + } + // else gDisallow_classes_no_html keeps its initial value (see top) + + let AllowHTML_menuitem = document.getElementById(menuIDs[0]); + let Sanitized_menuitem = document.getElementById(menuIDs[1]); + let AsPlaintext_menuitem = document.getElementById(menuIDs[2]); + let AllBodyParts_menuitem = menuIDs[3] + ? document.getElementById(menuIDs[3]) + : null; + + document.getElementById("otherActionsMenu_bodyAllParts").hidden = + !Services.prefs.getBoolPref("mailnews.display.show_all_body_parts_menu"); + + // Clear all checkmarks. + AllowHTML_menuitem.removeAttribute("checked"); + Sanitized_menuitem.removeAttribute("checked"); + AsPlaintext_menuitem.removeAttribute("checked"); + if (AllBodyParts_menuitem) { + AllBodyParts_menuitem.removeAttribute("checked"); + } + + if ( + !prefer_plaintext && + !html_as && + !disallow_classes && + AllowHTML_menuitem + ) { + AllowHTML_menuitem.setAttribute("checked", true); + } else if ( + !prefer_plaintext && + html_as == 3 && + disallow_classes > 0 && + Sanitized_menuitem + ) { + Sanitized_menuitem.setAttribute("checked", true); + } else if ( + prefer_plaintext && + html_as == 1 && + disallow_classes > 0 && + AsPlaintext_menuitem + ) { + AsPlaintext_menuitem.setAttribute("checked", true); + } else if ( + !prefer_plaintext && + html_as == 4 && + !disallow_classes && + AllBodyParts_menuitem + ) { + AllBodyParts_menuitem.setAttribute("checked", true); + } + // else (the user edited prefs/user.js) check none of the radio menu items + + if (isFeed) { + AllowHTML_menuitem.hidden = !gShowFeedSummary; + Sanitized_menuitem.hidden = !gShowFeedSummary; + AsPlaintext_menuitem.hidden = !gShowFeedSummary; + document.getElementById( + "otherActionsMenu_viewFeedSummarySeparator" + ).hidden = !gShowFeedSummary; + } +} + +/** + * Object literal to handle a few simple customization options for the message + * header. + */ +const gHeaderCustomize = { + docURL: "chrome://messenger/content/messenger.xhtml", + /** + * The DOM element panel collecting all customization options. + * + * @type {XULElement} + */ + customizePanel: null, + /** + * The object storing all saved customization options. + * + * @note Any keys added to this object should also be added to the telemetry + * scalar tb.ui.configuration.message_header. + * + * @type {object} + * @property {boolean} showAvatar - If the profile picture of the sender + * should be shown. + * @property {boolean} showBigAvatar - If a big profile picture of the sender + * should be shown. + * @property {boolean} showFullAddress - If the sender should always be + * shown with the full name and email address. + * @property {boolean} hideLabels - If the labels column should be hidden. + * @property {boolean} subjectLarge - If the font size of the subject line + * should be increased. + * @property {string} buttonStyle - The style in which the buttons should be + * rendered: + * - "default" = icons+text + * - "only-icons" = only icons + * - "only-text" = only text + */ + customizeData: { + showAvatar: true, + showBigAvatar: false, + showFullAddress: true, + hideLabels: true, + subjectLarge: true, + buttonStyle: "default", + }, + + /** + * Initialize the customizer. + */ + init() { + this.customizePanel = document.getElementById( + "messageHeaderCustomizationPanel" + ); + + if (Services.xulStore.hasValue(this.docURL, "messageHeader", "layout")) { + this.customizeData = JSON.parse( + Services.xulStore.getValue(this.docURL, "messageHeader", "layout") + ); + this.updateLayout(); + } + }, + + /** + * Reset and update the customized style of the message header. + */ + updateLayout() { + let header = document.getElementById("messageHeader"); + // Always clear existing styles to avoid visual issues. + header.classList.remove( + "message-header-large-subject", + "message-header-buttons-only-icons", + "message-header-buttons-only-text", + "message-header-hide-label-column" + ); + + // Bail out if we don't have anything to customize. + if (!Object.keys(this.customizeData).length) { + header.classList.add( + "message-header-large-subject", + "message-header-show-recipient-avatar", + "message-header-show-sender-full-address", + "message-header-hide-label-column" + ); + return; + } + + header.classList.toggle( + "message-header-large-subject", + this.customizeData.subjectLarge || false + ); + + header.classList.toggle( + "message-header-hide-label-column", + this.customizeData.hideLabels || false + ); + + header.classList.toggle( + "message-header-show-recipient-avatar", + this.customizeData.showAvatar || false + ); + + header.classList.toggle( + "message-header-show-big-avatar", + this.customizeData.showBigAvatar || false + ); + + header.classList.toggle( + "message-header-show-sender-full-address", + this.customizeData.showFullAddress || false + ); + + switch (this.customizeData.buttonStyle) { + case "only-icons": + case "only-text": + header.classList.add( + `message-header-buttons-${this.customizeData.buttonStyle}` + ); + break; + + case "default": + default: + header.classList.remove( + "message-header-buttons-only-icons", + "message-header-buttons-only-text" + ); + break; + } + + gMessageHeader.syncLabelsColumnWidths(); + }, + + /** + * Show the customization panel for the message header. + */ + showPanel() { + this.customizePanel.openPopup( + document.getElementById("otherActionsButton"), + "after_end", + 6, + 6, + false + ); + }, + + /** + * Update the panel's elements to reflect the users' customization. + */ + onPanelShowing() { + document.getElementById("headerButtonStyle").value = + this.customizeData.buttonStyle || "default"; + + document.getElementById("headerShowAvatar").checked = + this.customizeData.showAvatar || false; + + document.getElementById("headerShowBigAvatar").checked = + this.customizeData.showBigAvatar || false; + + document.getElementById("headerShowFullAddress").checked = + this.customizeData.showFullAddress || false; + + document.getElementById("headerHideLabels").checked = + this.customizeData.hideLabels || false; + + document.getElementById("headerSubjectLarge").checked = + this.customizeData.subjectLarge || false; + + let type = Ci.nsMimeHeaderDisplayTypes; + let pref = Services.prefs.getIntPref("mail.show_headers"); + + document.getElementById("headerViewAllHeaders").checked = + type.AllHeaders == pref; + }, + + /** + * Update the buttons style when the menuitem value is changed. + * + * @param {Event} event - The menuitem command event. + */ + updateButtonStyle(event) { + this.customizeData.buttonStyle = event.target.value; + this.updateLayout(); + }, + + /** + * Show or hide the profile picture of the sender recipient. + * + * @param {Event} event - The checkbox command event. + */ + toggleAvatar(event) { + const isChecked = event.target.checked; + this.customizeData.showAvatar = isChecked; + document.getElementById("headerShowBigAvatar").disabled = !isChecked; + this.updateLayout(); + }, + + /** + * Show big or small profile picture of the sender recipient. + * + * @param {Event} event - The checkbox command event. + */ + toggleBigAvatar(event) { + this.customizeData.showBigAvatar = event.target.checked; + this.updateLayout(); + }, + + /** + * Show or hide the sender's full address, which will show the display name + * and the email address on two different lines. + * + * @param {Event} event - The checkbox command event. + */ + toggleSenderAddress(event) { + this.customizeData.showFullAddress = event.target.checked; + this.updateLayout(); + }, + + /** + * Show or hide the labels column. + * + * @param {Event} event - The checkbox command event. + */ + toggleLabelColumn(event) { + this.customizeData.hideLabels = event.target.checked; + this.updateLayout(); + }, + + /** + * Update the subject style when the checkbox is clicked. + * + * @param {Event} event - The checkbox command event. + */ + updateSubjectStyle(event) { + this.customizeData.subjectLarge = event.target.checked; + this.updateLayout(); + }, + + /** + * Show or hide all the headers of a message. + * + * @param {Event} event - The checkbox command event. + */ + toggleAllHeaders(event) { + let mode = event.target.checked + ? Ci.nsMimeHeaderDisplayTypes.AllHeaders + : Ci.nsMimeHeaderDisplayTypes.NormalHeaders; + Services.prefs.setIntPref("mail.show_headers", mode); + AdjustHeaderView(mode); + ReloadMessage(); + }, + + /** + * Close the customize panel. + */ + closePanel() { + this.customizePanel.hidePopup(); + }, + + /** + * Update the xulStore only when the panel is closed. + */ + onPanelHidden() { + Services.xulStore.setValue( + this.docURL, + "messageHeader", + "layout", + JSON.stringify(this.customizeData) + ); + }, +}; + +/** + * Object to handle the creation, destruction, and update of all recipient + * fields that will be showed in the message header. + */ +const gMessageHeader = { + /** + * Get the newsgroup server corresponding to the currently selected message. + * + * @returns {?nsISubscribableServer} The server for the newsgroup, or null. + */ + get newsgroupServer() { + if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) { + return gFolder.server?.QueryInterface(Ci.nsISubscribableServer); + } + + return null; + }, + + /** + * Toggle the scrollable style of the message header area. + * + * @param {boolean} showAllHeaders - True if we need to show all header fields + * and ignore the space limit for multi recipients row. + */ + toggleScrollableHeader(showAllHeaders) { + document + .getElementById("messageHeader") + .classList.toggle("scrollable", showAllHeaders); + }, + + /** + * Ensure that the all visible labels have the same size. + */ + syncLabelsColumnWidths() { + let allHeaderLabels = document.querySelectorAll( + ".message-header-row:not([hidden]) .message-header-label" + ); + + // Clear existing style. + for (let label of allHeaderLabels) { + label.style.minWidth = null; + } + + let minWidth = Math.max(...Array.from(allHeaderLabels, i => i.clientWidth)); + for (let label of allHeaderLabels) { + label.style.minWidth = `${minWidth}px`; + } + }, + + openCopyPopup(event, element) { + document.getElementById("copyCreateFilterFrom").disabled = + !gFolder?.server.canHaveFilters; + + let popup = document.getElementById( + element.matches(`:scope[is="url-header-row"]`) + ? "copyUrlPopup" + : "copyPopup" + ); + popup.headerField = element; + popup.openPopupAtScreen(event.screenX, event.screenY, true); + }, + + async openEmailAddressPopup(event, element) { + // Bail out if we don't have an email address. + if (!element.emailAddress) { + return; + } + + document + .getElementById("emailAddressPlaceHolder") + .setAttribute("label", element.emailAddress); + + document.getElementById("addToAddressBookItem").hidden = + element.cardDetails.card; + document.getElementById("editContactItem").hidden = + !element.cardDetails.card || element.cardDetails.book?.readOnly; + document.getElementById("viewContactItem").hidden = + !element.cardDetails.card || !element.cardDetails.book?.readOnly; + + let discoverKeyMenuItem = document.getElementById("searchKeysOpenPGP"); + if (discoverKeyMenuItem) { + let hidden = await PgpSqliteDb2.hasAnyPositivelyAcceptedKeyForEmail( + element.emailAddress + ); + discoverKeyMenuItem.hidden = hidden; + discoverKeyMenuItem.nextElementSibling.hidden = hidden; // Hide separator. + } + + document.getElementById("createFilterFrom").disabled = + !gFolder?.server.canHaveFilters; + + let popup = document.getElementById("emailAddressPopup"); + popup.headerField = element; + + if (!event.screenX) { + popup.openPopup(event.target, "after_start", 0, 0, true); + return; + } + + popup.openPopupAtScreen(event.screenX, event.screenY, true); + }, + + openNewsgroupPopup(event, element) { + document + .getElementById("newsgroupPlaceHolder") + .setAttribute("label", element.textContent); + + let subscribed = this.newsgroupServer + ?.QueryInterface(Ci.nsINntpIncomingServer) + .containsNewsgroup(element.textContent); + document.getElementById("subscribeToNewsgroupItem").hidden = subscribed; + document.getElementById("subscribeToNewsgroupSeparator").hidden = + subscribed; + + let popup = document.getElementById("newsgroupPopup"); + popup.headerField = element; + + if (!event.screenX) { + popup.openPopup(event.target, "after_start", 0, 0, true); + return; + } + + popup.openPopupAtScreen(event.screenX, event.screenY, true); + }, + + openMessageIdPopup(event, element) { + document + .getElementById("messageIdContext-messageIdTarget") + .setAttribute("label", element.id); + + // We don't want to show "Open Message For ID" for the same message + // we're viewing. + document.getElementById("messageIdContext-openMessageForMsgId").hidden = + `<${gMessage.messageId}>` == element.id; + + // We don't want to show "Open Browser With Message-ID" for non-nntp + // messages. + document.getElementById("messageIdContext-openBrowserWithMsgId").hidden = + !gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false); + + let popup = document.getElementById("messageIdContext"); + popup.headerField = element; + + if (!event.screenX) { + popup.openPopup(event.target, "after_start", 0, 0, true); + return; + } + + popup.openPopupAtScreen(event.screenX, event.screenY, true); + }, + + /** + * Add a contact to the address book. + * + * @param {Event} event - The DOM Event. + */ + addContact(event) { + event.currentTarget.parentNode.headerField.addToAddressBook(); + }, + + /** + * Show the edit card popup panel. + * + * @param {Event} event - The DOM Event. + */ + showContactEdit(event) { + this.editContact(event.currentTarget.parentNode.headerField); + }, + + /** + * Trigger a new message compose window. + * + * @param {Event} event - The click DOMEvent. + */ + composeMessage(event) { + let recipient = event.currentTarget.parentNode.headerField; + + let fields = Cc[ + "@mozilla.org/messengercompose/composefields;1" + ].createInstance(Ci.nsIMsgCompFields); + + if (recipient.classList.contains("header-newsgroup")) { + fields.newsgroups = recipient.textContent; + } + + if (recipient.fullAddress) { + let addresses = MailServices.headerParser.makeFromDisplayAddress( + recipient.fullAddress + ); + if (addresses.length) { + fields.to = MailServices.headerParser.makeMimeHeader([addresses[0]]); + } + } + + let params = Cc[ + "@mozilla.org/messengercompose/composeparams;1" + ].createInstance(Ci.nsIMsgComposeParams); + params.type = Ci.nsIMsgCompType.New; + + // If the Shift key was pressed toggle the composition format + // (HTML vs. plaintext). + params.format = event.shiftKey + ? Ci.nsIMsgCompFormat.OppositeOfDefault + : Ci.nsIMsgCompFormat.Default; + + if (gFolder) { + params.identity = MailServices.accounts.getFirstIdentityForServer( + gFolder.server + ); + } + params.composeFields = fields; + MailServices.compose.OpenComposeWindowWithParams(null, params); + }, + + /** + * Copy the email address, as well as the name if wanted, in the clipboard. + * + * @param {Event} event - The DOM Event. + * @param {boolean} withName - True if we need to copy also the name. + */ + copyAddress(event, withName = false) { + let recipient = event.currentTarget.parentNode.headerField; + let address; + if (recipient.classList.contains("header-newsgroup")) { + address = recipient.textContent; + } else { + address = withName ? recipient.fullAddress : recipient.emailAddress; + } + navigator.clipboard.writeText(address); + }, + + copyNewsgroupURL(event) { + let server = this.newsgroupServer; + if (!server) { + return; + } + + let newsgroup = event.currentTarget.parentNode.headerField.textContent; + + let url; + if (server.socketType != Ci.nsMsgSocketType.SSL) { + url = "news://" + server.hostName; + if (server.port != Ci.nsINntpUrl.DEFAULT_NNTP_PORT) { + url += ":" + server.port; + } + url += "/" + newsgroup; + } else { + url = "snews://" + server.hostName; + if (server.port != Ci.nsINntpUrl.DEFAULT_NNTPS_PORT) { + url += ":" + server.port; + } + url += "/" + newsgroup; + } + + try { + let uri = Services.io.newURI(url); + navigator.clipboard.writeText(decodeURI(uri.spec)); + } catch (e) { + console.error("Invalid URL: " + url); + } + }, + + /** + * Subscribe to a newsgroup. + * + * @param {Event} event - The DOM Event. + */ + subscribeToNewsgroup(event) { + let server = this.newsgroupServer; + if (server) { + let newsgroup = event.currentTarget.parentNode.headerField.textContent; + server.subscribe(newsgroup); + server.commitSubscribeChanges(); + } + }, + + /** + * Copy the text value of an header field. + * + * @param {Event} event - The DOM Event. + */ + copyString(event) { + // This method is used inside the copyPopup menupopup, which is triggered by + // both HTML headers fields and XUL labels. We need to account for those + // different widgets in order to properly copy the text. + let target = + event.currentTarget.parentNode.triggerNode || + event.currentTarget.parentNode.headerField; + navigator.clipboard.writeText( + window.getSelection().isCollapsed + ? target.textContent + : window.getSelection().toString() + ); + }, + + /** + * Open the message filter dialog prefilled with available data. + * + * @param {Event} event - The DOM Event. + */ + createFilter(event) { + let element = event.currentTarget.parentNode.headerField; + top.MsgFilters( + element.emailAddress || element.value.textContent, + gFolder, + element.dataset.headerName + ); + }, + + /** + * Show the edit contact popup panel. + * + * @param {HTMLLIElement} element - The recipient element. + */ + editContact(element) { + editContactInlineUI.showEditContactPanel(element.cardDetails, element); + }, + + /** + * Set the tags to the message header tag element. + */ + setTags() { + // Bail out if we don't have a message selected. + if (!gMessage || !gFolder) { + return; + } + + // Extract the tag keys from the message header. + let msgKeyArray = gMessage.getStringProperty("keywords").split(" "); + + // Get the list of known tags. + let tagsArray = MailServices.tags.getAllTags().filter(t => t.tag); + let tagKeys = {}; + for (let tagInfo of tagsArray) { + tagKeys[tagInfo.key] = true; + } + // Only use tags that match our saved tags. + let msgKeys = msgKeyArray.filter(k => k in tagKeys); + + if (msgKeys.length) { + currentHeaderData.tags = { + headerName: "tags", + headerValue: msgKeys.join(" "), + }; + return; + } + + // No more tags, so clear out the header field. + delete currentHeaderData.tags; + }, + + onMessageIdClick(event) { + let id = event.currentTarget.closest(".header-message-id").id; + if (event.button == 0) { + // Remove the < and > symbols. + OpenMessageForMessageId(id.substring(1, id.length - 1)); + } + }, + + openMessage(event) { + let id = event.currentTarget.parentNode.headerField.id; + // Remove the < and > symbols. + OpenMessageForMessageId(id.substring(1, id.length - 1)); + }, + + openBrowser(event) { + let id = event.currentTarget.parentNode.headerField.id; + // Remove the < and > symbols. + OpenBrowserWithMessageId(id.substring(1, id.length - 1)); + }, + + copyMessageId(event) { + navigator.clipboard.writeText( + event.currentTarget.parentNode.headerField.id + ); + }, + + copyWebsiteUrl(event) { + navigator.clipboard.writeText( + event.currentTarget.parentNode.headerField.value.textContent + ); + }, +}; + +function MarkSelectedMessagesRead(markRead) { + ClearPendingReadTimer(); + gDBView.doCommand( + markRead + ? Ci.nsMsgViewCommandType.markMessagesRead + : Ci.nsMsgViewCommandType.markMessagesUnread + ); + if (markRead) { + reportMsgRead({ isNewRead: true }); + } +} + +function MarkSelectedMessagesFlagged(markFlagged) { + gDBView.doCommand( + markFlagged + ? Ci.nsMsgViewCommandType.flagMessages + : Ci.nsMsgViewCommandType.unflagMessages + ); +} + +/** + * Take the message id from the messageIdNode and use the url defined in the + * hidden pref "mailnews.messageid_browser.url" to open it in a browser window + * (%mid is replaced by the message id). + * @param {string} messageId - The message id to open. + */ +function OpenBrowserWithMessageId(messageId) { + var browserURL = Services.prefs.getComplexValue( + "mailnews.messageid_browser.url", + Ci.nsIPrefLocalizedString + ).data; + browserURL = browserURL.replace(/%mid/, messageId); + try { + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(browserURL)); + } catch (ex) { + console.error( + "Failed to open message-id in browser; browserURL=" + browserURL + ); + } +} + +/** + * Take the message id from the messageIdNode, search for the corresponding + * message in all folders starting with the current selected folder, then the + * current account followed by the other accounts and open corresponding + * message if found. + * @param {string} messageId - The message id to open. + */ +function OpenMessageForMessageId(messageId) { + let startServer = gFolder?.server; + + window.setCursor("wait"); + let msgHdr = MailUtils.getMsgHdrForMsgId(messageId, startServer); + window.setCursor("auto"); + + // If message was found open corresponding message. + if (msgHdr) { + if (parent.location == "about:3pane") { + // Message in 3pane. + parent.selectMessage(msgHdr); + } else { + // Message in tab, standalone message window. + let uri = msgHdr.folder.getUriForMsg(msgHdr); + window.displayMessage(uri); + } + return; + } + let messageIdStr = "<" + messageId + ">"; + let bundle = document.getElementById("bundle_messenger"); + let errorTitle = bundle.getString("errorOpenMessageForMessageIdTitle"); + let errorMessage = bundle.getFormattedString( + "errorOpenMessageForMessageIdMessage", + [messageIdStr] + ); + Services.prompt.alert(window, errorTitle, errorMessage); +} + +/** + * @param headermode {Ci.nsMimeHeaderDisplayTypes} + */ +function AdjustHeaderView(headermode) { + const all = Ci.nsMimeHeaderDisplayTypes.AllHeaders; + document + .getElementById("messageHeader") + .setAttribute("show_header_mode", headermode == all ? "all" : "normal"); +} + +/** + * Should the reply command/button be enabled? + * + * @return whether the reply command/button should be enabled. + */ +function IsReplyEnabled() { + // If we're in an rss item, we never want to Reply, because there's + // usually no-one useful to reply to. + return !FeedUtils.isFeedMessage(gMessage); +} + +/** + * Should the reply-all command/button be enabled? + * + * @return whether the reply-all command/button should be enabled. + */ +function IsReplyAllEnabled() { + if (gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) { + // If we're in a news item, we always want ReplyAll, because we can + // reply to the sender and the newsgroup. + return true; + } + if (FeedUtils.isFeedMessage(gMessage)) { + // If we're in an rss item, we never want to ReplyAll, because there's + // usually no-one useful to reply to. + return false; + } + + let addresses = + gMessage.author + "," + gMessage.recipients + "," + gMessage.ccList; + + // If we've got any BCCed addresses (because we sent the message), add + // them as well. + if ("bcc" in currentHeaderData) { + addresses += currentHeaderData.bcc.headerValue; + } + + // Check to see if my email address is in the list of addresses. + let [myIdentity] = MailUtils.getIdentityForHeader(gMessage); + let myEmail = myIdentity ? myIdentity.email : null; + // We aren't guaranteed to have an email address, so guard against that. + let imInAddresses = + myEmail && addresses.toLowerCase().includes(myEmail.toLowerCase()); + + // Now, let's get the number of unique addresses. + let uniqueAddresses = MailServices.headerParser.removeDuplicateAddresses( + addresses, + "" + ); + let numAddresses = + MailServices.headerParser.parseEncodedHeader(uniqueAddresses).length; + + // I don't want to count my address in the number of addresses to reply + // to, since I won't be emailing myself. + if (imInAddresses) { + numAddresses--; + } + + // ReplyAll is enabled if there is more than 1 person to reply to. + return numAddresses > 1; +} + +/** + * Should the reply-list command/button be enabled? + * + * @return whether the reply-list command/button should be enabled. + */ +function IsReplyListEnabled() { + // ReplyToList is enabled if there is a List-Post header + // with the correct format. + let listPost = currentHeaderData["list-post"]; + if (!listPost) { + return false; + } + + // XXX: Once Bug 496914 provides a parser, we should use that instead. + // Until then, we need to keep the following regex in sync with the + // listPost parsing in nsMsgCompose.cpp's + // QuotingOutputStreamListener::OnStopRequest. + return /<mailto:.+>/.test(listPost.headerValue); +} + +/** + * Update the enabled/disabled states of the Reply, Reply-All, and + * Reply-List buttons. (After this function runs, one of the buttons + * should be shown, and the others should be hidden.) + */ +function UpdateReplyButtons() { + // If we have no message, because we're being called from + // MailToolboxCustomizeDone before someone selected a message, then just + // return. + if (!gMessage) { + return; + } + + let buttonToShow; + if (gFolder?.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) { + // News messages always default to the "followup" dual-button. + buttonToShow = "followup"; + } else if (FeedUtils.isFeedMessage(gMessage)) { + // RSS items hide all the reply buttons. + buttonToShow = null; + } else if (IsReplyListEnabled()) { + // Mail messages show the "reply" button (not the dual-button) and + // possibly the "reply all" and "reply list" buttons. + buttonToShow = "replyList"; + } else if (IsReplyAllEnabled()) { + buttonToShow = "replyAll"; + } else { + buttonToShow = "reply"; + } + + let smartReplyButton = document.getElementById("hdrSmartReplyButton"); + if (smartReplyButton) { + let replyButton = document.getElementById("hdrReplyButton"); + let replyAllButton = document.getElementById("hdrReplyAllButton"); + let replyListButton = document.getElementById("hdrReplyListButton"); + let followupButton = document.getElementById("hdrFollowupButton"); + + replyButton.hidden = buttonToShow != "reply"; + replyAllButton.hidden = buttonToShow != "replyAll"; + replyListButton.hidden = buttonToShow != "replyList"; + followupButton.hidden = buttonToShow != "followup"; + } + + let replyToSenderButton = document.getElementById("hdrReplyToSenderButton"); + if (replyToSenderButton) { + if (FeedUtils.isFeedMessage(gMessage)) { + replyToSenderButton.hidden = true; + } else if (smartReplyButton) { + replyToSenderButton.hidden = buttonToShow == "reply"; + } else { + replyToSenderButton.hidden = false; + } + } + + // Run this method only after all the header toolbar buttons have been updated + // so we deal with the actual state. + headerToolbarNavigation.updateRovingTab(); +} + +/** + * Update the enabled/disabled states of the Reply, Reply-All, Reply-List, + * Followup, and Forward buttons based on the number of identities. + * If there are no identities, all of these buttons should be disabled. + */ +function updateComposeButtons() { + const hasIdentities = MailServices.accounts.allIdentities.length; + for (let id of [ + "hdrReplyButton", + "hdrReplyAllButton", + "hdrReplyListButton", + "hdrFollowupButton", + "hdrForwardButton", + "hdrReplyToSenderButton", + ]) { + document.getElementById(id).disabled = !hasIdentities; + } +} + +function SelectedMessagesAreJunk() { + try { + let junkScore = gMessage.getStringProperty("junkscore"); + return junkScore != "" && junkScore != "0"; + } catch (ex) { + return false; + } +} + +function SelectedMessagesAreRead() { + return gMessage?.isRead; +} + +function SelectedMessagesAreFlagged() { + return gMessage?.isFlagged; +} + +function MsgReplyMessage(event) { + if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false)) { + MsgReplyGroup(event); + } else { + MsgReplySender(event); + } +} + +function MsgReplySender(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToSender, event); +} + +function MsgReplyGroup(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToGroup, event); +} + +function MsgReplyToAllMessage(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyAll, event); +} + +function MsgReplyToListMessage(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ReplyToList, event); +} + +function MsgForwardMessage(event) { + var forwardType = Services.prefs.getIntPref("mail.forward_message_mode", 0); + + // mail.forward_message_mode could be 1, if the user migrated from 4.x + // 1 (forward as quoted) is obsolete, so we treat is as forward inline + // since that is more like forward as quoted then forward as attachment + if (forwardType == 0) { + MsgForwardAsAttachment(event); + } else { + MsgForwardAsInline(event); + } +} + +function MsgForwardAsAttachment(event) { + commandController._composeMsgByType( + Ci.nsIMsgCompType.ForwardAsAttachment, + event + ); +} + +function MsgForwardAsInline(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.ForwardInline, event); +} + +function MsgRedirectMessage(event) { + commandController._composeMsgByType(Ci.nsIMsgCompType.Redirect, event); +} + +function MsgEditMessageAsNew(aEvent) { + commandController._composeMsgByType(Ci.nsIMsgCompType.EditAsNew, aEvent); +} + +function MsgEditDraftMessage(aEvent) { + commandController._composeMsgByType(Ci.nsIMsgCompType.Draft, aEvent); +} + +function MsgNewMessageFromTemplate(aEvent) { + commandController._composeMsgByType(Ci.nsIMsgCompType.Template, aEvent); +} + +function MsgEditTemplateMessage(aEvent) { + commandController._composeMsgByType(Ci.nsIMsgCompType.EditTemplate, aEvent); +} + +function MsgComposeDraftMessage() { + top.ComposeMessage( + Ci.nsIMsgCompType.Draft, + Ci.nsIMsgCompFormat.Default, + gFolder, + [gMessageURI] + ); +} + +/** + * Update the "archive", "junk" and "delete" buttons in the message header area. + */ +function updateHeaderToolbarButtons() { + let isDummyMessage = !gViewWrapper.isSynthetic && !gMessage.folder; + let archiveButton = document.getElementById("hdrArchiveButton"); + let junkButton = document.getElementById("hdrJunkButton"); + let trashButton = document.getElementById("hdrTrashButton"); + + if (isDummyMessage) { + archiveButton.disabled = true; + junkButton.disabled = true; + trashButton.disabled = true; + return; + } + + archiveButton.disabled = !MessageArchiver.canArchive([gMessage]); + let junkScore = gMessage.getStringProperty("junkscore"); + let hideJunk = junkScore == Ci.nsIJunkMailPlugin.IS_SPAM_SCORE; + if (!commandController._getViewCommandStatus(Ci.nsMsgViewCommandType.junk)) { + hideJunk = true; + } + junkButton.disabled = hideJunk; + trashButton.disabled = false; +} + +/** + * Checks if the selected messages can be marked as read or unread + * + * @param markingRead true if trying to mark messages as read, false otherwise + * @return true if the chosen operation can be performed + */ +function CanMarkMsgAsRead(markingRead) { + return gMessage && SelectedMessagesAreRead() != markingRead; +} + +/** + * Marks the selected messages as read or unread + * + * @param read true if trying to mark messages as read, false if marking unread, + * undefined if toggling the read status + */ +function MsgMarkMsgAsRead(read) { + if (read == undefined) { + read = !gMessage.isRead; + } + MarkSelectedMessagesRead(read); +} + +function MsgMarkAsFlagged() { + MarkSelectedMessagesFlagged(!SelectedMessagesAreFlagged()); +} + +/** + * Extract email data and prefill the event/task dialog with that data. + */ +function convertToEventOrTask(isTask = false) { + window.top.calendarExtract.extractFromEmail(gMessage, isTask); +} + +/** + * Triggered by the onHdrPropertyChanged notification for a single message being + * displayed. We handle updating the message display if our displayed message + * might have had its junk status change. This primarily entails updating the + * notification bar (that thing that appears above the message and says "this + * message might be junk") and (potentially) reloading the message because junk + * status affects the form of HTML display used (sanitized vs not). + * When our tab implementation is no longer multiplexed (reusing the same + * display widget), this must be moved into the MessageDisplayWidget or + * otherwise be scoped to the tab. + * + * @param {nsIMsgHdr} msgHdr - The nsIMsgHdr of the message with a junk status change. + */ +function HandleJunkStatusChanged(msgHdr) { + if (!msgHdr || !msgHdr.folder) { + return; + } + + let junkBarStatus = gMessageNotificationBar.checkJunkMsgStatus(msgHdr); + + // Only reload message if junk bar display state is changing and only if the + // reload is really needed. + if (junkBarStatus != 0) { + // We may be forcing junk mail to be rendered with sanitized html. + // In that scenario, we want to reload the message if the status has just + // changed to not junk. + var sanitizeJunkMail = Services.prefs.getBoolPref( + "mail.spam.display.sanitize" + ); + + // Only bother doing this if we are modifying the html for junk mail.... + if (sanitizeJunkMail) { + let junkScore = msgHdr.getStringProperty("junkscore"); + let isJunk = junkScore == Ci.nsIJunkMailPlugin.IS_SPAM_SCORE; + + // If the current row isn't going to change, reload to show sanitized or + // unsanitized. Otherwise we wouldn't see the reloaded version anyway. + // 1) When marking as non-junk from the Junk folder, the msg would move + // back to the Inbox -> no reload needed + // When marking as non-junk from a folder other than the Junk folder, + // the message isn't moved back to Inbox -> reload needed + // (see nsMsgDBView::DetermineActionsForJunkChange) + // 2) When marking as junk, the msg will move or delete, if manualMark is set. + // 3) Marking as junk in the junk folder just changes the junk status. + if ( + (!isJunk && !msgHdr.folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk)) || + (isJunk && !msgHdr.folder.server.spamSettings.manualMark) || + (isJunk && msgHdr.folder.isSpecialFolder(Ci.nsMsgFolderFlags.Junk)) + ) { + ReloadMessage(); + return; + } + } + } + + gMessageNotificationBar.setJunkMsg(msgHdr); +} + +/** + * Object to handle message related notifications that are showing in a + * notificationbox above the message content. + */ +var gMessageNotificationBar = { + get stringBundle() { + delete this.stringBundle; + return (this.stringBundle = document.getElementById("bundle_messenger")); + }, + + get brandBundle() { + delete this.brandBundle; + return (this.brandBundle = document.getElementById("bundle_brand")); + }, + + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "top"); + document.getElementById("mail-notification-top").append(element); + }); + } + return this._notificationBox; + }, + + /** + * Check if the current status of the junk notification is correct or not. + * + * @param {nsIMsgDBHdr} aMsgHdr - Information about the message + * @returns {integer} Tri-state status information. + * 1: notification is missing + * 0: notification is correct + * -1: notification must be removed + */ + checkJunkMsgStatus(aMsgHdr) { + let junkScore = aMsgHdr ? aMsgHdr.getStringProperty("junkscore") : ""; + let junkStatus = this.isShowingJunkNotification(); + + if (junkScore == "" || junkScore == Ci.nsIJunkMailPlugin.IS_HAM_SCORE) { + // This is not junk. The notification should not be shown. + return junkStatus ? -1 : 0; + } + + // This is junk. The notification should be shown. + return junkStatus ? 0 : 1; + }, + + setJunkMsg(aMsgHdr) { + goUpdateCommand("cmd_junk"); + + let junkBarStatus = this.checkJunkMsgStatus(aMsgHdr); + if (junkBarStatus == -1) { + this.msgNotificationBar.removeNotification( + this.msgNotificationBar.getNotificationWithValue("junkContent"), + true + ); + } else if (junkBarStatus == 1) { + let brandName = this.brandBundle.getString("brandShortName"); + let junkBarMsg = this.stringBundle.getFormattedString("junkBarMessage", [ + brandName, + ]); + + let buttons = [ + { + label: this.stringBundle.getString("junkBarInfoButton"), + accessKey: this.stringBundle.getString("junkBarInfoButtonKey"), + popup: null, + callback(aNotification, aButton) { + // TODO: This doesn't work in a message window. + top.openContentTab( + "https://support.mozilla.org/kb/thunderbird-and-junk-spam-messages" + ); + return true; // keep notification open + }, + }, + { + label: this.stringBundle.getString("junkBarButton"), + accessKey: this.stringBundle.getString("junkBarButtonKey"), + popup: null, + callback(aNotification, aButton) { + commandController.doCommand("cmd_markAsNotJunk"); + // Return true (=don't close) since changing junk status will fire a + // JunkStatusChanged notification which will make the junk bar go away + // for this message -> no notification to close anymore -> trying to + // close would just fail. + return true; + }, + }, + ]; + + this.msgNotificationBar.appendNotification( + "junkContent", + { + label: junkBarMsg, + image: "chrome://messenger/skin/icons/junk.svg", + priority: this.msgNotificationBar.PRIORITY_WARNING_HIGH, + }, + buttons + ); + } + }, + + isShowingJunkNotification() { + return !!this.msgNotificationBar.getNotificationWithValue("junkContent"); + }, + + setRemoteContentMsg(aMsgHdr, aContentURI, aCanOverride) { + // update the allow remote content for sender string + let brandName = this.brandBundle.getString("brandShortName"); + let remoteContentMsg = this.stringBundle.getFormattedString( + "remoteContentBarMessage", + [brandName] + ); + + let buttonLabel = this.stringBundle.getString( + AppConstants.platform == "win" + ? "remoteContentPrefLabel" + : "remoteContentPrefLabelUnix" + ); + let buttonAccesskey = this.stringBundle.getString( + AppConstants.platform == "win" + ? "remoteContentPrefAccesskey" + : "remoteContentPrefAccesskeyUnix" + ); + + let buttons = [ + { + label: buttonLabel, + accessKey: buttonAccesskey, + popup: "remoteContentOptions", + callback() {}, + }, + ]; + + // The popup value is a space separated list of all the blocked origins. + let popup = document.getElementById("remoteContentOptions"); + let principal = Services.scriptSecurityManager.createContentPrincipal( + aContentURI, + {} + ); + let origins = popup.value ? popup.value.split(" ") : []; + if (!origins.includes(principal.origin)) { + origins.push(principal.origin); + } + popup.value = origins.join(" "); + + if (!this.isShowingRemoteContentNotification()) { + let notification = this.msgNotificationBar.appendNotification( + "remoteContent", + { + label: remoteContentMsg, + image: "chrome://messenger/skin/icons/remote-blocked.svg", + priority: this.msgNotificationBar.PRIORITY_WARNING_MEDIUM, + }, + aCanOverride ? buttons : [] + ); + + notification.buttonContainer.firstElementChild.classList.add( + "button-menu-list" + ); + } + }, + + isShowingRemoteContentNotification() { + return !!this.msgNotificationBar.getNotificationWithValue("remoteContent"); + }, + + setPhishingMsg() { + let phishingMsgNote = this.stringBundle.getString("phishingBarMessage"); + + let buttonLabel = this.stringBundle.getString( + AppConstants.platform == "win" + ? "phishingBarPrefLabel" + : "phishingBarPrefLabelUnix" + ); + let buttonAccesskey = this.stringBundle.getString( + AppConstants.platform == "win" + ? "phishingBarPrefAccesskey" + : "phishingBarPrefAccesskeyUnix" + ); + + let buttons = [ + { + label: buttonLabel, + accessKey: buttonAccesskey, + popup: "phishingOptions", + callback(aNotification, aButton) {}, + }, + ]; + + if (!this.isShowingPhishingNotification()) { + let notification = this.msgNotificationBar.appendNotification( + "maybeScam", + { + label: phishingMsgNote, + image: "chrome://messenger/skin/icons/phishing.svg", + priority: this.msgNotificationBar.PRIORITY_CRITICAL_MEDIUM, + }, + buttons + ); + + notification.buttonContainer.firstElementChild.classList.add( + "button-menu-list" + ); + } + }, + + isShowingPhishingNotification() { + return !!this.msgNotificationBar.getNotificationWithValue("maybeScam"); + }, + + setMDNMsg(aMdnGenerator, aMsgHeader, aMimeHdr) { + this.mdnGenerator = aMdnGenerator; + // Return receipts can be RFC 3798 or not. + let mdnHdr = + aMimeHdr.extractHeader("Disposition-Notification-To", false) || + aMimeHdr.extractHeader("Return-Receipt-To", false); // not + let fromHdr = aMimeHdr.extractHeader("From", false); + + let mdnAddr = + MailServices.headerParser.extractHeaderAddressMailboxes(mdnHdr); + let fromAddr = + MailServices.headerParser.extractHeaderAddressMailboxes(fromHdr); + + let authorName = + MailServices.headerParser.extractFirstName( + aMsgHeader.mime2DecodedAuthor + ) || aMsgHeader.author; + + // If the return receipt doesn't go to the sender address, note that in the + // notification. + let mdnBarMsg = + mdnAddr != fromAddr + ? this.stringBundle.getFormattedString("mdnBarMessageAddressDiffers", [ + authorName, + mdnAddr, + ]) + : this.stringBundle.getFormattedString("mdnBarMessageNormal", [ + authorName, + ]); + + let buttons = [ + { + label: this.stringBundle.getString("mdnBarSendReqButton"), + accessKey: this.stringBundle.getString("mdnBarSendReqButtonKey"), + popup: null, + callback(aNotification, aButton) { + SendMDNResponse(); + return false; // close notification + }, + }, + { + label: this.stringBundle.getString("mdnBarIgnoreButton"), + accessKey: this.stringBundle.getString("mdnBarIgnoreButtonKey"), + popup: null, + callback(aNotification, aButton) { + IgnoreMDNResponse(); + return false; // close notification + }, + }, + ]; + + this.msgNotificationBar.appendNotification( + "mdnRequested", + { + label: mdnBarMsg, + priority: this.msgNotificationBar.PRIORITY_INFO_MEDIUM, + }, + buttons + ); + }, + + setDraftEditMessage() { + if (!gMessage || !gFolder) { + return; + } + + if (gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Drafts, true)) { + let draftMsgNote = this.stringBundle.getString("draftMessageMsg"); + + let buttons = [ + { + label: this.stringBundle.getString("draftMessageButton"), + accessKey: this.stringBundle.getString("draftMessageButtonKey"), + popup: null, + callback(aNotification, aButton) { + MsgComposeDraftMessage(); + return true; // keep notification open + }, + }, + ]; + + this.msgNotificationBar.appendNotification( + "draftMsgContent", + { + label: draftMsgNote, + priority: this.msgNotificationBar.PRIORITY_INFO_HIGH, + }, + buttons + ); + } + }, + + clearMsgNotifications() { + this.msgNotificationBar.removeAllNotifications(true); + }, +}; + +/** + * LoadMsgWithRemoteContent + * Reload the current message, allowing remote content + */ +function LoadMsgWithRemoteContent() { + // we want to get the msg hdr for the currently selected message + // change the "remoteContentBar" property on it + // then reload the message + + setMsgHdrPropertyAndReload("remoteContentPolicy", kAllowRemoteContent); + window.content?.focus(); +} + +/** + * Populate the remote content options for the current message. + */ +function onRemoteContentOptionsShowing(aEvent) { + let origins = aEvent.target.value ? aEvent.target.value.split(" ") : []; + + let addresses = MailServices.headerParser.parseEncodedHeader(gMessage.author); + addresses = addresses.slice(0, 1); + // If there is an author's email, put it also in the menu. + let adrCount = addresses.length; + if (adrCount > 0) { + let authorEmailAddress = addresses[0].email; + let authorEmailAddressURI = Services.io.newURI( + "chrome://messenger/content/email=" + authorEmailAddress + ); + let mailPrincipal = Services.scriptSecurityManager.createContentPrincipal( + authorEmailAddressURI, + {} + ); + origins.push(mailPrincipal.origin); + } + + let messengerBundle = document.getElementById("bundle_messenger"); + + // Out with the old... + let children = aEvent.target.children; + for (let i = children.length - 1; i >= 0; i--) { + if (children[i].getAttribute("class") == "allow-remote-uri") { + children[i].remove(); + } + } + + let urlSepar = document.getElementById("remoteContentAllMenuSeparator"); + + // ... and in with the new. + for (let origin of origins) { + let menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute( + "label", + messengerBundle.getFormattedString("remoteAllowResource", [ + origin.replace("chrome://messenger/content/email=", ""), + ]) + ); + menuitem.setAttribute("value", origin); + menuitem.setAttribute("class", "allow-remote-uri"); + menuitem.setAttribute("oncommand", "allowRemoteContentForURI(this.value);"); + if (origin.startsWith("chrome://messenger/content/email=")) { + aEvent.target.appendChild(menuitem); + } else { + aEvent.target.insertBefore(menuitem, urlSepar); + } + } + + let URLcount = origins.length - adrCount; + let allowAllItem = document.getElementById("remoteContentOptionAllowAll"); + let allURLLabel = messengerBundle.getString("remoteAllowAll"); + allowAllItem.label = PluralForm.get(URLcount, allURLLabel).replace( + "#1", + URLcount + ); + + allowAllItem.collapsed = URLcount < 2; + document.getElementById("remoteContentOriginsMenuSeparator").collapsed = + urlSepar.collapsed = allowAllItem.collapsed && adrCount == 0; +} + +/** + * Add privileges to display remote content for the given uri. + * + * @param aUriSpec |String| uri for the site to add permissions for. + * @param aReload Reload the message display after allowing the URI. + */ +function allowRemoteContentForURI(aUriSpec, aReload = true) { + let uri = Services.io.newURI(aUriSpec); + Services.perms.addFromPrincipal( + Services.scriptSecurityManager.createContentPrincipal(uri, {}), + "image", + Services.perms.ALLOW_ACTION + ); + if (aReload) { + ReloadMessage(); + } +} + +/** + * Add privileges to display remote content for the given uri. + * + * @param aListNode The menulist element containing the URIs to allow. + */ +function allowRemoteContentForAll(aListNode) { + let uriNodes = aListNode.querySelectorAll(".allow-remote-uri"); + for (let uriNode of uriNodes) { + if (!uriNode.value.startsWith("chrome://messenger/content/email=")) { + allowRemoteContentForURI(uriNode.value, false); + } + } + ReloadMessage(); +} + +/** + * Displays fine-grained, per-site preferences for remote content. + */ +function editRemoteContentSettings() { + top.openOptionsDialog("panePrivacy", "privacyCategory"); +} + +/** + * Set the msg hdr flag to ignore the phishing warning and reload the message. + */ +function IgnorePhishingWarning() { + // This property should really be called skipPhishingWarning or something + // like that, but it's too late to change that now. + // This property is used to suppress the phishing bar for the message. + setMsgHdrPropertyAndReload("notAPhishMessage", 1); +} + +/** + * Open the preferences dialog to allow disabling the scam feature. + */ +function OpenPhishingSettings() { + top.openOptionsDialog("panePrivacy", "privacySecurityCategory"); +} + +function setMsgHdrPropertyAndReload(aProperty, aValue) { + // we want to get the msg hdr for the currently selected message + // change the appropriate property on it then reload the message + if (gMessage) { + gMessage.setUint32Property(aProperty, aValue); + ReloadMessage(); + } +} + +/** + * Mark a specified message as read. + * @param msgHdr header (nsIMsgDBHdr) of the message to mark as read + */ +function MarkMessageAsRead(msgHdr) { + ClearPendingReadTimer(); + msgHdr.folder.markMessagesRead([msgHdr], true); + reportMsgRead({ isNewRead: true }); +} + +function ClearPendingReadTimer() { + if (gMarkViewedMessageAsReadTimer) { + clearTimeout(gMarkViewedMessageAsReadTimer); + gMarkViewedMessageAsReadTimer = null; + } +} + +// this is called when layout is actually finished rendering a +// mail message. OnMsgLoaded is called when libmime is done parsing the message +function OnMsgParsed(aUrl) { + // browser doesn't do this, but I thought it could be a useful thing to test out... + // If the find bar is visible and we just loaded a new message, re-run + // the find command. This means the new message will get highlighted and + // we'll scroll to the first word in the message that matches the find text. + var findBar = document.getElementById("FindToolbar"); + if (!findBar.hidden) { + findBar.onFindAgainCommand(false); + } + + let browser = getMessagePaneBrowser(); + // Run the phishing detector on the message if it hasn't been marked as not + // a scam already. + if ( + gMessage && + !gMessage.getUint32Property("notAPhishMessage") && + PhishingDetector.analyzeMsgForPhishingURLs(aUrl, browser) + ) { + gMessageNotificationBar.setPhishingMsg(); + } + + // Notify anyone (e.g., extensions) who's interested in when a message is loaded. + Services.obs.notifyObservers(null, "MsgMsgDisplayed", gMessageURI); + + let doc = browser && browser.contentDocument ? browser.contentDocument : null; + + // Rewrite any anchor elements' href attribute to reflect that the loaded + // document is a mailnews url. This will cause docShell to scroll to the + // element in the document rather than opening the link externally. + let links = doc && doc.links ? doc.links : []; + for (let linkNode of links) { + if (!linkNode.hash) { + continue; + } + + // We have a ref fragment which may reference a node in this document. + // Ensure html in mail anchors work as expected. + let anchorId = linkNode.hash.replace("#", ""); + // Continue if an id (html5) or name attribute value for the ref is not + // found in this document. + let selector = "#" + anchorId + ", [name='" + anchorId + "']"; + try { + if (!linkNode.ownerDocument.querySelector(selector)) { + continue; + } + } catch (ex) { + continue; + } + + // Then check if the href url matches the document baseURL. + if ( + makeURI(linkNode.href).specIgnoringRef != + makeURI(linkNode.baseURI).specIgnoringRef + ) { + continue; + } + + // Finally, if the document url is a message url, and the anchor href is + // http, it needs to be adjusted so docShell finds the node. + let messageURI = makeURI(linkNode.ownerDocument.URL); + if ( + messageURI instanceof Ci.nsIMsgMailNewsUrl && + linkNode.href.startsWith("http") + ) { + linkNode.href = messageURI.specIgnoringRef + linkNode.hash; + } + } + + // Scale any overflowing images, exclude http content. + let imgs = doc && !doc.URL.startsWith("http") ? doc.images : []; + for (let img of imgs) { + if ( + img.clientWidth - doc.body.offsetWidth >= 0 && + (img.clientWidth <= img.naturalWidth || !img.naturalWidth) + ) { + img.setAttribute("overflowing", "true"); + } + + // This is the default case for images when a message is loaded. + img.setAttribute("shrinktofit", "true"); + } +} + +function OnMsgLoaded(aUrl) { + if (!aUrl) { + return; + } + + window.msgLoaded = true; + window.dispatchEvent( + new CustomEvent("MsgLoaded", { detail: gMessage, bubbles: true }) + ); + window.dispatchEvent( + new CustomEvent("MsgsLoaded", { detail: [gMessage], bubbles: true }) + ); + + if (!gFolder) { + return; + } + + gMessageNotificationBar.setJunkMsg(gMessage); + + // See if MDN was requested but has not been sent. + HandleMDNResponse(aUrl); +} + +/** + * Marks the message as read, optionally after a delay, if the preferences say + * we should do so. + */ +function autoMarkAsRead() { + if (!gMessage?.folder) { + // The message can't be marked read or unread. + return; + } + + if (document.hidden) { + // We're in an inactive docShell (probably a background tab). Wait until + // it becomes active before marking the message as read. + document.addEventListener("visibilitychange", () => autoMarkAsRead(), { + once: true, + }); + return; + } + + let markReadAutoMode = Services.prefs.getBoolPref( + "mailnews.mark_message_read.auto" + ); + + // We just finished loading a message. If messages are to be marked as read + // automatically, set a timer to mark the message is read after n seconds + // where n can be configured by the user. + if (!gMessage.isRead && markReadAutoMode) { + let markReadOnADelay = Services.prefs.getBoolPref( + "mailnews.mark_message_read.delay" + ); + + let winType = top.document.documentElement.getAttribute("windowtype"); + // Only use the timer if viewing using the 3-pane preview pane and the + // user has set the pref. + if (markReadOnADelay && winType == "mail:3pane") { + // 3-pane window + ClearPendingReadTimer(); + let markReadDelayTime = Services.prefs.getIntPref( + "mailnews.mark_message_read.delay.interval" + ); + if (markReadDelayTime == 0) { + MarkMessageAsRead(gMessage); + } else { + gMarkViewedMessageAsReadTimer = setTimeout( + MarkMessageAsRead, + markReadDelayTime * 1000, + gMessage + ); + } + } else { + // standalone msg window + MarkMessageAsRead(gMessage); + } + } +} + +/** + * This function handles all mdn response generation (ie, imap and pop). + * For pop the msg uid can be 0 (ie, 1st msg in a local folder) so no + * need to check uid here. No one seems to set mimeHeaders to null so + * no need to check it either. + */ +function HandleMDNResponse(aUrl) { + if (!aUrl) { + return; + } + + var msgFolder = aUrl.folder; + if ( + !msgFolder || + !gMessage || + gFolder.isSpecialFolder(Ci.nsMsgFolderFlags.Newsgroup, false) + ) { + return; + } + + // if the message is marked as junk, do NOT attempt to process a return receipt + // in order to better protect the user + if (SelectedMessagesAreJunk()) { + return; + } + + var mimeHdr; + + try { + mimeHdr = aUrl.mimeHeaders; + } catch (ex) { + return; + } + + // If we didn't get the message id when we downloaded the message header, + // we cons up an md5: message id. If we've done that, we'll try to extract + // the message id out of the mime headers for the whole message. + let msgId = gMessage.messageId; + if (msgId.startsWith("md5:")) { + var mimeMsgId = mimeHdr.extractHeader("Message-Id", false); + if (mimeMsgId) { + gMessage.messageId = mimeMsgId; + } + } + + // After a msg is downloaded it's already marked READ at this point so we must check if + // the msg has a "Disposition-Notification-To" header and no MDN report has been sent yet. + if (gMessage.flags & Ci.nsMsgMessageFlags.MDNReportSent) { + return; + } + + var DNTHeader = mimeHdr.extractHeader("Disposition-Notification-To", false); + var oldDNTHeader = mimeHdr.extractHeader("Return-Receipt-To", false); + if (!DNTHeader && !oldDNTHeader) { + return; + } + + // Everything looks good so far, let's generate the MDN response. + var mdnGenerator = Cc[ + "@mozilla.org/messenger-mdn/generator;1" + ].createInstance(Ci.nsIMsgMdnGenerator); + const MDN_DISPOSE_TYPE_DISPLAYED = 0; + let askUser = mdnGenerator.process( + MDN_DISPOSE_TYPE_DISPLAYED, + top.msgWindow, + msgFolder, + gMessage.messageKey, + mimeHdr, + false + ); + if (askUser) { + gMessageNotificationBar.setMDNMsg(mdnGenerator, gMessage, mimeHdr); + } +} + +function SendMDNResponse() { + gMessageNotificationBar.mdnGenerator.userAgreed(); +} + +function IgnoreMDNResponse() { + gMessageNotificationBar.mdnGenerator.userDeclined(); +} + +// An object to help collecting reading statistics of secure emails. +var gSecureMsgProbe = {}; + +/** + * Update gSecureMsgProbe and report to telemetry if necessary. + */ +function reportMsgRead({ isNewRead = false, key = null }) { + if (isNewRead) { + gSecureMsgProbe.isNewRead = true; + } + if (key) { + gSecureMsgProbe.key = key; + } + if (gSecureMsgProbe.key && gSecureMsgProbe.isNewRead) { + Services.telemetry.keyedScalarAdd( + "tb.mails.read_secure", + gSecureMsgProbe.key, + 1 + ); + } +} + +window.addEventListener("secureMsgLoaded", event => { + reportMsgRead({ key: event.detail.key }); +}); + +/** + * Roving tab navigation for the header buttons. + */ +var headerToolbarNavigation = { + /** + * Get all currently visible buttons of the message header toolbar. + * + * @returns {Array} An array of buttons. + */ + get headerButtons() { + return this.headerToolbar.querySelectorAll( + `toolbarbutton:not([hidden="true"],[is="toolbarbutton-menu-button"]),toolbaritem[id="hdrSmartReplyButton"]>toolbarbutton:not([hidden="true"])>dropmarker, button:not([hidden])` + ); + }, + + init() { + this.headerToolbar = document.getElementById("header-view-toolbar"); + this.headerToolbar.addEventListener("keypress", event => { + this.triggerMessageHeaderRovingTab(event); + }); + }, + + /** + * Update the `tabindex` attribute of the currently visible buttons. + */ + updateRovingTab() { + for (let button of this.headerButtons) { + button.tabIndex = -1; + } + // Allow focus on the first available button. + // We use `setAttribute` to guarantee compatibility with XUL toolbarbuttons. + this.headerButtons[0].setAttribute("tabindex", "0"); + }, + + /** + * Handles the keypress event on the message header toolbar. + * + * @param {Event} event - The keypress DOMEvent. + */ + triggerMessageHeaderRovingTab(event) { + // Expected keyboard actions are Left, Right, Home, End, Space, and Enter. + if ( + !["ArrowRight", "ArrowLeft", "Home", "End", " ", "Enter"].includes( + event.key + ) + ) { + return; + } + + const headerButtons = [...this.headerButtons]; + let focusableButton = headerButtons.find(b => b.tabIndex != -1); + let elementIndex = headerButtons.indexOf(focusableButton); + + // TODO: Remove once the buttons are updated to not be XUL + // NOTE: Normally a button click handler would cover Enter and Space key + // events. However, we need to prevent the default behavior and explicitly + // trigger the button click because the XUL toolbarbuttons do not work when + // the Enter key is pressed. They do work when the Space key is pressed. + // However, if the toolbarbutton is a dropdown menu, the Space key + // does not open the menu. + if ( + event.key == "Enter" || + (event.key == " " && event.target.hasAttribute("type")) + ) { + if ( + event.target.getAttribute("class") == + "toolbarbutton-menubutton-dropmarker" + ) { + event.preventDefault(); + event.target.parentNode + .querySelector("menupopup") + .openPopup(event.target.parentNode, "after_end", { + triggerEvent: event, + }); + } else { + event.preventDefault(); + event.target.click(); + return; + } + } + + // Find the adjacent focusable element based on the pressed key. + if ( + (document.dir == "rtl" && event.key == "ArrowLeft") || + (document.dir == "ltr" && event.key == "ArrowRight") + ) { + elementIndex++; + if (elementIndex > headerButtons.length - 1) { + elementIndex = 0; + } + } else if ( + (document.dir == "ltr" && event.key == "ArrowLeft") || + (document.dir == "rtl" && event.key == "ArrowRight") + ) { + elementIndex--; + if (elementIndex == -1) { + elementIndex = headerButtons.length - 1; + } + } + + // Move the focus to a new toolbar button and update the tabindex attribute. + let newFocusableButton = headerButtons[elementIndex]; + if (newFocusableButton) { + focusableButton.tabIndex = -1; + newFocusableButton.setAttribute("tabindex", "0"); + newFocusableButton.focus(); + } + }, +}; diff --git a/comm/mail/base/content/msgSecurityPane.inc.xhtml b/comm/mail/base/content/msgSecurityPane.inc.xhtml new file mode 100644 index 0000000000..a6a35e4b76 --- /dev/null +++ b/comm/mail/base/content/msgSecurityPane.inc.xhtml @@ -0,0 +1,131 @@ +# 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/. + <panel id="messageSecurityPanel" + type="arrow" + orient="vertical" + class="cui-widget-panel" + position="bottomright topright" + tabindex="-1"> + <vbox class="security-panel-body"> + <html:header class="message-security-header"> + <html:h3>&status.label; <html:span id="techLabel"></html:span></html:h3> + </html:header> + + <vbox class="message-security-body"> + <!-- Notification container --> + <vbox> + <!-- Import OpenPGP key --> + <hbox id="openpgpKeyBox" hidden="true" + class="inline-notification-container info-container"> + <hbox class="inline-notification-wrapper align-center"> + <html:img class="notification-image" + src="chrome://messenger/skin/icons/information.svg" + alt="" /> + <description> + <html:span data-l10n-id="openpgp-has-sender-key"></html:span> + </description> + <button id="openpgpImportButton" + class="button-focusable" + data-l10n-id="openpgp-import-sender-key" + oncommand="Enigmail.msg.importAttachedSenderKey();"/> + </hbox> + </hbox> + + <!-- Missing signature --> + <hbox id="signatureKeyBox" hidden="true" + class="inline-notification-container info-container"> + <hbox class="inline-notification-wrapper align-center"> + <html:img class="notification-image" + src="chrome://messenger/skin/icons/information.svg" + alt="" /> + <description data-l10n-id="openpgp-missing-signature-key"/> + <button data-l10n-id="openpgp-search-signature-key" + class="button-focusable" + oncommand="Enigmail.msg.searchSignatureKey();"/> + </hbox> + </hbox> + </vbox> + + <label id="signatureLabel" class="message-security-label"/> + <label id="signatureHeader" collapsed="true" /> + <description id="signatureExplanation"/> + + <vbox id="signatureCert" class="message-security-container" + collapsed="true"> + <hbox> + <label id="signedByLabel" class="cert-label">&signer.name;</label> + <description id="signedBy" /> + </hbox> + <hbox> + <label id="signerEmailLabel" + class="cert-label">&email.address;</label> + <description id="signerEmail" /> + </hbox> + <hbox> + <label id="sigCertIssuedByLabel" + class="cert-label">&issuer.name;</label> + <description id="sigCertIssuedBy" /> + </hbox> + <hbox pack="end"> + <button id="signatureCertView" label="&signatureCert.label;" + class="button-focusable" + oncommand="viewSignatureCert()" /> + </hbox> + </vbox> + + <hbox id="signatureKey" class="message-security-container" + collapsed="true" align="center"> + <label id="signatureKeyId" flex="1" context="simpleCopyPopup"/> + <button id="viewSignatureKey" data-l10n-id="openpgp-view-signer-key" + class="button-focusable" + oncommand="viewSignatureKey()" collapsed="true"/> + </hbox> + + <label id="encryptionLabel" class="message-security-label"/> + <label id="encryptionHeader" collapsed="true" /> + <description id="encryptionExplanation"/> + + <vbox id="encryptionCert" class="message-security-container" + collapsed="true"> + <hbox> + <label id="encryptedForLabel" + class="cert-label">&recipient.name;</label> + <description id="encryptedFor" /> + </hbox> + <hbox> + <label id="recipientEmailLabel" + class="cert-label">&email.address;</label> + <description id="recipientEmail" /> + </hbox> + <hbox> + <label id="encCertIssuedByLabel" + class="cert-label">&issuer.name;</label> + <description id="encCertIssuedBy" /> + </hbox> + <hbox pack="end"> + <button id="encryptionCertView" label="&encryptionCert.label;" + class="button-focusable" + oncommand="viewEncryptionCert()" /> + </hbox> + </vbox> + + <vbox id="encryptionKey" class="message-security-container" + collapsed="true"> + <label id="encryptionKeyId" context="simpleCopyPopup"/> + <hbox pack="end"> + <button id="viewEncryptionKey" + data-l10n-id="openpgp-view-your-encryption-key" + class="button-focusable" + oncommand="viewEncryptionKey()" + collapsed="true"/> + </hbox> + </vbox> + + <vbox id="otherEncryptionKeys" collapsed="true"> + <label id="otherLabel" class="message-security-label none"/> + <vbox id="otherEncryptionKeysList"/> + </vbox> + </vbox> + </vbox> + </panel> diff --git a/comm/mail/base/content/msgSecurityPane.js b/comm/mail/base/content/msgSecurityPane.js new file mode 100644 index 0000000000..e8887363ce --- /dev/null +++ b/comm/mail/base/content/msgSecurityPane.js @@ -0,0 +1,111 @@ +/* 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/. */ + +/** + * Functions related to the msgSecurityPane.inc.xhtml file, used in the message + * header to display S/MIME and OpenPGP encryption and signature info. + */ + +/* import-globals-from ../../../mailnews/extensions/smime/msgReadSMIMEOverlay.js */ +/* import-globals-from ../../extensions/openpgp/content/ui/enigmailMessengerOverlay.js */ +/* import-globals-from aboutMessage.js */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + EnigmailConstants: "chrome://openpgp/content/modules/constants.jsm", + EnigmailKeyRing: "chrome://openpgp/content/modules/keyRing.jsm", + EnigmailWindows: "chrome://openpgp/content/modules/windows.jsm", +}); + +var gSigKeyId = null; +var gEncKeyId = null; + +/** + * Reveal message security popup panel with updated OpenPGP or S/MIME info. + */ +function showMessageReadSecurityInfo() { + // Interrupt if no message is selected or no encryption technology was used. + if (!gMessage || document.getElementById("cryptoBox").hidden) { + return; + } + + // OpenPGP. + if (document.getElementById("cryptoBox").getAttribute("tech") === "OpenPGP") { + Enigmail.msg.loadOpenPgpMessageSecurityInfo(); + showMessageSecurityPanel(); + return; + } + + // S/MIME. + if (gSignatureStatus === Ci.nsICMSMessageErrors.VERIFY_NOT_YET_ATTEMPTED) { + showImapSignatureUnknown(); + return; + } + + loadSmimeMessageSecurityInfo(); + showMessageSecurityPanel(); +} + +/** + * Reveal the popup panel with the populated message security info. + */ +function showMessageSecurityPanel() { + document + .getElementById("messageSecurityPanel") + .openPopup( + document.getElementById("encryptionTechBtn"), + "bottomright topright", + 0, + 0, + false + ); +} + +/** + * Reset all values and clear the text of the message security popup panel. + */ +function onMessageSecurityPopupHidden() { + // Clear the variables for signature and encryption. + gSigKeyId = null; + gEncKeyId = null; + + // Hide the UI elements. + document.getElementById("signatureHeader").collapsed = true; + document.getElementById("encryptionHeader").collapsed = true; + document.getElementById("signatureCert").collapsed = true; + document.getElementById("signatureKey").collapsed = true; + document.getElementById("viewSignatureKey").collapsed = true; + document.getElementById("encryptionKey").collapsed = true; + document.getElementById("encryptionCert").collapsed = true; + document.getElementById("viewEncryptionKey").collapsed = true; + document.getElementById("otherEncryptionKeys").collapsed = true; + + let keyList = document.getElementById("otherEncryptionKeysList"); + // Clear any possible existing key previously appended to the DOM. + for (let node of keyList.children) { + keyList.removeChild(node); + } +} + +async function viewSignatureKey() { + if (!gSigKeyId) { + return; + } + + // If the signature acceptance was edited, reload the current message. + if (await EnigmailWindows.openKeyDetails(window, gSigKeyId, false)) { + ReloadMessage(); + } +} + +function viewEncryptionKey() { + if (!gEncKeyId) { + return; + } + + EnigmailWindows.openKeyDetails(window, gEncKeyId, false); +} diff --git a/comm/mail/base/content/msgViewNavigation.js b/comm/mail/base/content/msgViewNavigation.js new file mode 100644 index 0000000000..6ff860a717 --- /dev/null +++ b/comm/mail/base/content/msgViewNavigation.js @@ -0,0 +1,207 @@ +/* -*- Mode: Javascript; 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/. */ + +/* This file contains the js functions necessary to implement view navigation within the 3 pane. */ + +/* globals DBViewWrapper, dbViewWrapperListener, TreeSelection */ +/* globals gDBView: true, gFolder: true, gViewWrapper: true */ // mailCommon.js + +ChromeUtils.defineModuleGetter( + this, + "FolderUtils", + "resource:///modules/FolderUtils.jsm" +); + +function GetSubFoldersInFolderPaneOrder(folder) { + function compareFolderSortKey(folder1, folder2) { + return folder1.compareSortKeys(folder2); + } + // sort the subfolders + return folder.subFolders.sort(compareFolderSortKey); +} + +function FindNextChildFolder(aParent, aAfter) { + // Search the child folders of aParent for unread messages + // but in the case that we are working up from the current folder + // we need to skip up to and including the current folder + // we skip the current folder in case a mail view is hiding unread messages + if (aParent.getNumUnread(true) > 0) { + var subFolders = GetSubFoldersInFolderPaneOrder(aParent); + var i = 0; + var folder = null; + + // Skip folders until after the specified child + while (folder != aAfter) { + folder = subFolders[i++]; + } + + let ignoreFlags = + Ci.nsMsgFolderFlags.Trash | + Ci.nsMsgFolderFlags.SentMail | + Ci.nsMsgFolderFlags.Drafts | + Ci.nsMsgFolderFlags.Queue | + Ci.nsMsgFolderFlags.Templates | + Ci.nsMsgFolderFlags.Junk; + while (i < subFolders.length) { + folder = subFolders[i++]; + // If there is unread mail in the trash, sent, drafts, unsent messages + // templates or junk special folder, + // we ignore it when doing cross folder "next" navigation. + if (!folder.isSpecialFolder(ignoreFlags, true)) { + if (folder.getNumUnread(false) > 0) { + return folder; + } + + folder = FindNextChildFolder(folder, null); + if (folder) { + return folder; + } + } + } + } + + return null; +} + +function FindNextFolder() { + // look for the next folder, this will only look on the current account + // and below us, in the folder pane + // note use of gDBView restricts this function to message folders + // otherwise you could go next unread from a server + var folder = FindNextChildFolder(gDBView.msgFolder, null); + if (folder) { + return folder; + } + + // didn't find folder in children + // go up to the parent, and start at the folder after the current one + // unless we are at a server, in which case bail out. + folder = gDBView.msgFolder; + while (!folder.isServer) { + var parent = folder.parent; + folder = FindNextChildFolder(parent, folder); + if (folder) { + return folder; + } + + // none at this level after the current folder. go up. + folder = parent; + } + + // nothing in the current account, start with the next account (below) + // and try until we hit the bottom of the folder pane + + // start at the account after the current account + var rootFolders = GetRootFoldersInFolderPaneOrder(); + for (var i = 0; i < rootFolders.length; i++) { + if (rootFolders[i].URI == gDBView.msgFolder.server.serverURI) { + break; + } + } + + for (var j = i + 1; j < rootFolders.length; j++) { + folder = FindNextChildFolder(rootFolders[j], null); + if (folder) { + return folder; + } + } + + // if nothing from the current account down to the bottom + // (of the folder pane), start again at the top. + for (j = 0; j <= i; j++) { + folder = FindNextChildFolder(rootFolders[j], null); + if (folder) { + return folder; + } + } + return null; +} + +function GetRootFoldersInFolderPaneOrder() { + let accounts = FolderUtils.allAccountsSorted(false); + + let serversMsgFolders = []; + for (let account of accounts) { + serversMsgFolders.push(account.incomingServer.rootMsgFolder); + } + + return serversMsgFolders; +} + +/** + * Handle switching the folder if required for the given kind of navigation. + * Only used in about:3pane. + * + * @param {nsMsgNavigationType} type - The type of navigation. + * @returns {boolean} If the folder was changed for the navigation. + */ +function CrossFolderNavigation(type) { + // do cross folder navigation for next unread message/thread and message history + if ( + type != Ci.nsMsgNavigationType.nextUnreadMessage && + type != Ci.nsMsgNavigationType.nextUnreadThread + ) { + return false; + } + + let nextMode = Services.prefs.getIntPref("mailnews.nav_crosses_folders"); + // 0: "next" goes to the next folder, without prompting + // 1: "next" goes to the next folder, and prompts (the default) + // 2: "next" does nothing when there are no unread messages + + // not crossing folders, don't find next + if (nextMode == 2) { + return false; + } + + let folder = FindNextFolder(); + if (!folder || gDBView.msgFolder.URI == folder.URI) { + return false; + } + + if (nextMode == 1) { + let messengerBundle = + window.messengerBundle || + Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + let promptText = messengerBundle.formatStringFromName("advanceNextPrompt", [ + folder.name, + ]); + if ( + Services.prompt.confirmEx( + window, + null, + promptText, + Services.prompt.STD_YES_NO_BUTTONS, + null, + null, + null, + null, + {} + ) + ) { + return false; + } + } + + if (window.threadPane) { + // In about:3pane. + window.threadPane.forgetSelection(folder.URI); + window.displayFolder(folder.URI); + } else { + // In standalone about:message. Do just enough to call + // `commandController._navigate` again. + gViewWrapper = new DBViewWrapper(dbViewWrapperListener); + gViewWrapper._viewFlags = Ci.nsMsgViewFlagsType.kThreadedDisplay; + gViewWrapper.open(folder); + gDBView = gViewWrapper.dbView; + let selection = (gDBView.selection = new TreeSelection()); + selection.view = gDBView; + // We're now in a bit of a weird state until `displayMessage` is called, + // but being here means we have everything we need for that to happen. + } + return true; +} diff --git a/comm/mail/base/content/multimessageview.js b/comm/mail/base/content/multimessageview.js new file mode 100644 index 0000000000..9b4c593fde --- /dev/null +++ b/comm/mail/base/content/multimessageview.js @@ -0,0 +1,844 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + DisplayNameUtils: "resource:///modules/DisplayNameUtils.jsm", + Gloda: "resource:///modules/gloda/Gloda.jsm", + makeFriendlyDateAgo: "resource:///modules/TemplateUtils.jsm", + MessageArchiver: "resource:///modules/MessageArchiver.jsm", + mimeMsgToContentSnippetAndMeta: "resource:///modules/gloda/GlodaContent.jsm", + MsgHdrToMimeMessage: "resource:///modules/gloda/MimeMessage.jsm", + PluralStringFormatter: "resource:///modules/TemplateUtils.jsm", + TagUtils: "resource:///modules/TagUtils.jsm", +}); + +var gMessenger = Cc["@mozilla.org/messenger;1"].createInstance(Ci.nsIMessenger); + +// Set up our string formatter for localizing strings. +XPCOMUtils.defineLazyGetter(this, "formatString", function () { + let formatter = new PluralStringFormatter( + "chrome://messenger/locale/multimessageview.properties" + ); + return function (...args) { + return formatter.get(...args); + }; +}); + +/** + * A LimitIterator is a utility class that allows limiting the maximum number + * of items to iterate over. + * + * @param aArray The array to iterate over (can be anything with a .length + * property and a subscript operator. + * @param aMaxLength The maximum number of items to iterate over. + */ +function LimitIterator(aArray, aMaxLength) { + this._array = aArray; + this._maxLength = aMaxLength; +} + +LimitIterator.prototype = { + /** + * Returns true if the iterator won't actually iterate over everything in the + * array. + */ + get limited() { + return this._array.length > this._maxLength; + }, + + /** + * Returns the number of elements that will actually be iterated over. + */ + get length() { + return Math.min(this._array.length, this._maxLength); + }, + + /** + * Returns the real number of elements in the array. + */ + get trueLength() { + return this._array.length; + }, +}; + +var JS_HAS_SYMBOLS = typeof Symbol === "function"; +var ITERATOR_SYMBOL = JS_HAS_SYMBOLS ? Symbol.iterator : "@@iterator"; + +/** + * Iterate over the array until we hit the end or the maximum length, + * whichever comes first. + */ +LimitIterator.prototype[ITERATOR_SYMBOL] = function* () { + let length = this.length; + for (let i = 0; i < length; i++) { + yield this._array[i]; + } +}; + +/** + * The MultiMessageSummary class is responsible for populating the message pane + * with a reasonable summary of a set of messages. + */ +function MultiMessageSummary() { + this._summarizers = {}; +} + +MultiMessageSummary.prototype = { + /** + * The maximum number of messages to examine in any way. + */ + kMaxMessages: 10000, + + /** + * Register a summarizer for a particular type of message summary. + * + * @param aSummarizer The summarizer object. + */ + registerSummarizer(aSummarizer) { + this._summarizers[aSummarizer.name] = aSummarizer; + aSummarizer.onregistered(this); + }, + + /** + * Store a mapping from a message header to the summary node in the DOM. We + * use this to update things when Gloda tells us to. + * + * @param aMsgHdr The nsIMsgDBHdr. + * @param aNode The related DOM node. + */ + mapMsgToNode(aMsgHdr, aNode) { + let key = aMsgHdr.messageKey + aMsgHdr.folder.URI; + this._msgNodes[key] = aNode; + }, + + /** + * Clear all the content from the summary. + */ + clear() { + this._selectCallback = null; + this._listener = null; + this._glodaQuery = null; + this._msgNodes = {}; + + // Clear the messages list. + let messageList = document.getElementById("message_list"); + while (messageList.hasChildNodes()) { + messageList.lastChild.remove(); + } + + // Clear the notice. + document.getElementById("notice").textContent = ""; + }, + + /** + * Fill in the summary pane describing the selected messages. + * + * @param aType The type of summary to perform (e.g. 'multimessage'). + * @param aMessages The messages to summarize. + * @param aDBView The current DB view. + * @param aSelectCallback Called with an array of nsIMsgHdrs when one of + * a summarized message is clicked on. + * @param [aListener] A listener to be notified when the summary starts and + * finishes. + */ + summarize(aType, aMessages, aDBView, aSelectCallback, aListener) { + this.clear(); + + this._selectCallback = aSelectCallback; + this._listener = aListener; + if (this._listener) { + this._listener.onLoadStarted(); + } + + // Enable/disable the archive button as appropriate. + let archiveBtn = document.getElementById("hdrArchiveButton"); + archiveBtn.hidden = !MessageArchiver.canArchive(aMessages); + + // Set archive and delete button listeners. + let topChromeWindow = window.browsingContext.topChromeWindow; + archiveBtn.onclick = event => { + if (event.button == 0) { + topChromeWindow.goDoCommand("cmd_archive"); + } + }; + document.getElementById("hdrTrashButton").onclick = event => { + if (event.button == 0) { + topChromeWindow.goDoCommand("cmd_delete"); + } + }; + + headerToolbarNavigation.init(); + + let summarizer = this._summarizers[aType]; + if (!summarizer) { + throw new Error('Unknown summarizer "' + aType + '"'); + } + + let messages = new LimitIterator(aMessages, this.kMaxMessages); + let summarizedMessages = summarizer.summarize(messages, aDBView); + + // Stash somewhere so it doesn't get GC'ed. + this._glodaQuery = Gloda.getMessageCollectionForHeaders( + summarizedMessages, + this + ); + this._computeSize(messages); + }, + + /** + * Set the heading for the summary. + * + * @param title The title for the heading. + * @param subtitle A smaller subtitle for the heading. + */ + setHeading(title, subtitle) { + let titleNode = document.getElementById("summary_title"); + let subtitleNode = document.getElementById("summary_subtitle"); + titleNode.textContent = title || ""; + subtitleNode.textContent = subtitle || ""; + }, + + /** + * Create a summary item for a message or thread. + * + * @param aMsgOrThread An nsIMsgDBHdr or an array thereof + * @param [aOptions] An optional object to customize the output: + * showSubject: true if the subject of the message + * should be shown; defaults to false + * snippetLength: the length in bytes of the message + * snippet; defaults to undefined (let Gloda decide) + * @returns A DOM node for the summary item. + */ + makeSummaryItem(aMsgOrThread, aOptions) { + let message, thread, numUnread, isStarred, tags; + if (aMsgOrThread instanceof Ci.nsIMsgDBHdr) { + thread = null; + message = aMsgOrThread; + + numUnread = message.isRead ? 0 : 1; + isStarred = message.isFlagged; + + tags = this._getTagsForMsg(message); + } else { + thread = aMsgOrThread; + message = thread[0]; + + numUnread = thread.reduce(function (x, hdr) { + return x + (hdr.isRead ? 0 : 1); + }, 0); + isStarred = thread.some(function (hdr) { + return hdr.isFlagged; + }); + + tags = new Set(); + for (let message of thread) { + for (let tag of this._getTagsForMsg(message)) { + tags.add(tag); + } + } + } + + let row = document.createElement("li"); + row.dataset.messageId = message.messageId; + row.classList.toggle("thread", thread && thread.length > 1); + row.classList.toggle("unread", numUnread > 0); + row.classList.toggle("starred", isStarred); + + row.appendChild(document.createElement("div")).classList.add("star"); + + let summary = document.createElement("div"); + summary.classList.add("item_summary"); + summary + .appendChild(document.createElement("div")) + .classList.add("item_header"); + summary.appendChild(document.createElement("div")).classList.add("snippet"); + row.appendChild(summary); + + let itemHeaderNode = row.querySelector(".item_header"); + + let authorNode = document.createElement("span"); + authorNode.classList.add("author"); + authorNode.textContent = DisplayNameUtils.formatDisplayNameList( + message.mime2DecodedAuthor, + "from" + ); + + if (aOptions && aOptions.showSubject) { + authorNode.classList.add("right"); + itemHeaderNode.appendChild(authorNode); + + let subjectNode = document.createElement("span"); + subjectNode.classList.add("subject", "primary_header", "link"); + subjectNode.textContent = + message.mime2DecodedSubject || formatString("noSubject"); + subjectNode.addEventListener("click", () => this._selectCallback(thread)); + itemHeaderNode.appendChild(subjectNode); + + if (thread && thread.length > 1) { + let numUnreadStr = ""; + if (numUnread) { + numUnreadStr = formatString( + "numUnread", + [numUnread.toLocaleString()], + numUnread + ); + } + let countStr = + "(" + + formatString( + "numMessages", + [thread.length.toLocaleString()], + thread.length + ) + + numUnreadStr + + ")"; + + let countNode = document.createElement("span"); + countNode.classList.add("count"); + countNode.textContent = countStr; + itemHeaderNode.appendChild(countNode); + } + } else { + let dateNode = document.createElement("span"); + dateNode.classList.add("date", "right"); + dateNode.textContent = makeFriendlyDateAgo(new Date(message.date / 1000)); + itemHeaderNode.appendChild(dateNode); + + authorNode.classList.add("primary_header", "link"); + authorNode.addEventListener("click", () => { + this._selectCallback([message]); + }); + itemHeaderNode.appendChild(authorNode); + } + + let tagNode = document.createElement("span"); + tagNode.classList.add("tags"); + this._addTagNodes(tags, tagNode); + itemHeaderNode.appendChild(tagNode); + + let snippetNode = row.querySelector(".snippet"); + try { + const kSnippetLength = aOptions && aOptions.snippetLength; + MsgHdrToMimeMessage( + message, + null, + function (aMsgHdr, aMimeMsg) { + if (aMimeMsg == null) { + // Shouldn't happen, but sometimes does? + return; + } + let [text, meta] = mimeMsgToContentSnippetAndMeta( + aMimeMsg, + aMsgHdr.folder, + kSnippetLength + ); + snippetNode.textContent = text; + if (meta.author) { + authorNode.textContent = meta.author; + } + }, + false, + { saneBodySize: true } + ); + } catch (e) { + if (e.result == Cr.NS_ERROR_FAILURE) { + // Offline messages generate exceptions, which is unfortunate. When + // that's fixed, this code should adapt. XXX + snippetNode.textContent = "..."; + } else { + throw e; + } + } + return row; + }, + + /** + * Show an informative notice about the summarized messages (e.g. if we only + * summarized some of them). + * + * @param aNoticeText The text to show in the notice. + */ + showNotice(aNoticeText) { + let notice = document.getElementById("notice"); + notice.textContent = aNoticeText; + }, + + /** + * Given a msgHdr, return a list of tag objects. This function just does the + * messy work of understanding how tags are stored in nsIMsgDBHdrs. It would + * be a good candidate for a utility library. + * + * @param aMsgHdr The msgHdr whose tags we want. + * @returns An array of nsIMsgTag objects. + */ + _getTagsForMsg(aMsgHdr) { + let keywords = new Set(aMsgHdr.getStringProperty("keywords").split(" ")); + let allTags = MailServices.tags.getAllTags(); + + return allTags.filter(function (tag) { + return keywords.has(tag.key); + }); + }, + + /** + * Add a list of tags to a DOM node. + * + * @param aTags An array (or any iterable) of nsIMsgTag objects. + * @param aTagsNode The DOM node to contain the list of tags. + */ + _addTagNodes(aTags, aTagsNode) { + // Make sure the tags are sorted in their natural order. + let sortedTags = [...aTags]; + sortedTags.sort(function (a, b) { + return a.key.localeCompare(b.key) || a.ordinal.localeCompare(b.ordinal); + }); + + for (let tag of sortedTags) { + let tagNode = document.createElement("span"); + + tagNode.className = "tag"; + let color = MailServices.tags.getColorForKey(tag.key); + if (color) { + let textColor = !TagUtils.isColorContrastEnough(color) + ? "white" + : "black"; + tagNode.setAttribute( + "style", + "color: " + textColor + "; background-color: " + color + ";" + ); + } + tagNode.dataset.tag = tag.tag; + tagNode.textContent = tag.tag; + aTagsNode.appendChild(tagNode); + } + }, + + /** + * Compute the size of the messages in the selection and display it in the + * element of id "size". + * + * @param aMessages A LimitIterator of the messages to calculate the size of. + */ + _computeSize(aMessages) { + let numBytes = 0; + for (let msgHdr of aMessages) { + numBytes += msgHdr.messageSize; + // XXX do something about news? + } + + let format = aMessages.limited + ? "messagesTotalSizeMoreThan" + : "messagesTotalSize"; + document.getElementById("size").textContent = formatString(format, [ + gMessenger.formatFileSize(numBytes), + ]); + }, + + // These are listeners for the gloda collections. + onItemsAdded(aItems) {}, + onItemsModified(aItems) { + this._processItems(aItems); + }, + onItemsRemoved(aItems) {}, + + /** + * Given a set of items from a gloda collection, process them and update + * the display accordingly. + * + * @param aItems Contents of a gloda collection. + */ + _processItems(aItems) { + let knownMessageNodes = new Map(); + + for (let glodaMsg of aItems) { + // Unread and starred will get set if any of the messages in a collapsed + // thread qualify. The trick here is that we may get multiple items + // corresponding to the same thread (and hence DOM node), so we need to + // detect when we get the first item for a particular DOM node, stash the + // preexisting status of that DOM node, an only do transitions if the + // items warrant it. + let key = glodaMsg.messageKey + glodaMsg.folder.uri; + let headerNode = this._msgNodes[key]; + if (!headerNode) { + continue; + } + if (!knownMessageNodes.has(headerNode)) { + knownMessageNodes.set(headerNode, { + read: true, + starred: false, + tags: new Set(), + }); + } + + let flags = knownMessageNodes.get(headerNode); + + // Count as read if *all* the messages are read. + flags.read &= glodaMsg.read; + // Count as starred if *any* of the messages are starred. + flags.starred |= glodaMsg.starred; + // Count as tagged with a tag if *any* of the messages have that tag. + for (let tag of this._getTagsForMsg(glodaMsg.folderMessage)) { + flags.tags.add(tag); + } + } + + for (let [headerNode, flags] of knownMessageNodes) { + headerNode.classList.toggle("unread", !flags.read); + headerNode.classList.toggle("starred", flags.starred); + + // Clear out all the tags and start fresh, just to make sure we don't get + // out of sync. + let tagsNode = headerNode.querySelector(".tags"); + while (tagsNode.hasChildNodes()) { + tagsNode.lastChild.remove(); + } + this._addTagNodes(flags.tags, tagsNode); + } + }, + + onQueryCompleted(aCollection) { + // If we need something that's just available from GlodaMessages, this is + // where we'll get it initially. + if (this._listener) { + this._listener.onLoadCompleted(); + } + }, +}; + +/** + * A summarizer to use for a single thread. + */ +function ThreadSummarizer() {} + +ThreadSummarizer.prototype = { + /** + * The maximum number of messages to summarize. + */ + kMaxSummarizedMessages: 100, + + /** + * The length of message snippets to fetch from Gloda. + */ + kSnippetLength: 300, + + /** + * Returns a canonical name for this summarizer. + */ + get name() { + return "thread"; + }, + + /** + * A function to be called once the summarizer has been registered with the + * main summary object. + * + * @param aContext The MultiMessageSummary object holding this summarizer. + */ + onregistered(aContext) { + this.context = aContext; + }, + + /** + * Summarize a list of messages. + * + * @param aMessages A LimitIterator of the messages to summarize. + * @returns An array of the messages actually summarized. + */ + summarize(aMessages, aDBView) { + let messageList = document.getElementById("message_list"); + + // Remove all ignored messages from summarization. + let summarizedMessages = []; + for (let message of aMessages) { + if (!message.isKilled) { + summarizedMessages.push(message); + } + } + let ignoredCount = aMessages.trueLength - summarizedMessages.length; + + // Summarize the selected messages. + let subject = null; + let maxCountExceeded = false; + for (let [i, msgHdr] of summarizedMessages.entries()) { + if (i == this.kMaxSummarizedMessages) { + summarizedMessages.length = i; + maxCountExceeded = true; + break; + } + + if (subject == null) { + subject = msgHdr.mime2DecodedSubject; + } + + let msgNode = this.context.makeSummaryItem(msgHdr, { + snippetLength: this.kSnippetLength, + }); + messageList.appendChild(msgNode); + + this.context.mapMsgToNode(msgHdr, msgNode); + } + + // Set the heading based on the subject and number of messages. + let countInfo = formatString( + "numMessages", + [aMessages.length.toLocaleString()], + aMessages.length + ); + if (ignoredCount != 0) { + let format = aMessages.limited ? "atLeastNumIgnored" : "numIgnored"; + countInfo += formatString( + format, + [ignoredCount.toLocaleString()], + ignoredCount + ); + } + + this.context.setHeading(subject || formatString("noSubject"), countInfo); + + if (maxCountExceeded) { + this.context.showNotice( + formatString("maxCountExceeded", [ + aMessages.trueLength.toLocaleString(), + this.kMaxSummarizedMessages.toLocaleString(), + ]) + ); + } + return summarizedMessages; + }, +}; + +/** + * A summarizer to use when multiple threads are selected. + */ +function MultipleSelectionSummarizer() {} + +MultipleSelectionSummarizer.prototype = { + /** + * The maximum number of threads to summarize. + */ + kMaxSummarizedThreads: 100, + + /** + * The length of message snippets to fetch from Gloda. + */ + kSnippetLength: 300, + + /** + * Returns a canonical name for this summarizer. + */ + get name() { + return "multipleselection"; + }, + + /** + * A function to be called once the summarizer has been registered with the + * main summary object. + * + * @param aContext The MultiMessageSummary object holding this summarizer. + */ + onregistered(aContext) { + this.context = aContext; + }, + + /** + * Summarize a list of messages. + * + * @param aMessages The messages to summarize. + */ + summarize(aMessages, aDBView) { + let messageList = document.getElementById("message_list"); + + let threads = this._buildThreads(aMessages, aDBView); + let threadsCount = threads.length; + + // Set the heading based on the number of messages & threads. + let format = aMessages.limited + ? "atLeastNumConversations" + : "numConversations"; + this.context.setHeading( + formatString(format, [threads.length.toLocaleString()], threads.length) + ); + + // Summarize the selected messages by thread. + let maxCountExceeded = false; + for (let [i, msgs] of threads.entries()) { + if (i == this.kMaxSummarizedThreads) { + threads.length = i; + maxCountExceeded = true; + break; + } + + let msgNode = this.context.makeSummaryItem(msgs, { + showSubject: true, + snippetLength: this.kSnippetLength, + }); + messageList.appendChild(msgNode); + + for (let msgHdr of msgs) { + this.context.mapMsgToNode(msgHdr, msgNode); + } + } + + if (maxCountExceeded) { + this.context.showNotice( + formatString("maxThreadCountExceeded", [ + threadsCount.toLocaleString(), + this.kMaxSummarizedThreads.toLocaleString(), + ]) + ); + + // Return only the messages for the threads we're actually showing. We + // need to collapse our array-of-arrays into a flat array. + return threads.reduce(function (accum, curr) { + accum.push(...curr); + return accum; + }, []); + } + + // Return everything, since we're showing all the threads. Don't forget to + // turn it into an array, though! + return [...aMessages]; + }, + + /** + * Group all the messages to be summarized into threads. + * + * @param aMessages The messages to group. + * @returns An array of arrays of messages, grouped by thread. + */ + _buildThreads(aMessages, aDBView) { + // First, we group the messages in threads and count the threads. + let threads = []; + let threadMap = {}; + for (let msgHdr of aMessages) { + let viewThreadId = aDBView.getThreadContainingMsgHdr(msgHdr).threadKey; + if (!(viewThreadId in threadMap)) { + threadMap[viewThreadId] = threads.length; + threads.push([msgHdr]); + } else { + threads[threadMap[viewThreadId]].push(msgHdr); + } + } + return threads; + }, +}; + +var gMessageSummary = new MultiMessageSummary(); + +gMessageSummary.registerSummarizer(new ThreadSummarizer()); +gMessageSummary.registerSummarizer(new MultipleSelectionSummarizer()); + +/** + * Roving tab navigation for the header buttons. + */ +const headerToolbarNavigation = { + /** + * If the roving tab has already been loaded. + * + * @type {boolean} + */ + isLoaded: false, + /** + * Get all currently visible buttons of the message header toolbar. + * + * @returns {Array} An array of buttons. + */ + get headerButtons() { + return this.headerToolbar.querySelectorAll( + `toolbarbutton:not([hidden="true"])` + ); + }, + + init() { + // Bail out if we already initialized this. + if (this.isLoaded) { + return; + } + this.headerToolbar = document.getElementById("header-view-toolbar"); + this.headerToolbar.addEventListener("keypress", event => { + this.triggerMessageHeaderRovingTab(event); + }); + this.updateRovingTab(); + this.isLoaded = true; + }, + + /** + * Update the `tabindex` attribute of the currently visible buttons. + */ + updateRovingTab() { + for (const button of this.headerButtons) { + button.tabIndex = -1; + } + // Allow focus on the first available button. + // We use `setAttribute` to guarantee compatibility with XUL toolbarbuttons. + this.headerButtons[0].setAttribute("tabindex", "0"); + }, + + /** + * Handles the keypress event on the message header toolbar. + * + * @param {Event} event - The keypress DOMEvent. + */ + triggerMessageHeaderRovingTab(event) { + // Expected keyboard actions are Left, Right, Home, End, Space, and Enter. + if (!["ArrowRight", "ArrowLeft", " ", "Enter"].includes(event.key)) { + return; + } + + const headerButtons = [...this.headerButtons]; + const focusableButton = headerButtons.find(b => b.tabIndex != -1); + let elementIndex = headerButtons.indexOf(focusableButton); + + // TODO: Remove once the buttons are updated to not be XUL + // NOTE: Normally a button click handler would cover Enter and Space key + // events. However, we need to prevent the default behavior and explicitly + // trigger the button click because the XUL toolbarbuttons do not work when + // the Enter key is pressed. They do work when the Space key is pressed. + // However, if the toolbarbutton is a dropdown menu, the Space key + // does not open the menu. + if ( + event.key == "Enter" || + (event.key == " " && event.target.hasAttribute("type")) + ) { + event.preventDefault(); + event.target.click(); + return; + } + + // Find the adjacent focusable element based on the pressed key. + const isRTL = document.dir == "rtl"; + if ( + (isRTL && event.key == "ArrowLeft") || + (!isRTL && event.key == "ArrowRight") + ) { + elementIndex++; + if (elementIndex > headerButtons.length - 1) { + elementIndex = 0; + } + } else if ( + (!isRTL && event.key == "ArrowLeft") || + (isRTL && event.key == "ArrowRight") + ) { + elementIndex--; + if (elementIndex == -1) { + elementIndex = headerButtons.length - 1; + } + } + + // Move the focus to a new toolbar button and update the tabindex attribute. + const newFocusableButton = headerButtons[elementIndex]; + if (newFocusableButton) { + focusableButton.tabIndex = -1; + newFocusableButton.setAttribute("tabindex", "0"); + newFocusableButton.focus(); + } + }, +}; diff --git a/comm/mail/base/content/multimessageview.xhtml b/comm/mail/base/content/multimessageview.xhtml new file mode 100644 index 0000000000..17241ce537 --- /dev/null +++ b/comm/mail/base/content/multimessageview.xhtml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="UTF-8"?> +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<!-- 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/. --> + +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd" [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%brandDTD; +<!ENTITY % startDTD SYSTEM "chrome://messenger/locale/multimessageview.dtd"> +%startDTD; ]> + +<html + xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <head> + <link + rel="stylesheet" + media="screen" + type="text/css" + href="chrome://messenger/skin/messenger.css" + /> + <link + rel="stylesheet" + media="screen" + type="text/css" + href="chrome://messenger/skin/primaryToolbar.css" + /> + <link + rel="stylesheet" + media="screen" + type="text/css" + href="chrome://messenger/skin/messageHeader.css" + /> + <link + rel="stylesheet" + media="screen, print" + type="text/css" + href="chrome://messenger/skin/multimessageview.css" + /> + <link rel="localization" href="messenger/multimessageview.ftl" /> + <title data-l10n-id="multi-message-window-title"></title> + <script src="chrome://messenger/content/multimessageview.js" /> + </head> + <body> + <div id="headingWrapper"> + <vbox + id="header-view-toolbox" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + > + <hbox id="header-view-toolbar" class="header-buttons-container"> + <toolbarbutton + id="hdrArchiveButton" + class="toolbarbutton-1 message-header-view-button hdrArchiveButton" + data-l10n-id="multi-message-archive-button" + /> + <toolbarbutton + id="hdrTrashButton" + class="toolbarbutton-1 message-header-view-button hdrTrashButton" + data-l10n-id="multi-message-delete-button" + /> + </hbox> + </vbox> + <h1 id="heading"> + <span id="summary_title" data-l10n-id="selected-messages-label"></span + >​ + <span id="summary_subtitle" /> + </h1> + </div> + <div id="content"> + <ul id="message_list" /> + <div id="footer"> + <span class="info" id="size" /> <span class="info" id="notice" /> + </div> + </div> + </body> +</html> diff --git a/comm/mail/base/content/newTagDialog.js b/comm/mail/base/content/newTagDialog.js new file mode 100644 index 0000000000..01af8892cc --- /dev/null +++ b/comm/mail/base/content/newTagDialog.js @@ -0,0 +1,108 @@ +/* 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 { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +var dialog; + +/** + * Pass in keyToEdit as a window argument to turn this dialog into an edit + * tag dialog. + */ +function onLoad() { + let windowArgs = window.arguments[0]; + + dialog = {}; + + dialog.OKButton = document.querySelector("dialog").getButton("accept"); + dialog.nameField = document.getElementById("name"); + dialog.nameField.focus(); + + // call this when OK is pressed + dialog.okCallback = windowArgs.okCallback; + if (windowArgs.keyToEdit) { + initializeForEditing(windowArgs.keyToEdit); + document.addEventListener("dialogaccept", onOKEditTag); + } else { + document.addEventListener("dialogaccept", onOKNewTag); + } + + doEnabling(); +} + +/** + * Turn the new tag dialog into an edit existing tag dialog + */ +function initializeForEditing(aTagKey) { + dialog.editTagKey = aTagKey; + + // Change the title of the dialog + var messengerBundle = document.getElementById("bundle_messenger"); + document.title = messengerBundle.getString("editTagTitle"); + + // extract the color and name for the current tag + document.getElementById("tagColorPicker").value = + MailServices.tags.getColorForKey(aTagKey); + dialog.nameField.value = MailServices.tags.getTagForKey(aTagKey); +} + +/** + * on OK handler for editing a new tag. + */ +function onOKEditTag(event) { + // get the tag name of the current key we are editing + let existingTagName = MailServices.tags.getTagForKey(dialog.editTagKey); + + // it's ok if the name didn't change + if (existingTagName != dialog.nameField.value) { + // don't let the user edit a tag to the name of another existing tag + if (MailServices.tags.getKeyForTag(dialog.nameField.value)) { + alertForExistingTag(); + event.preventDefault(); + return; + } + + MailServices.tags.setTagForKey(dialog.editTagKey, dialog.nameField.value); + } + + MailServices.tags.setColorForKey( + dialog.editTagKey, + document.getElementById("tagColorPicker").value + ); +} + +/** + * on OK handler for creating a new tag. Alerts the user if a tag with + * the name already exists. + */ +function onOKNewTag(event) { + var name = dialog.nameField.value; + + if (MailServices.tags.getKeyForTag(name)) { + alertForExistingTag(); + event.preventDefault(); + return; + } + if ( + !dialog.okCallback(name, document.getElementById("tagColorPicker").value) + ) { + event.preventDefault(); + } +} + +/** + * Alerts the user that they are trying to create a tag with a name that + * already exists. + */ +function alertForExistingTag() { + var messengerBundle = document.getElementById("bundle_messenger"); + var alertText = messengerBundle.getString("tagExists"); + Services.prompt.alert(window, document.title, alertText); +} + +function doEnabling() { + dialog.OKButton.disabled = !dialog.nameField.value; +} diff --git a/comm/mail/base/content/newTagDialog.xhtml b/comm/mail/base/content/newTagDialog.xhtml new file mode 100644 index 0000000000..38eccafec7 --- /dev/null +++ b/comm/mail/base/content/newTagDialog.xhtml @@ -0,0 +1,30 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/input-fields.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css" type="text/css"?> + +<!DOCTYPE window> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="tag-dialog-window" + lightweightthemes="true" + style="min-width: 25em;" + onload="onLoad();"> +<dialog> + + <stringbundle id="bundle_messenger" src="chrome://messenger/locale/messenger.properties"/> + + <script src="chrome://messenger/content/globalOverlay.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + <script src="chrome://messenger/content/newTagDialog.js"/> + <script src="chrome://messenger/content/dialogShadowDom.js"/> +#include tagDialog.inc.xhtml +</dialog> +</window> diff --git a/comm/mail/base/content/overrides/app-license-body.html b/comm/mail/base/content/overrides/app-license-body.html new file mode 100644 index 0000000000..4c4669bc32 --- /dev/null +++ b/comm/mail/base/content/overrides/app-license-body.html @@ -0,0 +1,1274 @@ +<!-- 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/. --> + +<hr /> + +<h1><a id="tb-apache"></a>Apache License 2.0</h1> + +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/@matrix-org/olm</code></li> + <li><code>chat/protocols/matrix/lib/another-json</code></li> + <li><code>chat/protocols/matrix/lib/matrix-events-sdk</code></li> + <li><code>chat/protocols/matrix/lib/matrix-sdk</code></li> + <li><code>chat/protocols/matrix/lib/matrix-widget-api</code></li> +</ul> + +<pre> + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS +</pre> + +<h1><a id="tb-bsd3clause"></a>BSD-3-Clause License</h1> + +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/libgcrypt/cipher/sha256-avx-amd64.S</code></li> + <li><code>third_party/libgcrypt/cipher/sha256-avx2-bmi2-amd64.S</code></li> + <li><code>third_party/libgcrypt/cipher/sha256-ssse3-amd64.S</code></li> + <li><code>third_party/libgcrypt/cipher/sha512-avx-amd64.S</code></li> + <li><code>third_party/libgcrypt/cipher/sha512-avx2-bmi2-amd64.S</code></li> + <li><code>third_party/libgcrypt/cipher/sha512-ssse3-amd64.S</code></li> +</ul> + +See the individual LICENSE files for copyright owners. + +<pre> + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + + * Neither the name of the Intel Corporation nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + + THIS SOFTWARE IS PROVIDED BY INTEL CORPORATION "AS IS" AND ANY + EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL INTEL CORPORATION OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/libgcrypt/random/jitterentropy-base.c</code></li> + <li><code>third_party/libgcrypt/random/jitterentropy.h</code></li> + <li> + <code + >third_party/libgcrypt/random/rndjent.c (plus common Libgcrypt copyright + holders)</code + > + </li> +</ul> + +<pre> + * Copyright Stephan Mueller <smueller@chronox.de>, 2013 + * + * License + * ======= + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, and the entire permission notice in its entirety, + * including the disclaimer of warranties. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. The name of the author may not be used to endorse or promote + * products derived from this software without specific prior + * written permission. + * + * ALTERNATIVELY, this product may be distributed under the terms of + * the GNU General Public License, in which case the provisions of the GPL are + * required INSTEAD OF the above restrictions. (This clause is + * necessary due to a potential bad interaction between the GPL and + * the restrictions contained in a BSD-style copyright.) + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ALL OF + * WHICH ARE HEREBY DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT + * OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + * USE OF THIS SOFTWARE, EVEN IF NOT ADVISED OF THE POSSIBILITY OF SUCH + * DAMAGE. +</pre> + +<h1><a id="tb-xlicense"></a>X License</h1> + +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/libgcrypt/install.sh</code></li> +</ul> + +<pre> + Copyright (C) 1994 X Consortium + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNEC- + TION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + Except as contained in this notice, the name of the X Consortium shall not + be used in advertising or otherwise to promote the sale, use or other deal- + ings in this Software without prior written authorization from the X Consor- + tium. +</pre> + +<h1><a id="tb-publicdomain"></a>Public domain</h1> + +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/libgcrypt/cipher/arcfour-amd64.S</code></li> +</ul> + +<pre> + Author: Marc Bevand <bevand_m (at) epita.fr> + Licence: I hereby disclaim the copyright on this code and place it + in the public domain. +</pre> + +<h1><a id="tb-ocblicense1"></a>OCB license 1</h1> + +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/libgcrypt/cipher/cipher-ocb.c</code></li> +</ul> + +<pre> + OCB is covered by several patents but may be used freely by most + software. See http://web.cs.ucdavis.edu/~rogaway/ocb/license.htm . + In particular license 1 is suitable for Libgcrypt: See + http://web.cs.ucdavis.edu/~rogaway/ocb/license1.pdf for the full + license document; it basically says: + + License 1 — License for Open-Source Software Implementations of OCB + (Jan 9, 2013) + + Under this license, you are authorized to make, use, and + distribute open-source software implementations of OCB. This + license terminates for you if you sue someone over their + open-source software implementation of OCB claiming that you have + a patent covering their implementation. + + + + License for Open Source Software Implementations of OCB + January 9, 2013 + + 1 Definitions + + 1.1 “Licensor” means Phillip Rogaway. + + 1.2 “Licensed Patents” means any patent that claims priority to United + States Patent Application No. 09/918,615 entitled “Method and Apparatus + for Facilitating Efficient Authenticated Encryption,” and any utility, + divisional, provisional, continuation, continuations-in-part, reexamination, + reissue, or foreign counterpart patents that may issue with respect to the + aforesaid patent application. This includes, but is not limited to, United + States Patent No. 7,046,802; United States Patent No. 7,200,227; United + States Patent No. 7,949,129; United States Patent No. 8,321,675 ; and any + patent that issues out of United States Patent Application No. 13/669,114. + + 1.3 “Use” means any practice of any invention claimed in the Licensed Patents. + + 1.4 “Software Implementation” means any practice of any invention + claimed in the Licensed Patents that takes the form of software executing on + a user-programmable, general-purpose computer or that takes the form of a + computer-readable medium storing such software. Software Implementation does + not include, for example, application-specific integrated circuits (ASICs), + field-programmable gate arrays (FPGAs), embedded systems, or IP cores. + + 1.5 “Open Source Software” means software whose source code is published + and made available for inspection and use by anyone because either (a) the + source code is subject to a license that permits recipients to copy, modify, + and distribute the source code without payment of fees or royalties, or + (b) the source code is in the public domain, including code released for + public use through a CC0 waiver. All licenses certified by the Open Source + Initiative at opensource.org as of January 9, 2013 and all Creative Commons + licenses identified on the creativecommons.org website as of January 9, + 2013, including the Public License Fallback of the CC0 waiver, satisfy these + requirements for the purposes of this license. + + 1.6 “Open Source Software Implementation” means a Software + Implementation in which the software implicating the Licensed Patents is + Open Source Software. Open Source Software Implementation does not include + any Software Implementation in which the software implicating the Licensed + Patents is combined, so as to form a larger program, with software that is + not Open Source Software. + + 2 License Grant + + 2.1 License. Subject to your compliance with the term s of this license, + including the restriction set forth in Section 2.2, Licensor hereby + grants to you a perpetual, worldwide, non-exclusive, non-transferable, + non-sublicenseable, no-charge, royalty-free, irrevocable license to practice + any invention claimed in the Licensed Patents in any Open Source Software + Implementation. + + 2.2 Restriction. If you or your affiliates institute patent litigation + (including, but not limited to, a cross-claim or counterclaim in a lawsuit) + against any entity alleging that any Use authorized by this license + infringes another patent, then any rights granted to you under this license + automatically terminate as of the date such litigation is filed. + + 3 Disclaimer + YOUR USE OF THE LICENSED PATENTS IS AT YOUR OWN RISK AND UNLESS REQUIRED + BY APPLICABLE LAW, LICENSOR MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY + KIND CONCERNING THE LICENSED PATENTS OR ANY PRODUCT EMBODYING ANY LICENSED + PATENT, EXPRESS OR IMPLIED, STATUT ORY OR OTHERWISE, INCLUDING, WITHOUT + LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR + PURPOSE, OR NONINFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY + CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, + ARISING FROM OR RELATED TO ANY USE OF THE LICENSED PATENTS, INCLUDING, + WITHOUT LIMITATION, DIRECT, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE + OR SPECIAL DAMAGES, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF + SUCH DAMAGES PRIOR TO SUCH AN OCCURRENCE. +</pre> + +<h1><a id="tb-bzip2license"></a>Bzip2 License</h1> + +<p>This license applies to files in <code>third_party/bzip2</code>.</p> + +<pre> +This program, "bzip2", the associated library "libbzip2", and all +documentation, are copyright (C) 1996-2019 Julian R Seward. All +rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. The origin of this software must not be misrepresented; you must + not claim that you wrote the original software. If you use this + software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +3. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + +4. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Julian Seward, jseward@acm.org +bzip2/libbzip2 version 1.0.8 of 13 July 2019 + </pre +> + +<h1><a id="tb-jsonclicense"></a>Json-C License</h1> + +<p>This license applies to files in <code>third_party/json-c</code>.</p> + +<pre> + +Copyright (c) 2009-2012 Eric Haszlakiewicz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---------------------------------------------------------------- + +Copyright (c) 2004, 2005 Metaparadigm Pte Ltd + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + </pre +> + +<h1><a id="tb-botanlicense"></a>Botan License</h1> + +<p>This license applies to files in <code>third_party/botan</code>.</p> + +<pre> +Copyright (C) 1999-2020 The Botan Authors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions, and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions, and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + </pre +> + +<h1><a id="tb-rnplicense"></a>RNP Licenses</h1> + +<p>These licenses apply to files in <code>third_party/rnp</code>.</p> + +<h2>Ribose's BSD 2-Clause License</h2> + +<pre> +Copyright (c) 2017, <a href="https://www.ribose.com">Ribose Inc</a>. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + </pre> + +<h2>NetBSD's BSD 2-Clause License</h2> +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/rnpinclude/rekey/rnp_key_store.h</code></li> + <li><code>third_party/rnpinclude/repgp/repgp_def.h</code></li> + <li><code>third_party/rnpinclude/rnp.h</code></li> + <li><code>third_party/rnpinclude/rnp/rnp_sdk.h</code></li> + <li><code>third_party/rnpsrc/librekey/key_store_pgp.h</code></li> + <li><code>third_party/rnpsrc/librekey/key_store_pgp.cpp</code></li> + <li><code>third_party/rnpsrc/librekey/rnp_key_store.cpp</code></li> + <li><code>third_party/rnpsrc/rnpkeys/main.cpp</code></li> + <li><code>third_party/rnpsrc/rnpkeys/rnpkeys.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto.cpp</code></li> + <li><code>third_party/rnpsrc/lib/pgp-key.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto.h</code></li> + <li><code>third_party/rnpsrc/lib/types.h</code></li> + <li><code>third_party/rnpsrc/lib/misc.cpp</code></li> + <li><code>third_party/rnpsrc/lib/pgp-key.h</code></li> + <li><code>third_party/rnpsrc/lib/fingerprint.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto/ec.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/s2k.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto/hash.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto/symmetric.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/s2k.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/bn.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/rng.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/rng.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto/dsa.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto/symmetric.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto/elgamal.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/bn.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto/hash.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/dsa.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/rsa.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/elgamal.cpp</code></li> + <li><code>third_party/rnpsrc/lib/crypto/eddsa.h</code></li> + <li><code>third_party/rnpsrc/lib/crypto/rsa.cpp</code></li> + <li><code>third_party/rnpsrc/rnp/rnp.cpp</code></li> +</ul> + +<pre> +This software contains source code originating from NetPGP, which +carries the following copyright notice and license. + +Copyright (c) 2009-2016, <a href="https://www.netbsd.org">The NetBSD Foundation, Inc</a>. +All rights reserved. + +This code is derived from software contributed to The NetBSD Foundation +by Alistair Crooks <agc at NetBSD.org> + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +</pre> + +<h2>Nominet UK's Apache 2.0 Licence</h2> +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/rnp/src/librekey/key_store_pgp.cpp</code></li> + <li><code>third_party/rnp/src/librekey/key_store_pgp.h</code></li> + <li><code>third_party/rnp/src/lib/crypto.cpp</code></li> + <li><code>third_party/rnp/src/lib/crypto.h</code></li> + <li><code>third_party/rnp/src/lib/misc.cpp</code></li> + <li><code>third_party/rnp/src/lib/pgp-key.cpp</code></li> + <li><code>third_party/rnp/src/lib/pgp-key.h</code></li> + <li><code>third_party/rnp/src/lib/types.h</code></li> + <li><code>third_party/rnp/src/lib/crypto/dsa.cpp</code></li> + <li><code>third_party/rnp/src/lib/crypto/elgamal.cpp</code></li> + <li><code>third_party/rnp/src/lib/crypto/hash.cpp</code></li> + <li><code>third_party/rnp/src/lib/crypto/rsa.cpp</code></li> + <li><code>third_party/rnp/src/lib/crypto/symmetric.cpp</code></li> + <li><code>third_party/rnp/src/lib/crypto/symmetric.h</code></li> +</ul> + +<pre> +This software contains source code originating from NetPGP, which +carries the following copyright notice and license. + +Copyright (c) 2005-2008 <a href="http://www.nic.uk">Nominet UK</a> +All rights reserved. + +Contributors: Ben Laurie, Rachel Willmer. The Contributors have asserted +their moral rights under the UK Copyright Design and Patents Act 1988 to +be recorded as the authors of this copyright work. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. + </pre> + +<h2>Nominet UK's BSD 3-Clause License</h2> + +<pre> +This software contains source code originating from NetPGP, which +carries the following copyright notice and license. + +Copyright (c) 2005 <a href="http://www.nic.uk">Nominet UK</a> +All rights reserved. + +Contributors: Ben Laurie, Rachel Willmer. The Contributors have asserted +their moral rights under the UK Copyright Design and Patents Act 1988 to +be recorded as the authors of this copyright work. + +This is a BSD-style Open Source licence. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. The name of Nominet UK or the contributors may not be used to + endorse or promote products derived from this software without specific + prior written permission; + +and provided that the user accepts the terms of the following disclaimer: + +THIS SOFTWARE IS PROVIDED BY NOMINET UK AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL NOMINET UK OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + </pre> + +<h2>OCB Patent License for Ribose Inc.</h2> + +<pre> +This license has been graciously granted by Professor Phillip Rogaway to allow +users of "rnp" to utilize the patented OCB blockcipher mode of operation, +which simultaneously provides privacy and authenticity. + +The license text is presented below in plain text form purely for referencial +purposes. The original signed license is available on request from Ribose Inc., +reachable at open.source@ribose.com. + +1. Definitions + +1.1 "Licensor" means Phillip Rogaway, of 1212 Purdue Dr., Davis, California, USA. + +1.2 "Licensed Patents" means any patent that claims priority to United States +Patent Application No. 09/918,615 entitled "Method and Apparatus for +Facilitating Efficient Authenticated Encryption," and any utility, divisional, +provisional, continuation, continuations in part, reexamination, reissue, or +foreign counterpart patents that may issue with respect to the aforesaid patent +application. This includes, but is not limited to, United States Patent No. +7,046,802; United States Patent No. 7,200,227; United States Patent No. +7,949,129; United States Patent No. 8,321,675; and any patent that issues out +or United States Patent Application No. 13/669,114. + +1.3 "Licensee" means Ribose Inc., at Suite 1, 8/F, 10 Ice House Street, +Central, Hong Kong, its affiliates, assignees, or successors in interest, or +anyone using, making, copying, modifying, distributing, having made, importing, +or having imported any program, software, or computer system including or based +upon Open Source Software published by Ribose Inc., or their customers, +suppliers, importers, manufacturers, distributors, or insurers. + +1.4 "Use in Licensee Products" means using, making, copying, modifying, +distributing, having made, importing or having imported any program, software, +or computer system published by Licensee, which contains or is based upon Open +Source Software which may include any implementation of the Licensed Patents. + +1.5 "Open Source Software" means software whose source code is published and +made available for inspection and use by anyone because either (a) the source +code is subject to a license that permits recipients to copy, modify, and +distribute the source code without payment of fees or royalties, or (b) the +source code is in the public domain, including code released for public use +through a CC0 waiver. All licenses certified by the Open Source Initiative at +opensource.org as of January 1, 2017 and all Creative Commons licenses +identified on the creativecommons.org website as of January 1, 2017, including +the Public License Fallback of the CC0 waiver, satisfy these requirements for +the purposes of this license. + +2. Grant of License + +2.1 Licensor hereby grants to Licensee a perpetual, worldwide, non-exclusive, +nontransferable, non-sublicenseable, no-charge, royalty-free, irrevocable +license to Use in Licensee Products any invention claimed in the Licensed +Patents in any Open Source Software Implementation and in hardware as long as +the Open Source Software incorporated in such hardware is freely licensed for +hardware embodiment. + +3. Disclaimer + +3.1 LICENSEE'S USE OF THE LICENSED PATENTS IS AT LICENSEE'S OWN RISK AND UNLESS +REQUIRED BY APPLICABLE LAW, LICENSOR MAKES NO REPRESENTATIONS OR WARRANTIES OF +ANY KIND CONCERNING THE LICENSED PATENTS OR ANY PRODUCT EMBODYING ANY LICENSED +PATENT, EXPRESS OR IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT +LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR +PURPOSE, OR NONINFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING +FROM OR RELATED TO ANY USE OF THE LICENSED PATENTS, INCLUDING, WITHOUT +LIMITATION, DIRECT, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR SPECIAL +DAMAGES, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES +PRIOR TO SUCH AN OCCURRENCE. + +[SIGNATURE by Phillip Rogaway] + +Date: August 28, 2017 + + </pre +> + +<h1><a id="tb-direntlicense"></a>Dirent License</h1> + +<p>This license applies to <code>third_party/niwcompat/dirent.h</code>.</p> + +<pre> +The MIT License (MIT) + +Copyright (c) 1998-2019 Toni Ronkko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + </pre +> + +<h1><a id="tb-mapiheaders"></a>MAPI Headers License</h1> + +<p>This license applies to <code>mailnews/mapi/include/</code>.</p> + +<pre> +MIT License + +Copyright (c) 2018 Microsoft + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + </pre +> + +<h1><a id="tb-getopt">getopt.c License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/niwcompat/getopt.c</code></li> + <li><code>third_party/niwcompat/getopt.h</code></li> +</ul> +<pre> +Copyright (c) 1987, 1993, 1994, 1996 + The Regents of the University of California. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the names of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ``AS +IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + </pre +> + +<h1><a id="tb-asn1js">ASN1.js License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>third_party/asn1js/</code></li> +</ul> +<pre> +Copyright (c) 2014, GMO GlobalSign +Copyright (c) 2015-2022, Peculiar Ventures +All rights reserved. + +Author 2014-2019, Yury Strozhevsky + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + </pre +> + +<h1><a id="tb-base-x">base-x License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/base-x</code></li> +</ul> +<pre> +The MIT License (MIT) + +Copyright (c) 2018 base-x contributors +Copyright (c) 2014-2018 The Bitcoin Core developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + </pre +> + +<h1><a id="tb-bs58">bs58 License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/bs58</code></li> +</ul> +<pre> +MIT License + +Copyright (c) 2018 cryptocoinjs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + </pre +> + +<h1><a id="tb-content-type">content-type License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/content-type</code></li> +</ul> +<pre> +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + </pre +> + +<h1><a id="tb-events">events License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/events</code></li> +</ul> +<pre> +MIT + +Copyright Joyent, Inc. and other Node contributors. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. + </pre +> + +<h1><a id="tb-p-retry">p-retry License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/p-retry</code></li> +</ul> +<pre> +MIT License + +Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. + </pre +> + +<h1><a id="tb-retry">retry License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/retry</code></li> +</ul> +<pre> +Copyright (c) 2011: +Tim Koschützki (tim@debuggable.com) +Felix Geisendörfer (felix@debuggable.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + </pre +> + +<h1><a id="tb-unhomoglyph">unhomoglyph License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/unhomoglyph</code></li> +</ul> +<pre> +Copyright (c) 2016 Vitaly Puzrin. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + </pre +> + +<h1><a id="tb-sax">sax License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/xmpp/lib/sax</code></li> +</ul> +<pre> +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==== + +`String.fromCodePoint` by Mathias Bynens used according to terms of MIT +License, as follows: + + Copyright Mathias Bynens <https://mathiasbynens.be/> + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + </pre +> + +<h1><a id="tb-lgpl">GNU Lesser General Public License 2.1</a></h1> +<p>This product contains code from the following LGPLed libraries:</p> +<ul> + <li><a href="https://www.gnupg.org/ftp/gcrypt/libgcrypt/">libgcrypt</a></li> + <li> + <a href="https://www.gnupg.org/ftp/gcrypt/libgpg-error/">gpg-error</a> + </li> + <li><a href="https://otr.cypherpunks.ca/">libotr</a></li> +</ul> + +(These libraries only ship in some versions of this product.) +<a href="#lgpl">Read the license above.</a> + +<h1><a id="tb-sdp-transform">sdp-transform License</a></h1> +<p>This license applies to the following files:</p> +<ul> + <li><code>chat/protocols/matrix/lib/sdp-transform</code></li> +</ul> +<pre> +(The MIT License) + +Copyright (c) 2013 Eirik Albrigtsen + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + </pre +> diff --git a/comm/mail/base/content/overrides/app-license-list.html b/comm/mail/base/content/overrides/app-license-list.html new file mode 100644 index 0000000000..19cbec0b01 --- /dev/null +++ b/comm/mail/base/content/overrides/app-license-list.html @@ -0,0 +1,33 @@ +<!-- 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/. --> + +<br /> + +<ul> + <li><a href="about:license#tb-apache">Apache License 2.0</a></li> + <li><a href="about:license#tb-bsd3clause">BSD-3-Clause License</a></li> + <li><a href="about:license#tb-xlicense">X License</a></li> + <li><a href="about:license#tb-publicdomain">Public domain</a></li> + <li><a href="about:license#tb-ocblicense1">OCB license 1</a></li> + <li><a href="about:license#tb-bzip2license">Bzip2 License</a></li> + <li><a href="about:license#tb-jsonclicense">Json-C License</a></li> + <li><a href="about:license#tb-botanlicense">Botan License</a></li> + <li><a href="about:license#tb-rnplicense">RNP Licenses</a></li> + <li><a href="about:license#tb-direntlicense">Dirent License</a></li> + <li><a href="about:license#tb-mapiheaders">MAPI Headers License</a></li> + <li><a href="about:license#tb-getopt">Getopt.c License</a></li> + <li><a href="about:license#tb-asn1js">ASN1.js License</a></li> + <li><a href="about:license#tb-base-x">base-x License</a></li> + <li><a href="about:license#tb-bs58">bs58 License</a></li> + <li><a href="about:license#tb-content-type">content-type License</a></li> + <li><a href="about:license#tb-events">events License</a></li> + <li><a href="about:license#tb-p-retry">p-retry License</a></li> + <li><a href="about:license#tb-retry">retry License</a></li> + <li><a href="about:license#tb-unhomoglyph">unhomoglyph License</a></li> + <li><a href="about:license#tb-sax">sax License</a></li> + <li> + <a href="about:license#tb-lgpl">GNU Lesser General Public License 2.1</a> + </li> + <li><a href="about:license#tb-sdp-transform">sdp-transform License</a></li> +</ul> diff --git a/comm/mail/base/content/overrides/app-license-name.html b/comm/mail/base/content/overrides/app-license-name.html new file mode 100644 index 0000000000..2bcdf72c40 --- /dev/null +++ b/comm/mail/base/content/overrides/app-license-name.html @@ -0,0 +1 @@ +Thunderbird diff --git a/comm/mail/base/content/overrides/app-license.html b/comm/mail/base/content/overrides/app-license.html new file mode 100644 index 0000000000..4a66b5bc53 --- /dev/null +++ b/comm/mail/base/content/overrides/app-license.html @@ -0,0 +1,9 @@ +<!-- 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/. --> + +<p> + <b>Binaries</b> of this product have been made available to you by the + <a href="https://www.thunderbird.net/">Thunderbird Project</a> under the + Mozilla Public License. <a href="about:rights">Know your rights</a>. +</p> diff --git a/comm/mail/base/content/printUtils.js b/comm/mail/base/content/printUtils.js new file mode 100644 index 0000000000..129cc6a41a --- /dev/null +++ b/comm/mail/base/content/printUtils.js @@ -0,0 +1,428 @@ +/* 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 { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + SubDialogManager: "resource://gre/modules/SubDialog.sys.mjs", +}); + +var { MailE10SUtils } = ChromeUtils.import( + "resource:///modules/MailE10SUtils.jsm" +); + +// Load PrintUtils lazily and modify it to suit. +XPCOMUtils.defineLazyGetter(this, "PrintUtils", () => { + let scope = {}; + Services.scriptloader.loadSubScript( + "chrome://global/content/printUtils.js", + scope + ); + scope.PrintUtils.getTabDialogBox = function (browser) { + if (!browser.tabDialogBox) { + browser.tabDialogBox = new TabDialogBox(browser); + } + return browser.tabDialogBox; + }; + scope.PrintUtils.createBrowser = function ({ + remoteType, + initialBrowsingContextGroupId, + userContextId, + skipLoad, + initiallyActive, + } = {}) { + let b = document.createXULElement("browser"); + // Use the JSM global to create the permanentKey, so that if the + // permanentKey is held by something after this window closes, it + // doesn't keep the window alive. + b.permanentKey = new (Cu.getGlobalForObject(Services).Object)(); + + const defaultBrowserAttributes = { + maychangeremoteness: "true", + messagemanagergroup: "browsers", + type: "content", + }; + for (let attribute in defaultBrowserAttributes) { + b.setAttribute(attribute, defaultBrowserAttributes[attribute]); + } + + if (userContextId) { + b.setAttribute("usercontextid", userContextId); + } + + if (remoteType) { + b.setAttribute("remoteType", remoteType); + b.setAttribute("remote", "true"); + } + + // Ensure that the browser will be created in a specific initial + // BrowsingContextGroup. This may change the process selection behaviour + // of the newly created browser, and is often used in combination with + // "remoteType" to ensure that the initial about:blank load occurs + // within the same process as another window. + if (initialBrowsingContextGroupId) { + b.setAttribute( + "initialBrowsingContextGroupId", + initialBrowsingContextGroupId + ); + } + + // We set large flex on both containers to allow the devtools toolbox to + // set a flex attribute. We don't want the toolbox to actually take up free + // space, but we do want it to collapse when the window shrinks, and with + // flex=0 it can't. When the toolbox is on the bottom it's a sibling of + // browserStack, and when it's on the side it's a sibling of + // browserContainer. + let stack = document.createXULElement("stack"); + stack.className = "browserStack"; + stack.appendChild(b); + + let browserContainer = document.createXULElement("vbox"); + browserContainer.className = "browserContainer"; + browserContainer.appendChild(stack); + + let browserSidebarContainer = document.createXULElement("hbox"); + browserSidebarContainer.className = "browserSidebarContainer"; + browserSidebarContainer.appendChild(browserContainer); + + // Prevent the superfluous initial load of a blank document + // if we're going to load something other than about:blank. + if (skipLoad) { + b.setAttribute("nodefaultsrc", "true"); + } + + return b; + }; + + scope.PrintUtils.__defineGetter__("printBrowser", () => + document.getElementById("hiddenPrintContent") + ); + scope.PrintUtils.loadPrintBrowser = async function (url) { + let printBrowser = this.printBrowser; + if (printBrowser.currentURI?.spec == url) { + return; + } + + // The template page hasn't been loaded yet. Do that now. + await new Promise(resolve => { + // Store a strong reference to this progress listener. + printBrowser.progressListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + /** nsIWebProgressListener */ + onStateChange(webProgress, request, stateFlags, status) { + if ( + stateFlags & Ci.nsIWebProgressListener.STATE_STOP && + printBrowser.currentURI.spec != "about:blank" + ) { + printBrowser.webProgress.removeProgressListener(this); + delete printBrowser.progressListener; + resolve(); + } + }, + }; + + printBrowser.webProgress.addProgressListener( + printBrowser.progressListener, + Ci.nsIWebProgress.NOTIFY_STATE_ALL + ); + MailE10SUtils.loadURI(printBrowser, url); + }); + }; + return scope.PrintUtils; +}); + +/** + * The TabDialogBox supports opening window dialogs as SubDialogs on the tab and content + * level. Both tab and content dialogs have their own separate managers. + * Dialogs will be queued FIFO and cover the web content. + * Dialogs are closed when the user reloads or leaves the page. + * While a dialog is open PopupNotifications, such as permission prompts, are + * suppressed. + */ +class TabDialogBox { + constructor(browser) { + this._weakBrowserRef = Cu.getWeakReference(browser); + + // Create parent element for tab dialogs + let template = document.getElementById("dialogStackTemplate"); + this.dialogStack = template.content.cloneNode(true).firstElementChild; + this.dialogStack.classList.add("tab-prompt-dialog"); + + while (browser.ownerDocument != document) { + // Find an ancestor <browser> in this document so that we can locate the + // print preview appropriately. + browser = browser.ownerGlobal.browsingContext.embedderElement; + } + + // This differs from Firefox by using a specific ancestor <stack> rather + // than the parent of the <browser>, so that a larger area of the screen + // is used for the preview. + this.printPreviewStack = document.querySelector(".printPreviewStack"); + if (this.printPreviewStack && this.printPreviewStack.contains(browser)) { + this.printPreviewStack.appendChild(this.dialogStack); + } else { + this.printPreviewStack = this.browser.parentNode; + this.browser.parentNode.insertBefore( + this.dialogStack, + this.browser.nextElementSibling + ); + } + + // Initially the stack only contains the template + let dialogTemplate = this.dialogStack.firstElementChild; + + // Create dialog manager for prompts at the tab level. + this._tabDialogManager = new SubDialogManager({ + dialogStack: this.dialogStack, + dialogTemplate, + orderType: SubDialogManager.ORDER_QUEUE, + allowDuplicateDialogs: true, + dialogOptions: { + consumeOutsideClicks: false, + }, + }); + } + + /** + * Open a dialog on tab or content level. + * + * @param {string} aURL - URL of the dialog to load in the tab box. + * @param {object} [aOptions] + * @param {string} [aOptions.features] - Comma separated list of window + * features. + * @param {boolean} [aOptions.allowDuplicateDialogs] - Whether to allow + * showing multiple dialogs with aURL at the same time. If false calls for + * duplicate dialogs will be dropped. + * @param {string} [aOptions.sizeTo] - Pass "available" to stretch dialog to + * roughly content size. + * @param {boolean} [aOptions.keepOpenSameOriginNav] - By default dialogs are + * aborted on any navigation. + * Set to true to keep the dialog open for same origin navigation. + * @param {number} [aOptions.modalType] - The modal type to create the dialog for. + * By default, we show the dialog for tab prompts. + * @returns {object} [result] Returns an object { closedPromise, dialog }. + * @returns {Promise} [result.closedPromise] Resolves once the dialog has been closed. + * @returns {SubDialog} [result.dialog] A reference to the opened SubDialog. + */ + open( + aURL, + { + features = null, + allowDuplicateDialogs = true, + sizeTo, + keepOpenSameOriginNav, + modalType = null, + allowFocusCheckbox = false, + } = {}, + ...aParams + ) { + let resolveClosed; + let closedPromise = new Promise(resolve => (resolveClosed = resolve)); + // Get the dialog manager to open the prompt with. + let dialogManager = + modalType === Ci.nsIPrompt.MODAL_TYPE_CONTENT + ? this.getContentDialogManager() + : this._tabDialogManager; + let hasDialogs = + this._tabDialogManager.hasDialogs || + this._contentDialogManager?.hasDialogs; + + if (!hasDialogs) { + this._onFirstDialogOpen(); + } + + let closingCallback = event => { + if (!hasDialogs) { + this._onLastDialogClose(); + } + + if (allowFocusCheckbox && !event.detail?.abort) { + this.maybeSetAllowTabSwitchPermission(event.target); + } + }; + + if (modalType == Ci.nsIPrompt.MODAL_TYPE_CONTENT) { + sizeTo = "limitheight"; + } + + // Open dialog and resolve once it has been closed + let dialog = dialogManager.open( + aURL, + { + features, + allowDuplicateDialogs, + sizeTo, + closingCallback, + closedCallback: resolveClosed, + }, + ...aParams + ); + + // Marking the dialog externally, instead of passing it as an option. + // The SubDialog(Manager) does not care about navigation. + // dialog can be null here if allowDuplicateDialogs = false. + if (dialog) { + dialog._keepOpenSameOriginNav = keepOpenSameOriginNav; + } + return { closedPromise, dialog }; + } + + _onFirstDialogOpen() { + for (let element of this.printPreviewStack.children) { + if (element != this.dialogStack) { + element.setAttribute("tabDialogShowing", true); + } + } + + // Register listeners + this._lastPrincipal = this.browser.contentPrincipal; + if ("addProgressListener" in this.browser) { + this.browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); + } + } + + _onLastDialogClose() { + for (let element of this.printPreviewStack.children) { + if (element != this.dialogStack) { + element.removeAttribute("tabDialogShowing"); + } + } + + // Clean up listeners + if ("removeProgressListener" in this.browser) { + this.browser.removeProgressListener(this); + } + this._lastPrincipal = null; + } + + _buildContentPromptDialog() { + let template = document.getElementById("dialogStackTemplate"); + let contentDialogStack = template.content.cloneNode(true).firstElementChild; + contentDialogStack.classList.add("content-prompt-dialog"); + + // Create a dialog manager for content prompts. + let tabPromptDialog = + this.browser.parentNode.querySelector(".tab-prompt-dialog"); + this.browser.parentNode.insertBefore(contentDialogStack, tabPromptDialog); + + let contentDialogTemplate = contentDialogStack.firstElementChild; + this._contentDialogManager = new SubDialogManager({ + dialogStack: contentDialogStack, + dialogTemplate: contentDialogTemplate, + orderType: SubDialogManager.ORDER_QUEUE, + allowDuplicateDialogs: true, + dialogOptions: { + consumeOutsideClicks: false, + }, + }); + } + + handleEvent(event) { + if (event.type !== "TabClose") { + return; + } + this.abortAllDialogs(); + } + + abortAllDialogs() { + this._tabDialogManager.abortDialogs(); + this._contentDialogManager?.abortDialogs(); + } + + focus() { + // Prioritize focusing the dialog manager for tab prompts + if (this._tabDialogManager._dialogs.length) { + this._tabDialogManager.focusTopDialog(); + return; + } + this._contentDialogManager?.focusTopDialog(); + } + + /** + * If the user navigates away or refreshes the page, close all dialogs for + * the current browser. + */ + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + if ( + !aWebProgress.isTopLevel || + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ) { + return; + } + + // Dialogs can be exempt from closing on same origin location change. + let filterFn; + + // Test for same origin location change + if ( + this._lastPrincipal?.isSameOrigin( + aLocation, + this.browser.browsingContext.usePrivateBrowsing + ) + ) { + filterFn = dialog => !dialog._keepOpenSameOriginNav; + } + + this._lastPrincipal = this.browser.contentPrincipal; + + this._tabDialogManager.abortDialogs(filterFn); + this._contentDialogManager?.abortDialogs(filterFn); + } + + get tab() { + return document.getElementById("tabmail").getTabForBrowser(this.browser); + } + + get browser() { + let browser = this._weakBrowserRef.get(); + if (!browser) { + throw new Error("Stale dialog box! The associated browser is gone."); + } + return browser; + } + + getTabDialogManager() { + return this._tabDialogManager; + } + + getContentDialogManager() { + if (!this._contentDialogManager) { + this._buildContentPromptDialog(); + } + return this._contentDialogManager; + } + + onNextPromptShowAllowFocusCheckboxFor(principal) { + this._allowTabFocusByPromptPrincipal = principal; + } + + /** + * Sets the "focus-tab-by-prompt" permission for the dialog. + */ + maybeSetAllowTabSwitchPermission(dialog) { + let checkbox = dialog.querySelector("checkbox"); + + if (checkbox.checked) { + Services.perms.addFromPrincipal( + this._allowTabFocusByPromptPrincipal, + "focus-tab-by-prompt", + Services.perms.ALLOW_ACTION + ); + } + + // Don't show the "allow tab switch checkbox" for subsequent prompts. + this._allowTabFocusByPromptPrincipal = null; + } +} + +TabDialogBox.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", +]); diff --git a/comm/mail/base/content/profileDowngrade.js b/comm/mail/base/content/profileDowngrade.js new file mode 100644 index 0000000000..3a9038e8e5 --- /dev/null +++ b/comm/mail/base/content/profileDowngrade.js @@ -0,0 +1,53 @@ +/* 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/. */ + +let gParams; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +window.addEventListener("load", event => { + init(); +}); + +function init() { + /* + * The C++ code passes a dialog param block using its integers as in and out + * arguments for this UI. The following are the uses of the integers: + * + * 0: A set of flags from nsIToolkitProfileService.downgradeUIFlags. + * 1: A return argument, one of nsIToolkitProfileService.downgradeUIChoice. + */ + gParams = window.arguments[0].QueryInterface(Ci.nsIDialogParamBlock); + + document.addEventListener("dialogextra1", createProfile); + document.addEventListener("dialogaccept", quit); + document.addEventListener("dialogcancel", quit); + + document.querySelector("dialog").getButton("accept").focus(); +} + +function quit() { + gParams.SetInt(1, Ci.nsIToolkitProfileService.quit); +} + +function createProfile() { + gParams.SetInt(1, Ci.nsIToolkitProfileService.createNewProfile); + window.close(); +} + +function moreInfo(event) { + if (event.type == "keypress" && event.key != "Enter") { + return; + } + event.preventDefault(); + + let uri = Services.io.newURI( + "https://support.mozilla.org/kb/unable-launch-older-version-profile" + ); + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(uri); +} diff --git a/comm/mail/base/content/profileDowngrade.xhtml b/comm/mail/base/content/profileDowngrade.xhtml new file mode 100644 index 0000000000..4768f930fb --- /dev/null +++ b/comm/mail/base/content/profileDowngrade.xhtml @@ -0,0 +1,48 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/profileDowngrade.css" type="text/css"?> + +<!DOCTYPE html [ +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +%brandDTD; +<!ENTITY % profileDTD SYSTEM "chrome://messenger/locale/profileDowngrade.dtd"> +%profileDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + scrolling="false"> +<head> + <title>&window.title;</title> + <script defer="defer" src="chrome://global/content/customElements.js"></script> + <script defer="defer" src="profileDowngrade.js"></script> +</head> +<body> +<xul:dialog buttonlabelextra1="&window.create;" +#ifdef XP_WIN + buttonlabelaccept="&window.quit-win;" +#else + buttonlabelaccept="&window.quit-nonwin;" +#endif + buttons="accept,extra1" buttonpack="end" + nobuttonspacer="true"> + <div id="contentWrapper"> + <img src="chrome://messenger/skin/icons/new/activity/info.svg" + alt="" + role="presentation" /> + <div> + <p id="nosync">&window.nosync2;</p> + <p> + <a class="text-link" + onclick="moreInfo(event);" + onkeypress="moreInfo(event);">&window.moreinfo;</a> + </p> + </div> + </div> +</xul:dialog> +</body> +</html> diff --git a/comm/mail/base/content/protovis-r2.6-modded.js b/comm/mail/base/content/protovis-r2.6-modded.js new file mode 100644 index 0000000000..547dedd17a --- /dev/null +++ b/comm/mail/base/content/protovis-r2.6-modded.js @@ -0,0 +1,5349 @@ +/* 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 pv = function () { +/** + * @namespace The Protovis namespace, <tt>pv</tt>. All public methods and fields + * should be registered on this object. Note that core Protovis source is + * surrounded by an anonymous function, so any other declared globals will not + * be visible outside of core methods. This also allows multiple versions of + * Protovis to coexist, since each version will see their own <tt>pv</tt> + * namespace. + */ +var pv = {}; + +/** + * Returns a prototype object suitable for extending the given class + * <tt>f</tt>. Rather than constructing a new instance of <tt>f</tt> to serve as + * the prototype (which unnecessarily runs the constructor on the created + * prototype object, potentially polluting it), an anonymous function is + * generated internally that shares the same prototype: + * + * <pre>function g() {} + * g.prototype = f.prototype; + * return new g();</pre> + * + * For more details, see Douglas Crockford's essay on prototypal inheritance. + * + * @param {function} f a constructor. + * @returns a suitable prototype object. + * @see Douglas Crockford's essay on <a + * href="http://javascript.crockford.com/prototypal.html">prototypal + * inheritance</a>. + */ +pv.extend = function(f) { + function g() {} + g.prototype = f.prototype; + return new g(); +}; + +/** + * Returns the passed-in argument, <tt>x</tt>; the identity function. This method + * is provided for convenience since it is used as the default behavior for a + * number of property functions. + * + * @param x a value. + * @returns the value <tt>x</tt>. + */ +pv.identity = function(x) { return x; }; + +/** + * Returns an array of numbers, starting at <tt>start</tt>, incrementing by + * <tt>step</tt>, until <tt>stop</tt> is reached. The stop value is exclusive. If + * only a single argument is specified, this value is interpreted as the + * <i>stop</i> value, with the <i>start</i> value as zero. If only two arguments + * are specified, the step value is implied to be one. + * + * <p>The method is modeled after the built-in <tt>range</tt> method from + * Python. See the Python documentation for more details. + * + * @see <a href="http://docs.python.org/library/functions.html#range">Python range</a>. + * @param {number} [start] the start value. + * @param {number} stop the stop value. + * @param {number} [step] the step value. + * @returns {number[]} an array of numbers. + */ +pv.range = function(start, stop, step) { + if (arguments.length == 1) { + stop = start; + start = 0; + } + if (step == undefined) step = 1; + else if (!step) throw new Error("step must be non-zero"); + var array = [], i = 0, j; + if (step < 0) { + while ((j = start + step * i++) > stop) { + array.push(j); + } + } else { + while ((j = start + step * i++) < stop) { + array.push(j); + } + } + return array; +}; + +/** + * Given two arrays <tt>a</tt> and <tt>b</tt>, returns an array of all possible + * pairs of elements [a<sub>i</sub>, b<sub>j</sub>]. The outer loop is on array + * <i>a</i>, while the inner loop is on <i>b</i>, such that the order of + * returned elements is [a<sub>0</sub>, b<sub>0</sub>], [a<sub>0</sub>, + * b<sub>1</sub>], ... [a<sub>0</sub>, b<sub>m</sub>], [a<sub>1</sub>, + * b<sub>0</sub>], [a<sub>1</sub>, b<sub>1</sub>], ... [a<sub>1</sub>, + * b<sub>m</sub>], ... [a<sub>n</sub>, b<sub>m</sub>]. If either array is empty, + * an empty array is returned. + * + * @param {array} a an array. + * @param {array} b an array. + * @returns {array} an array of pairs of elements in <tt>a</tt> and <tt>b</tt>. + */ +pv.cross = function(a, b) { + var array = []; + for (var i = 0, n = a.length, m = b.length; i < n; i++) { + for (var j = 0, x = a[i]; j < m; j++) { + array.push([x, b[j]]); + } + } + return array; +}; + +/** + * Given the specified array of <tt>arrays</tt>, concatenates the arrays into a + * single array. If the individual arrays are explicitly known, an alternative + * to blend is to use JavaScript's <tt>concat</tt> method directly. These two + * equivalent expressions:<ul> + * + * <li><tt>pv.blend([[1, 2, 3], ["a", "b", "c"]])</tt> + * <li><tt>[1, 2, 3].concat(["a", "b", "c"])</tt> + * + * </ul>return [1, 2, 3, "a", "b", "c"]. + * + * @param {array[]} arrays an array of arrays. + * @returns {array} an array containing all the elements of each array in + * <tt>arrays</tt>. + */ +pv.blend = function(arrays) { + return Array.prototype.concat.apply([], arrays); +}; + +/** + * Returns all of the property names (keys) of the specified object (a map). The + * order of the returned array is not defined. + * + * @param map an object. + * @returns {string[]} an array of strings corresponding to the keys. + * @see #entries + */ +pv.keys = function(map) { + var array = []; + for (var key in map) { + array.push(key); + } + return array; +}; + +/** + * Returns all of the entries (key-value pairs) of the specified object (a + * map). The order of the returned array is not defined. Each key-value pair is + * represented as an object with <tt>key</tt> and <tt>value</tt> attributes, + * e.g., <tt>{key: "foo", value: 42}</tt>. + * + * @param map an object. + * @returns {array} an array of key-value pairs corresponding to the keys. + */ +pv.entries = function(map) { + var array = []; + for (var key in map) { + array.push({ key: key, value: map[key] }); + } + return array; +}; + +/** + * Returns all of the values (attribute values) of the specified object (a + * map). The order of the returned array is not defined. + * + * @param map an object. + * @returns {array} an array of objects corresponding to the values. + * @see #entries + */ +pv.values = function(map) { + var array = []; + for (var key in map) { + array.push(map[key]); + } + return array; +}; + +/** + * Returns a normalized copy of the specified array, such that the sum of the + * returned elements sum to one. If the specified array is not an array of + * numbers, an optional accessor function <tt>f</tt> can be specified to map the + * elements to numbers. For example, if <tt>array</tt> is an array of objects, + * and each object has a numeric property "foo", the expression + * + * <pre>pv.normalize(array, function(d) d.foo)</pre> + * + * returns a normalized array on the "foo" property. If an accessor function is + * not specified, the identity function is used. + * + * @param {array} array an array of objects, or numbers. + * @param {function} [f] an optional accessor function. + * @returns {number[]} an array of numbers that sums to one. + */ +pv.normalize = function(array, f) { + if (!f) f = pv.identity; + var sum = pv.sum(array, f); + return array.map(function(d) { return f(d) / sum; }); +}; + +/** + * Returns the sum of the specified array. If the specified array is not an + * array of numbers, an optional accessor function <tt>f</tt> can be specified + * to map the elements to numbers. See {@link #normalize} for an example. + * + * @param {array} array an array of objects, or numbers. + * @param {function} [f] an optional accessor function. + * @returns {number} the sum of the specified array. + */ +pv.sum = function(array, f) { + if (!f) f = pv.identity; + return pv.reduce(array, function(p, d) { return p + f(d); }, 0); +}; + +/** + * Returns the maximum value of the specified array. If the specified array is + * not an array of numbers, an optional accessor function <tt>f</tt> can be + * specified to map the elements to numbers. See {@link #normalize} for an + * example. + * + * @param {array} array an array of objects, or numbers. + * @param {function} [f] an optional accessor function. + * @returns {number} the maximum value of the specified array. + */ +pv.max = function(array, f) { + if (!f) f = pv.identity; + return pv.reduce(array, function(p, d) { return Math.max(p, f(d)); }, -Infinity); +}; + +/** + * Returns the index of the maximum value of the specified array. If the + * specified array is not an array of numbers, an optional accessor function + * <tt>f</tt> can be specified to map the elements to numbers. See + * {@link #normalize} for an example. + * + * @param {array} array an array of objects, or numbers. + * @param {function} [f] an optional accessor function. + * @returns {number} the index of the maximum value of the specified array. + */ +pv.max.index = function(array, f) { + if (!f) f = pv.identity; + var maxi = -1, maxx = -Infinity; + for (var i = 0; i < array.length; i++) { + var x = f(array[i]); + if (x > maxx) { + maxx = x; + maxi = i; + } + } + return maxi; +} + +/** + * Returns the minimum value of the specified array of numbers. If the specified + * array is not an array of numbers, an optional accessor function <tt>f</tt> + * can be specified to map the elements to numbers. See {@link #normalize} for + * an example. + * + * @param {array} array an array of objects, or numbers. + * @param {function} [f] an optional accessor function. + * @returns {number} the minimum value of the specified array. + */ +pv.min = function(array, f) { + if (!f) f = pv.identity; + return pv.reduce(array, function(p, d) { return Math.min(p, f(d)); }, Infinity); +}; + +/** + * Returns the index of the minimum value of the specified array. If the + * specified array is not an array of numbers, an optional accessor function + * <tt>f</tt> can be specified to map the elements to numbers. See + * {@link #normalize} for an example. + * + * @param {array} array an array of objects, or numbers. + * @param {function} [f] an optional accessor function. + * @returns {number} the index of the minimum value of the specified array. + */ +pv.min.index = function(array, f) { + if (!f) f = pv.identity; + var mini = -1, minx = Infinity; + for (var i = 0; i < array.length; i++) { + var x = f(array[i]); + if (x < minx) { + minx = x; + mini = i; + } + } + return mini; +} + +/** + * Returns the arithmetic mean, or average, of the specified array. If the + * specified array is not an array of numbers, an optional accessor function + * <tt>f</tt> can be specified to map the elements to numbers. See + * {@link #normalize} for an example. + * + * @param {array} array an array of objects, or numbers. + * @param {function} [f] an optional accessor function. + * @returns {number} the mean of the specified array. + */ +pv.mean = function(array, f) { + return pv.sum(array, f) / array.length; +}; + +/** + * Returns the median of the specified array. If the specified array is not an + * array of numbers, an optional accessor function <tt>f</tt> can be specified + * to map the elements to numbers. See {@link #normalize} for an example. + * + * @param {array} array an array of objects, or numbers. + * @param {function} [f] an optional accessor function. + * @returns {number} the median of the specified array. + */ +pv.median = function(array, f) { + if (!f) f = pv.identity; + array = array.map(f).sort(function(a, b) { return a - b; }); + if (array.length % 2) return array[Math.floor(array.length / 2)]; + var i = array.length / 2; + return (array[i - 1] + array[i]) / 2; +}; + +if (/\[native code\]/.test(Array.prototype.reduce)) { +/** + * Applies the specified function <tt>f</tt> against an accumulator and each + * value of the specified array (from left-ot-right) so as to reduce it to a + * single value. + * + * <p>Array reduce was added in JavaScript 1.8. This implementation uses the native + * method if provided; otherwise we use our own implementation derived from the + * JavaScript documentation. Note that we don't want to add it to the Array + * prototype directly because this breaks certain (bad) for loop idioms. + * + * @see <a + * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/reduce">Array.reduce</a>. + * @param {array} array an array. + * @param {function} [f] a callback function to execute on each value in the array. + * @param [v] the object to use as the first argument to the first callback. + * @returns the reduced value. + */ + pv.reduce = function(array, f, v) { + var p = Array.prototype; + return p.reduce.apply(array, p.slice.call(arguments, 1)); + }; +} else { + pv.reduce = function(array, f, v) { + var len = array.length; + if (!len && (arguments.length == 2)) { + throw new Error(); + } + + var i = 0; + if (arguments.length < 3) { + while (true) { + if (i in array) { + v = array[i++]; + break; + } + if (++i >= len) { + throw new Error(); + } + } + } + + for (; i < len; i++) { + if (i in array) { + v = f.call(null, v, array[i], i, array); + } + } + return v; + }; +}; + +/** + * Returns a map constructed from the specified <tt>keys</tt>, using the function + * <tt>f</tt> to compute the value for each key. The arguments to the value + * function are the same as those used in the built-in array <tt>map</tt> + * function: the key, the index, and the array itself. The callback is invoked + * only for indexes of the array which have assigned values; it is not invoked + * for indexes which have been deleted or which have never been assigned values. + * + * <p>For example, this expression creates a map from strings to string length: + * + * <pre>pv.dict(["one", "three", "seventeen"], function(s) s.length)</pre> + * + * The returned value is <tt>{one: 3, three: 5, seventeen: 9}</tt>. + * + * @see <a + * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Array/map">Array.map</a>. + * @param {array} keys an array. + * @param {function} f a value function. + * @returns a map from keys to values. + */ +pv.dict = function(keys, f) { + var m = {}; + for (var i = 0; i < keys.length; i++) { + if (i in keys) { + var k = keys[i]; + m[k] = f.call(null, k, i, keys); + } + } + return m; +}; + +/** + * Returns a permutation of the specified array, using the specified array of + * indexes. The returned array contains the corresponding element in + * <tt>array</tt> for each index in <tt>indexes</tt>, in order. For example, + * + * <pre>pv.permute(["a", "b", "c"], [1, 2, 0])</pre> + * + * returns <tt>["b", "c", "a"]</tt>. It is acceptable for the array of indexes + * to be a different length from the array of elements, and for indexes to be + * duplicated or omitted. The optional accessor function <tt>f</tt> can be used + * to perform a simultaneous mapping of the array elements. + * + * @param {array} array an array. + * @param {number[]} indexes an array of indexes into <tt>array</tt>. + * @param {function} [f] an optional accessor function. + * @returns {array} an array of elements from <tt>array</tt>; a permutation. + */ +pv.permute = function(array, indexes, f) { + if (!f) f = pv.identity; + var p = new Array(indexes.length); + indexes.forEach(function(j, i) { p[i] = f(array[j]); }); + return p; +}; + +/** + * Returns a map from key to index for the specified <tt>keys</tt> array. For + * example, + * + * <pre>pv.numerate(["a", "b", "c"])</pre> + * + * returns <tt>{a: 0, b: 1, c: 2}</tt>. Note that since JavaScript maps only + * support string keys, <tt>keys</tt> must contain strings, or other values that + * naturally map to distinct string values. Alternatively, an optional accessor + * function <tt>f</tt> can be specified to compute the string key for the given + * element. + * + * @param {array} keys an array, usually of string keys. + * @param {function} [f] an optional key function. + * @returns a map from key to index. + */ +pv.numerate = function(keys, f) { + if (!f) f = pv.identity; + var map = {}; + keys.forEach(function(x, i) { map[f(x)] = i; }); + return map; +}; + +/** + * The comparator function for natural order. This can be used in conjunction with + * the built-in array <tt>sort</tt> method to sort elements by their natural + * order, ascending. Note that if no comparator function is specified to the + * built-in <tt>sort</tt> method, the default order is lexicographic, <i>not</i> + * natural! + * + * @see <a + * href="http://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Array/sort">Array.sort</a>. + * @param a an element to compare. + * @param b an element to compare. + * @returns {number} negative if a < b; positive if a > b; otherwise 0. + */ +pv.naturalOrder = function(a, b) { + return (a < b) ? -1 : ((a > b) ? 1 : 0); +}; + +/** + * The comparator function for reverse natural order. This can be used in + * conjunction with the built-in array <tt>sort</tt> method to sort elements by + * their natural order, descending. Note that if no comparator function is + * specified to the built-in <tt>sort</tt> method, the default order is + * lexicographic, <i>not</i> natural! + * + * @see #naturalOrder + * @param a an element to compare. + * @param b an element to compare. + * @returns {number} negative if a < b; positive if a > b; otherwise 0. + */ +pv.reverseOrder = function(b, a) { + return (a < b) ? -1 : ((a > b) ? 1 : 0); +}; + +/** @namespace Namespace constants for SVG, XMLNS, and XLINK. */ +pv.ns = { + /** + * The SVG namespace, "http://www.w3.org/2000/svg". + * + * @type string + */ + svg: "http://www.w3.org/2000/svg", + + /** + * The XMLNS namespace, "http://www.w3.org/2000/xmlns". + * + * @type string + */ + xmlns: "http://www.w3.org/2000/xmlns", + + /** + * The XLINK namespace, "http://www.w3.org/1999/xlink". + * + * @type string + */ + xlink: "http://www.w3.org/1999/xlink" +}; + +/** @namespace Protovis major and minor version numbers. */ +pv.version = { + /** + * The major version number. + * + * @type number + */ + major: 2, + + /** + * The minor version number. + * + * @type number + */ + minor: 6 +}; +/** + * Returns the {@link pv.Color} for the specified color format string. Colors + * may have an associated opacity, or alpha channel. Color formats are specified + * by CSS Color Modular Level 3, using either in RGB or HSL color space. For + * example:<ul> + * + * <li>#f00 // #rgb + * <li>#ff0000 // #rrggbb + * <li>rgb(255, 0, 0) + * <li>rgb(100%, 0%, 0%) + * <li>hsl(0, 100%, 50%) + * <li>rgba(0, 0, 255, 0.5) + * <li>hsla(120, 100%, 50%, 1) + * + * </ul>The SVG 1.0 color keywords names are also supported, such as "aliceblue" + * and yellowgreen". The "transparent" keyword is also supported for a + * fully-transparent color. + * + * <p>If the <tt>format</tt> argument is already an instance of <tt>Color</tt>, + * the argument is returned with no further processing. + * + * @param {string} format the color specification string, e.g., "#f00". + * @returns {pv.Color} the corresponding <tt>Color</tt>. + * @see <a href="http://www.w3.org/TR/SVG/types.html#ColorKeywords">SVG color keywords</a>. + * @see <a href="http://www.w3.org/TR/css3-color/">CSS3 color module</a>. + */ +pv.color = function(format) { + if (!format || (format == "transparent")) { + return new pv.Color.Rgb(0, 0, 0, 0); + } + if (format instanceof pv.Color) { + return format; + } + + /* Handle hsl, rgb. */ + var m1 = /([a-z]+)\((.*)\)/i.exec(format); + if (m1) { + var m2 = m1[2].split(","), a = 1; + switch (m1[1]) { + case "hsla": + case "rgba": { + a = parseFloat(m2[3]); + break; + } + } + switch (m1[1]) { + case "hsla": + case "hsl": { + var h = parseFloat(m2[0]), // degrees + s = parseFloat(m2[1]) / 100, // percentage + l = parseFloat(m2[2]) / 100; // percentage + return (new pv.Color.Hsl(h, s, l, a)).rgb(); + } + case "rgba": + case "rgb": { + let parse = function(c) { // either integer or percentage + let f = parseFloat(c); + return (c[c.length - 1] == '%') ? Math.round(f * 2.55) : f; + }; + let r = parse(m2[0]), g = parse(m2[1]), b = parse(m2[2]); + return new pv.Color.Rgb(r, g, b, a); + } + } + } + + /* Otherwise, assume named colors. TODO allow lazy conversion to RGB. */ + return new pv.Color(format, 1); +}; + +/** + * Constructs a color with the specified color format string and opacity. This + * constructor should not be invoked directly; use {@link pv.color} instead. + * + * @class Represents an abstract (possibly translucent) color. The color is + * divided into two parts: the <tt>color</tt> attribute, an opaque color format + * string, and the <tt>opacity</tt> attribute, a float in [0, 1]. The color + * space is dependent on the implementing class; all colors support the + * {@link #rgb} method to convert to RGB color space for interpolation. + * + * <p>See also the <a href="../../api/Color.html">Color guide</a>. + * + * @param {string} color an opaque color format string, such as "#f00". + * @param {number} opacity the opacity, in [0,1]. + * @see pv.color + */ +pv.Color = function(color, opacity) { + /** + * An opaque color format string, such as "#f00". + * + * @type string + * @see <a href="http://www.w3.org/TR/SVG/types.html#ColorKeywords">SVG color keywords</a>. + * @see <a href="http://www.w3.org/TR/css3-color/">CSS3 color module</a>. + */ + this.color = color; + + /** + * The opacity, a float in [0, 1]. + * + * @type number + */ + this.opacity = opacity; +}; + +/** + * Constructs a new RGB color with the specified channel values. + * + * @class Represents a color in RGB space. + * + * @param {number} r the red channel, an integer in [0,255]. + * @param {number} g the green channel, an integer in [0,255]. + * @param {number} b the blue channel, an integer in [0,255]. + * @param {number} a the alpha channel, a float in [0,1]. + * @extends pv.Color + */ +pv.Color.Rgb = function(r, g, b, a) { + pv.Color.call(this, a ? ("rgb(" + r + "," + g + "," + b + ")") : "none", a); + + /** + * The red channel, an integer in [0, 255]. + * + * @type number + */ + this.r = r; + + /** + * The green channel, an integer in [0, 255]. + * + * @type number + */ + this.g = g; + + /** + * The blue channel, an integer in [0, 255]. + * + * @type number + */ + this.b = b; + + /** + * The alpha channel, a float in [0, 1]. + * + * @type number + */ + this.a = a; +}; +pv.Color.Rgb.prototype = pv.extend(pv.Color); + +/** + * Returns the RGB color equivalent to this color. This method is abstract and + * must be implemented by subclasses. + * + * @returns {pv.Color.Rgb} an RGB color. + * @function + * @name pv.Color.prototype.rgb + */ + +/** + * Returns this. + * + * @returns {pv.Color.Rgb} this. + */ +pv.Color.Rgb.prototype.rgb = function() { return this; }; + +/** + * Constructs a new HSL color with the specified values. + * + * @class Represents a color in HSL space. + * + * @param {number} h the hue, an integer in [0, 360]. + * @param {number} s the saturation, a float in [0, 1]. + * @param {number} l the lightness, a float in [0, 1]. + * @param {number} a the opacity, a float in [0, 1]. + * @extends pv.Color + */ +pv.Color.Hsl = function(h, s, l, a) { + pv.Color.call(this, "hsl(" + h + "," + (s * 100) + "%," + (l * 100) + "%)", a); + + /** + * The hue, an integer in [0, 360]. + * + * @type number + */ + this.h = h; + + /** + * The saturation, a float in [0, 1]. + * + * @type number + */ + this.s = s; + + /** + * The lightness, a float in [0, 1]. + * + * @type number + */ + this.l = l; + + /** + * The opacity, a float in [0, 1]. + * + * @type number + */ + this.a = a; +}; +pv.Color.Hsl.prototype = pv.extend(pv.Color); + +/** + * Returns the RGB color equivalent to this HSL color. + * + * @returns {pv.Color.Rgb} an RGB color. + */ +pv.Color.Hsl.prototype.rgb = function() { + var h = this.h, s = this.s, l = this.l; + + /* Some simple corrections for h, s and l. */ + h = h % 360; if (h < 0) h += 360; + s = Math.max(0, Math.min(s, 1)); + l = Math.max(0, Math.min(l, 1)); + + /* From FvD 13.37 */ + var m2 = (l < .5) ? (l * (l + s)) : (l + s - l * s); + var m1 = 2 * l - m2; + if (s == 0) { + return new rgb(l, l, l); + } + function v(h) { + if (h > 360) h -= 360; + else if (h < 0) h += 360; + if (h < 60) return m1 + (m2 - m1) * h / 60; + else if (h < 180) return m2; + else if (h < 240) return m1 + (m2 - m1) * (240 - h) / 60; + return m1; + } + function vv(h) { + return Math.round(v(h) * 255); + } + + return new pv.Color.Rgb(vv(h + 120), vv(h), vv(h - 120), this.a); +}; +/** + * Returns a new categorical color encoding using the specified colors. The + * arguments to this method are an array of colors; see {@link pv.color}. For + * example, to create a categorical color encoding using the <tt>species</tt> + * attribute: + * + * <pre>pv.colors("red", "green", "blue").by(function(d) d.species)</pre> + * + * The result of this expression can be used as a fill- or stroke-style + * property. This assumes that the data's <tt>species</tt> attribute is a + * string. + * + * @returns {pv.Colors} a new categorical color encoding. + * @param {string} colors... categorical colors. + * @see pv.Colors + */ +pv.colors = function() { + return pv.Colors(arguments); +}; + +/** + * Returns a new categorical color encoding using the specified colors. This + * constructor is typically not used directly; use {@link pv.colors} instead. + * + * @class Represents a categorical color encoding using the specified colors. + * The returned object can be used as a property function; the appropriate + * categorical color will be returned by evaluating the current datum, or + * through whatever other means the encoding uses to determine uniqueness, per + * the {@link #by} method. The default implementation allocates a distinct color + * per {@link pv.Mark#childIndex}. + * + * @param {string[]} values an array of colors; see {@link pv.color}. + * @returns {pv.Colors} a new categorical color encoding. + * @see pv.colors + */ +pv.Colors = function(values) { + + /** + * @ignore Each set of colors has an associated (numeric) ID that is used to + * store a cache of assigned colors on the root scene. As unique keys are + * discovered, a new color is allocated and assigned to the given key. + * + * The key function determines how uniqueness is determined. By default, + * colors are assigned using the mark's childIndex, such that each new mark + * added is given a new color. Note that derived marks will not inherit the + * exact color of the prototype, but instead inherit the set of colors. + */ + function colors(keyf) { + var id = pv.Colors.count++; + + function color() { + var key = keyf.apply(this, this.root.scene.data); + var state = this.root.scene.colors; + if (!state) this.root.scene.colors = state = {}; + if (!state[id]) state[id] = { count: 0 }; + var color = state[id][key]; + if (color == undefined) { + color = state[id][key] = values[state[id].count++ % values.length]; + } + return color; + } + return color; + } + + var c = colors(function() { return this.childIndex; }); + + /** + * Allows a new set of colors to be derived from the current set using a + * different key function. For instance, to color marks using the value of the + * field "foo", say: + * + * <pre>pv.Colors.category10.by(function(d) d.foo)</pre> + * + * For convenience, "index" and "parent.index" keys are predefined. + * + * @param {function} v the new key function. + * @name pv.Colors.prototype.by + * @function + * @returns {pv.Colors} a new color scheme + */ + c.by = colors; + + /** + * A derivative color encoding using the same colors, but allocating unique + * colors based on the mark index. + * + * @name pv.Colors.prototype.unique + * @type pv.Colors + */ + c.unique = c.by(function() { return this.index; }); + + /** + * A derivative color encoding using the same colors, but allocating unique + * colors based on the parent index. + * + * @name pv.Colors.prototype.parent + * @type pv.Colors + */ + c.parent = c.by(function() { return this.parent.index; }); + + /** + * The underlying array of colors. + * + * @type string[] + * @name pv.Colors.prototype.values + */ + c.values = values; + + return c; +}; + +/** @private */ +pv.Colors.count = 0; + +/* From Flare. */ + +/** + * A 10-color scheme. + * + * @type pv.Colors + */ +pv.Colors.category10 = pv.colors( + "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", + "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf" +); + +/** + * A 20-color scheme. + * + * @type pv.Colors + */ +pv.Colors.category20 = pv.colors( + "#1f77b4", "#aec7e8", "#ff7f0e", "#ffbb78", "#2ca02c", + "#98df8a", "#d62728", "#ff9896", "#9467bd", "#c5b0d5", + "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", "#7f7f7f", + "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5" +); + +/** + * An alternative 19-color scheme. + * + * @type pv.Colors + */ +pv.Colors.category19 = pv.colors( + "#9c9ede", "#7375b5", "#4a5584", "#cedb9c", "#b5cf6b", + "#8ca252", "#637939", "#e7cb94", "#e7ba52", "#bd9e39", + "#8c6d31", "#e7969c", "#d6616b", "#ad494a", "#843c39", + "#de9ed6", "#ce6dbd", "#a55194", "#7b4173" +); +// TODO support arbitrary color stops + +/** + * Returns a linear color ramp from the specified <tt>start</tt> color to the + * specified <tt>end</tt> color. The color arguments may be specified either as + * <tt>string</tt>s or as {@link pv.Color}s. + * + * @param {string} start the start color; may be a <tt>pv.Color</tt>. + * @param {string} end the end color; may be a <tt>pv.Color</tt>. + * @returns {pv.Ramp} a color ramp from <tt>start</tt> to <tt>end</tt>. + */ +pv.ramp = function(start, end) { + return pv.Ramp(pv.color(start), pv.color(end)); +}; + +/** + * Constructs a ramp from the specified start color to the specified end + * color. This constructor should not be invoked directly; use {@link pv.ramp} + * instead. + * + * @class Represents a linear color ramp from the specified <tt>start</tt> color + * to the specified <tt>end</tt> color. Ramps can be used as property functions; + * their behavior is equivalent to calling {@link #value}, passing in the + * current datum as the sample point. If the data is <i>not</i> a float in [0, + * 1], the {@link #by} method can be used to map the datum to a suitable sample + * point. + * + * @extends Function + * @param {pv.Color} start the start color. + * @param {pv.Color} end the end color. + * @see pv.ramp + */ +pv.Ramp = function(start, end) { + var s = start.rgb(), e = end.rgb(), f = pv.identity; + + /** @ignore Property function. */ + function ramp() { + return value(f.apply(this, this.root.scene.data)); + } + + /** @ignore Interpolates between start and end at value aT in [0,1]. */ + function value(aT) { + var t = Math.max(0, Math.min(1, aT)); + var a = s.a * (1 - t) + e.a * t; + if (a < 1e-5) a = 0; // avoid scientific notation + return (s.a == 0) ? new pv.Color.Rgb(e.r, e.g, e.b, a) + : ((e.a == 0) ? new pv.Color.Rgb(s.r, s.g, s.b, a) + : new pv.Color.Rgb( + Math.round(s.r * (1 - t) + e.r * t), + Math.round(s.g * (1 - t) + e.g * t), + Math.round(s.b * (1 - t) + e.b * t), a)); + } + + /** + * Sets the sample function to be the specified function <tt>v</tt>. + * + * @param {function} v the new sample function. + * @name pv.Ramp.prototype.by + * @function + * @returns {pv.Ramp} this. + */ + ramp.by = function(v) { f = v; return this; }; + + /** + * Returns the interpolated color at the specified sample point. + * + * @param {number} t the sample point in [0, 1]. + * @name pv.Ramp.prototype.value + * @function + * @returns {pv.Color.Rgb} the interpolated color. + */ + ramp.value = value; + + return ramp; +}; +/** + * Constructs a new mark with default properties. Marks, with the exception of + * the root panel, are not typically constructed directly; instead, they are + * added to a panel or an existing mark via {@link pv.Mark#add}. + * + * @class Represents a data-driven graphical mark. The <tt>Mark</tt> class is + * the base class for all graphical marks in Protovis; it does not provide any + * specific rendering functionality, but together with {@link Panel} establishes + * the core framework. + * + * <p>Concrete mark types include familiar visual elements such as bars, lines + * and labels. Although a bar mark may be used to construct a bar chart, marks + * know nothing about charts; it is only through their specification and + * composition that charts are produced. These building blocks permit many + * combinatorial possibilities. + * + * <p>Marks are associated with <b>data</b>: a mark is generated once per + * associated datum, mapping the datum to visual <b>properties</b> such as + * position and color. Thus, a single mark specification represents a set of + * visual elements that share the same data and visual encoding. The type of + * mark defines the names of properties and their meaning. A property may be + * static, ignoring the associated datum and returning a constant; or, it may be + * dynamic, derived from the associated datum or index. Such dynamic encodings + * can be specified succinctly using anonymous functions. Special properties + * called event handlers can be registered to add interactivity. + * + * <p>While most properties are <i>variable</i>, some mark types, such as lines + * and areas, generate a single visual element rather than a distinct visual + * element per datum. With these marks, some properties may be <b>fixed</b>. + * Fixed properties can vary per mark, but not <i>per datum</i>! These + * properties are evaluated solely for the first (0-index) datum, and typically + * are specified as a constant. However, it is valid to use a function if the + * property varies between panels or is dynamically generated. + * + * <p>Protovis uses <b>inheritance</b> to simplify the specification of related + * marks: a new mark can be derived from an existing mark, inheriting its + * properties. The new mark can then override properties to specify new + * behavior, potentially in terms of the old behavior. In this way, the old mark + * serves as the <b>prototype</b> for the new mark. Most mark types share the + * same basic properties for consistency and to facilitate inheritance. + * + * <p>See also the <a href="../../api/">Protovis guide</a>. + */ +pv.Mark = function() {}; + +/** + * Returns the mark type name. Names should be lower case, with words separated + * by hyphens. For example, the mark class <tt>FooBar</tt> should return + * "foo-bar". + * + * <p>Note that this method is defined on the constructor, not on the prototype, + * and thus is a static method. The constructor is accessible through the + * {@link #type} field. + * + * @returns {string} the mark type name, such as "mark". + */ +pv.Mark.toString = function() { return "mark"; }; + +/** + * Defines and registers a property method for the property with the given name. + * This method should be called on a mark class prototype to define each exposed + * property. (Note this refers to the JavaScript <tt>prototype</tt>, not the + * Protovis mark prototype, which is the {@link #proto} field.) + * + * <p>The created property method supports several modes of invocation: <ol> + * + * <li>If invoked with a <tt>Function</tt> argument, this function is evaluated + * for each associated datum. The return value of the function is used as the + * computed property value. The context of the function (<tt>this</tt>) is this + * mark. The arguments to the function are the associated data of this mark and + * any enclosing panels. For example, a linear encoding of numerical data to + * height is specified as + * + * <pre>m.height(function(d) d * 100);</pre> + * + * The expression <tt>d * 100</tt> will be evaluated for the height property of + * each mark instance. This function is stored in the <tt>$height</tt> field. The + * return value of the property method (e.g., <tt>m.height</tt>) is this mark + * (<tt>m</tt>)).<p> + * + * <li>If invoked with a non-function argument, the property is treated as a + * constant, and wrapped with an accessor function. This wrapper function is + * stored in the equivalent internal (<tt>$</tt>-prefixed) field. The return + * value of the property method (e.g., <tt>m.height</tt>) is this mark.<p> + * + * <li>If invoked from an event handler, the property is set to the specified + * value on the current instance (i.e., the instance that triggered the event, + * such as a mouse click). In this case, the value should be a constant and not + * a function. The return value is this mark. For example, saying + * + * <pre>this.fillStyle("red").strokeStyle("black");</pre> + * + * from a "click" event handler will set the fill color to red, and the stroke + * color to black, for any marks that are clicked.<p> + * + * <li>If invoked with no arguments, the computed property value for the current + * mark instance in the scene graph is returned. This facilitates <i>property + * chaining</i>, where one mark's properties are defined in terms of another's. + * For example, to offset a mark's location from its prototype, you might say + * + * <pre>m.top(function() this.proto.top() + 10);</pre> + * + * Note that the index of the mark being evaluated (in the above example, + * <tt>this.proto</tt>) is inherited from the <tt>Mark</tt> class and set by + * this mark. So, if the fifth element's top property is being evaluated, the + * fifth instance of <tt>this.proto</tt> will similarly be queried for the value + * of its top property. If the mark being evaluated has a different number of + * instances, or its data is unrelated, the behavior of this method is + * undefined. In these cases it may be better to index the <tt>scene</tt> + * explicitly to specify the exact instance. + * + * </ol><p>Property names should follow standard JavaScript method naming + * conventions, using lowerCamel-style capitalization. + * + * <p>In addition to creating the property method, every property is registered + * in the {@link #properties} array on the <tt>prototype</tt>. Although this + * array is an instance field, it is considered immutable and shared by all + * instances of a given mark type. The <tt>properties</tt> array can be queried + * to see if a mark type defines a particular property, such as width or height. + * + * @param {string} name the property name. + */ +pv.Mark.prototype.defineProperty = function(name) { + if (!this.hasOwnProperty("properties")) { + this.properties = (this.properties || []).concat(); + } + this.properties.push(name); + this[name] = function(v) { + if (arguments.length) { + if (this.scene) { + this.scene[this.index][name] = v; + } else { + this["$" + name] = (v instanceof Function) ? v : function() { return v; }; + } + return this; + } + return this.scene[this.index][name]; + }; +}; + +/** + * The constructor; the mark type. This mark type may define default property + * functions (see {@link #defaults}) that are used if the property is not + * overridden by the mark or any of its prototypes. + * + * @type function + */ +pv.Mark.prototype.type = pv.Mark; + +/** + * The mark prototype, possibly null, from which to inherit property + * functions. The mark prototype is not necessarily of the same type as this + * mark. Any properties defined on this mark will override properties inherited + * either from the prototype or from the type-specific defaults. + * + * @type pv.Mark + */ +pv.Mark.prototype.proto = null; + +/** + * The enclosing parent panel. The parent panel is generally null only for the + * root panel; however, it is possible to create "offscreen" marks that are used + * only for inheritance purposes. + * + * @type pv.Panel + */ +pv.Mark.prototype.parent = null; + +/** + * The child index. -1 if the enclosing parent panel is null; otherwise, the + * zero-based index of this mark into the parent panel's <tt>children</tt> array. + * + * @type number + */ +pv.Mark.prototype.childIndex = -1; + +/** + * The mark index. The value of this field depends on which instance (i.e., + * which element of the data array) is currently being evaluated. During the + * build phase, the index is incremented over each datum; when handling events, + * the index is set to the instance that triggered the event. + * + * @type number + */ +pv.Mark.prototype.index = -1; + +/** + * The scene graph. The scene graph is an array of objects; each object (or + * "node") corresponds to an instance of this mark and an element in the data + * array. The scene graph can be traversed to lookup previously-evaluated + * properties. + * + * <p>For instance, consider a stacked area chart. The bottom property of the + * area can be defined using the <i>cousin</i> instance, which is the current + * area instance in the previous instantiation of the parent panel. In this + * sample code, + * + * <pre>new pv.Panel() + * .width(150).height(150) + * .add(pv.Panel) + * .data([[1, 1.2, 1.7, 1.5, 1.7], + * [.5, 1, .8, 1.1, 1.3], + * [.2, .5, .8, .9, 1]]) + * .add(pv.Area) + * .data(function(d) d) + * .bottom(function() { + * var c = this.cousin(); + * return c ? (c.bottom + c.height) : 0; + * }) + * .height(function(d) d * 40) + * .left(function() this.index * 35) + * .root.render();</pre> + * + * the bottom property is computed based on the upper edge of the corresponding + * datum in the previous series. The area's parent panel is instantiated once + * per series, so the cousin refers to the previous (below) area mark. (Note + * that the position of the upper edge is not the same as the top property, + * which refers to the top margin: the distance from the top edge of the panel + * to the top edge of the mark.) + * + * @see #first + * @see #last + * @see #sibling + * @see #cousin + */ +pv.Mark.prototype.scene = null; + +/** + * The root parent panel. This may be null for "offscreen" marks that are + * created for inheritance purposes only. + * + * @type pv.Panel + */ +pv.Mark.prototype.root = null; + +/** + * The data property; an array of objects. The size of the array determines the + * number of marks that will be instantiated; each element in the array will be + * passed to property functions to compute the property values. Typically, the + * data property is specified as a constant array, such as + * + * <pre>m.data([1, 2, 3, 4, 5]);</pre> + * + * However, it is perfectly acceptable to define the data property as a + * function. This function might compute the data dynamically, allowing + * different data to be used per enclosing panel. For instance, in the stacked + * area graph example (see {@link #scene}), the data function on the area mark + * dereferences each series. + * + * @type array + * @name pv.Mark.prototype.data + */ +pv.Mark.prototype.defineProperty("data"); + +/** + * The visible property; a boolean determining whether or not the mark instance + * is visible. If a mark instance is not visible, its other properties will not + * be evaluated. Similarly, for panels no child marks will be rendered. + * + * @type boolean + * @name pv.Mark.prototype.visible + */ +pv.Mark.prototype.defineProperty("visible"); + +/** + * The left margin; the distance, in pixels, between the left edge of the + * enclosing panel and the left edge of this mark. Note that in some cases this + * property may be redundant with the right property, or with the conjunction of + * right and width. + * + * @type number + * @name pv.Mark.prototype.left + */ +pv.Mark.prototype.defineProperty("left"); + +/** + * The right margin; the distance, in pixels, between the right edge of the + * enclosing panel and the right edge of this mark. Note that in some cases this + * property may be redundant with the left property, or with the conjunction of + * left and width. + * + * @type number + * @name pv.Mark.prototype.right + */ +pv.Mark.prototype.defineProperty("right"); + +/** + * The top margin; the distance, in pixels, between the top edge of the + * enclosing panel and the top edge of this mark. Note that in some cases this + * property may be redundant with the bottom property, or with the conjunction + * of bottom and height. + * + * @type number + * @name pv.Mark.prototype.top + */ +pv.Mark.prototype.defineProperty("top"); + +/** + * The bottom margin; the distance, in pixels, between the bottom edge of the + * enclosing panel and the bottom edge of this mark. Note that in some cases + * this property may be redundant with the top property, or with the conjunction + * of top and height. + * + * @type number + * @name pv.Mark.prototype.bottom + */ +pv.Mark.prototype.defineProperty("bottom"); + +/** + * The cursor property; corresponds to the CSS cursor property. This is + * typically used in conjunction with event handlers to indicate interactivity. + * + * @type string + * @name pv.Mark.prototype.cursor + * @see <a href="http://www.w3.org/TR/CSS2/ui.html#propdef-cursor">CSS2 cursor</a>. + */ +pv.Mark.prototype.defineProperty("cursor"); + +/** + * The title property; corresponds to the HTML/SVG title property, allowing the + * general of simple plain text tooltips. + * + * @type string + * @name pv.Mark.prototype.title + */ +pv.Mark.prototype.defineProperty("title"); + +/** + * Default properties for all mark types. By default, the data array is a single + * null element; if the data property is not specified, this causes each mark to + * be instantiated as a singleton. The visible property is true by default. + * + * @type pv.Mark + */ +pv.Mark.defaults = new pv.Mark() + .data([null]) + .visible(true); + +/** + * Sets the prototype of this mark to the specified mark. Any properties not + * defined on this mark may be inherited from the specified prototype mark, or + * its prototype, and so on. The prototype mark need not be the same type of + * mark as this mark. (Note that for inheritance to be useful, properties with + * the same name on different mark types should have equivalent meaning.) + * + * @param {pv.Mark} proto the new prototype. + * @return {pv.Mark} this mark. + */ +pv.Mark.prototype.extend = function(proto) { + this.proto = proto; + return this; +}; + +/** + * Adds a new mark of the specified type to the enclosing parent panel, whilst + * simultaneously setting the prototype of the new mark to be this mark. + * + * @param {function} type the type of mark to add; a constructor, such as + * <tt>pv.Bar</tt>. + * @return {pv.Mark} the new mark. + */ +pv.Mark.prototype.add = function(type) { + return this.parent.add(type).extend(this); +}; + +/** + * Constructs a new mark anchor with default properties. + * + * @class Represents an anchor on a given mark. An anchor is itself a mark, but + * without a visual representation. It serves only to provide useful default + * properties that can be inherited by other marks. Each type of mark can define + * any number of named anchors for convenience. If the concrete mark type does + * not define an anchor implementation specifically, one will be inherited from + * the mark's parent class. + * + * <p>For example, the bar mark provides anchors for its four sides: left, + * right, top and bottom. Adding a label to the top anchor of a bar, + * + * <pre>bar.anchor("top").add(pv.Label);</pre> + * + * will render a text label on the top edge of the bar; the top anchor defines + * the appropriate position properties (top and left), as well as text-rendering + * properties for convenience (textAlign and textBaseline). + * + * @extends pv.Mark + */ +pv.Mark.Anchor = function() { + pv.Mark.call(this); +}; +pv.Mark.Anchor.prototype = pv.extend(pv.Mark); + +/** + * The anchor name. The set of supported anchor names is dependent on the + * concrete mark type; see the mark type for details. For example, bars support + * left, right, top and bottom anchors. + * + * <p>While anchor names are typically constants, the anchor name is a true + * property, which means you can specify a function to compute the anchor name + * dynamically. For instance, if you wanted to alternate top and bottom anchors, + * saying + * + * <pre>m.anchor(function() (this.index % 2) ? "top" : "bottom").add(pv.Dot);</pre> + * + * would have the desired effect. + * + * @type string + * @name pv.Mark.Anchor.prototype.name + */ +pv.Mark.Anchor.prototype.defineProperty("name"); + +/** + * Returns an anchor with the specified name. While anchor names are typically + * constants, the anchor name is a true property, which means you can specify a + * function to compute the anchor name dynamically. See the + * {@link pv.Mark.Anchor#name} property for details. + * + * @param {string} name the anchor name; either a string or a property function. + * @returns {pv.Mark.Anchor} the new anchor. + */ +pv.Mark.prototype.anchor = function(name) { + var anchorType = this.type; + while (!anchorType.Anchor) { + anchorType = anchorType.defaults.proto.type; + } + var anchor = new anchorType.Anchor().extend(this).name(name); + anchor.parent = this.parent; + anchor.type = this.type; + return anchor; +}; + +/** + * Returns the anchor target of this mark, if it is derived from an anchor; + * otherwise returns null. For example, if a label is derived from a bar anchor, + * + * <pre>bar.anchor("top").add(pv.Label);</pre> + * + * then property functions on the label can refer to the bar via the + * <tt>anchorTarget</tt> method. This method is also useful for mark types + * defining properties on custom anchors. + * + * @returns {pv.Mark} the anchor target of this mark; possibly null. + */ +pv.Mark.prototype.anchorTarget = function() { + var target = this; + while (!(target instanceof pv.Mark.Anchor)) { + target = target.proto; + if (!target) return null; + } + return target.proto; +}; + +/** + * Returns the first instance of this mark in the scene graph. This method can + * only be called when the mark is bound to the scene graph (for example, from + * an event handler, or within a property function). + * + * @returns a node in the scene graph. + */ +pv.Mark.prototype.first = function() { + return this.scene[0]; +}; + +/** + * Returns the last instance of this mark in the scene graph. This method can + * only be called when the mark is bound to the scene graph (for example, from + * an event handler, or within a property function). In addition, note that mark + * instances are built sequentially, so the last instance of this mark may not + * yet be constructed. + * + * @returns a node in the scene graph. + */ +pv.Mark.prototype.last = function() { + return this.scene[this.scene.length - 1]; +}; + +/** + * Returns the previous instance of this mark in the scene graph, or null if + * this is the first instance. + * + * @returns a node in the scene graph, or null. + */ +pv.Mark.prototype.sibling = function() { + return (this.index == 0) ? null : this.scene[this.index - 1]; +}; + +/** + * Returns the current instance in the scene graph of this mark, in the previous + * instance of the enclsoing parent panel. May return null if this instance + * could not be found. + * + * @returns a node in the scene graph, or null. + */ +pv.Mark.prototype.cousin = function() { + var p = this.parent, s = p && p.sibling(); + return (s && s.children) ? s.children[this.childIndex][this.index] : null; +}; + +/** + * Renders this mark, including recursively rendering all child marks if this is + * a panel. Rendering consists of two phases: <b>build</b> and <b>update</b>. In + * the future, the update phase could conceivably be decoupled to allow + * different rendering engines. Similarly, future work is needed to allow + * dynamic rebuilding based on interaction. (For example, dynamic expansion of a + * tree visualization.) + * + * <p>In the build phase (see {@link #build}), all properties are evaluated, and + * the scene graph is generated. However, nothing is rendered. + * + * <p>In the update phase (see {@link #update}), the mark is rendered by + * creating and updating elements and attributes in the SVG image. No properties + * are evaluated during the update phase; instead the values computed previously + * in the build phase are simply translated into SVG. + */ +pv.Mark.prototype.render = function() { + this.build(); + this.update(); +}; + +/** + * Evaluates properties and computes implied properties. Properties are stored + * in the {@link #scene} array for each instance of this mark. + * + * <p>As marks are built recursively, the {@link #index} property is updated to + * match the current index into the data array for each mark. Note that the + * index property is only set for the mark currently being built and its + * enclosing parent panels. The index property for other marks is unset, but is + * inherited from the global <tt>Mark</tt> class prototype. This allows mark + * properties to refer to properties on other marks <i>in the same panel</i> + * conveniently; however, in general it is better to reference mark instances + * specifically through the scene graph rather than depending on the magical + * behavior of {@link #index}. + * + * <p>The root scene array has a special property, <tt>data</tt>, which stores + * the current data stack. The first element in this stack is the current datum, + * followed by the datum of the enclosing parent panel, and so on. The data + * stack should not be accessed directly; instead, property functions are passed + * the current data stack as arguments. + * + * <p>The evaluation of the <tt>data</tt> and <tt>visible</tt> properties is + * special. The <tt>data</tt> property is evaluated first; unlike the other + * properties, the data stack is from the parent panel, rather than the current + * mark, since the data is not defined until the data property is evaluated. + * The <tt>visible</tt> property is subsequently evaluated for each instance; + * only if true will the {@link #buildInstance} method be called, evaluating + * other properties and recursively building the scene graph. + * + * <p>If this mark is being re-built, any old instances of this mark that no + * longer exist (because the new data array contains fewer elements) will be + * cleared using {@link #clearInstance}. + * + * @param parent the instance of the parent panel from the scene graph. + */ +pv.Mark.prototype.build = function(parent) { + if (!this.scene) { + this.scene = []; + if (!this.parent) { + this.scene.data = []; + } + } + + var data = this.get("data"); + var stack = this.root.scene.data; + stack.unshift(null); + this.index = -1; + + this.$$data = data; // XXX + + for (var i = 0, d; i < data.length; i++) { + pv.Mark.prototype.index = ++this.index; + var s = {}; + + /* + * This is a bit confusing and could be cleaned up. This "scene" stores the + * previous scene graph; we want to reuse SVG elements that were created + * previously rather than recreating them, so we extract them. We also want + * to reuse SVG child elements as well. + */ + if (this.scene[this.index]) { + s.svg = this.scene[this.index].svg; + s.children = this.scene[this.index].children; + } + this.scene[this.index] = s; + + s.index = i; + s.data = stack[0] = data[i]; + s.parent = parent; + s.visible = this.get("visible"); + if (s.visible) { + this.buildInstance(s); + } + } + stack.shift(); + delete this.index; + pv.Mark.prototype.index = -1; + + /* Clear any old instances from the scene. */ + for (var i = data.length; i < this.scene.length; i++) { + this.clearInstance(this.scene[i]); + } + this.scene.length = data.length; + + return this; +}; + +/** + * Removes the specified mark instance from the SVG image. This method depends + * on the <tt>svg</tt> property of the scene graph node. If the specified mark + * instance was not present in the SVG image (for example, because it was not + * visible), this method has no effect. + * + * @param s a node in the scene graph; the instance of the mark to clear. + */ +pv.Mark.prototype.clearInstance = function(s) { + if (s.svg) { + s.parent.svg.removeChild(s.svg); + } +}; + +/** + * Evaluates all of the properties for this mark for the specified instance + * <tt>s</tt> in the scene graph. The set of properties to evaluate is retrieved + * from the {@link #properties} array for this mark type (see {@link #type}). + * After these properties are evaluated, any <b>implied</b> properties may be + * computed by the mark and set on the scene graph; see {@link #buildImplied}. + * + * <p>For panels, this method recursively builds the scene graph for all child + * marks as well. In general, this method should not need to be overridden by + * concrete mark types. + * + * @param s a node in the scene graph; the instance of the mark to build. + */ +pv.Mark.prototype.buildInstance = function(s) { + var p = this.type.prototype; + for (var i = 0; i < p.properties.length; i++) { + var name = p.properties[i]; + if (!(name in s)) { + s[name] = this.get(name); + } + } + this.buildImplied(s); +}; + +/** + * Computes the implied properties for this mark for the specified instance + * <tt>s</tt> in the scene graph. Implied properties are those with dependencies + * on multiple other properties; for example, the width property may be implied + * if the left and right properties are set. This method can be overridden by + * concrete mark types to define new implied properties, if necessary. + * + * <p>The default implementation computes the implied CSS box model properties. + * The prioritization of redundant properties is as follows:<ol> + * + * <li>If the <tt>width</tt> property is not specified (i.e., null), its value is + * the width of the parent panel, minus this mark's left and right margins; the + * left and right margins are zero if not specified. + * + * <li>Otherwise, if the <tt>right</tt> margin is not specified, its value is the + * width of the parent panel, minus this mark's width and left margin; the left + * margin is zero if not specified. + * + * <li>Otherwise, if the <tt>left</tt> property is not specified, its value is + * the width of the parent panel, minus this mark's width and the right margin. + * + * </ol>This prioritization is then duplicated for the <tt>height</tt>, + * <tt>bottom</tt> and <tt>top</tt> properties, respectively. + * + * @param s a node in the scene graph; the instance of the mark to build. + */ +pv.Mark.prototype.buildImplied = function(s) { + var l = s.left; + var r = s.right; + var t = s.top; + var b = s.bottom; + + /* Assume width and height are zero if not supported by this mark type. */ + var p = this.type.prototype; + var w = p.width ? s.width : 0; + var h = p.height ? s.height : 0; + + /* Compute implied width, right and left. */ + var width = s.parent ? s.parent.width : 0; + if (w == null) { + w = width - (r = r || 0) - (l = l || 0); + } else if (r == null) { + r = width - w - (l = l || 0); + } else if (l == null) { + l = width - w - (r = r || 0); + } + + /* Compute implied height, bottom and top. */ + var height = s.parent ? s.parent.height : 0; + if (h == null) { + h = height - (t = t || 0) - (b = b || 0); + } else if (b == null) { + b = height - h - (t = t || 0); + } else if (t == null) { + t = height - h - (b = b || 0); + } + + s.left = l; + s.right = r; + s.top = t; + s.bottom = b; + + /* Only set width and height if they are supported by this mark type. */ + if (p.width) s.width = w; + if (p.height) s.height = h; +}; + +var property; // XXX + +/** + * Evaluates the property function with the specified name for the current data + * stack. The data stack, <tt>this.root.scene.data</tt>, contains the current + * datum, followed by the datum for the enclosing panel, and so on. + * + * <p>This method first finds the implementing property function by querying the + * current mark. If the current mark does not define the property function, the + * prototype mark is queried, and so on. If none of the mark prototypes define a + * property function with the given name, the type default function is used. If + * no default function is provided, this method returns null. + * + * <p>The context of the property function is <tt>this</tt> instance (i.e., the + * leaf-level mark), rather than whatever mark defined the property function. + * Because of this behavior, a property function may be called on an object of a + * different "class" (e.g., a Dot inheriting the fill style from a Line). Also + * note that properties are not inherited statically; inheritance happens at the + * property function / mark level, not per property value / mark instance. Thus, + * even if a Dot extends from a Line, if the Line's fill style is defined using + * a function that generates a random color, the Dot may get a different color. + * + * @param {string} name the property name. + * @returns the evaluated property value. + */ +pv.Mark.prototype.get = function(name) { + var mark = this; + while (!mark["$" + name]) { + mark = mark.proto; + if (!mark) { + mark = this.type.defaults; + while (!mark["$" + name]) { + mark = mark.proto; + if (!mark) { + return null; + } + } + break; + } + } + property = name; // XXX + return mark["$" + name].apply(this, this.root.scene.data); +}; + +/** + * Updates the display, propagating property values computed in the build phase + * to the SVG image. This method is typically invoked by {@link #render}, but is + * also invoked after an event handler is triggered to update the display of a + * specific mark. + * + * @see #event + */ +pv.Mark.prototype.update = function() { + for (var i = 0; i < this.scene.length; i++) { + this.updateInstance(this.scene[i]); + } +}; + +/** + * Updates the display for the specified mark instance <tt>s</tt> in the scene + * graph. This implementation handles basic properties for all mark types, such + * as visibility, cursor and title tooltip. Concrete mark types should override + * this method to specify how marks are rendered. + * + * @param s a node in the scene graph; the instance of the mark to update. + */ +pv.Mark.prototype.updateInstance = function(s) { + var that = this, v = s.svg; + + /* visible */ + if (!s.visible) { + if (v) v.setAttribute("display", "none"); + return; + } + v.removeAttribute("display"); + + /* cursor */ + if (s.cursor) v.style.cursor = s.cursor; + + /* title (Safari only supports xlink:title on anchor elements) */ + var p = v.parentNode; + if (s.title) { + if (!v.$title) { + v.$title = document.createElementNS(pv.ns.svg, "a"); + p.insertBefore(v.$title, v); + v.$title.appendChild(v); + } + v.$title.setAttributeNS(pv.ns.xlink, "title", s.title); + } else if (v.$title) { + p.insertBefore(v, v.$title); + p.removeChild(v.$title); + delete v.$title; + } + + /* event */ + function dispatch(type) { + return function(e) { + /* TODO set full scene stack. */ + var data = [s.data], p = s; + while ((p = p.parent)) { + data.push(p.data); + } + that.index = s.index; + that.scene = s.parent.children[that.childIndex]; + that.events[type].apply(that, data); + that.updateInstance(s); // XXX updateInstance, bah! + delete that.index; + delete that.scene; + e.preventDefault(); + }; + }; + + /* TODO inherit event handlers. */ + if (!this.events) + return; + for (var type in this.events) { + v["on" + type] = dispatch(type); + } +}; + +/** + * Registers an event handler for the specified event type with this mark. When + * an event of the specified type is triggered, the specified handler will be + * invoked. The handler is invoked in a similar method to property functions: + * the context is <tt>this</tt> mark instance, and the arguments are the full + * data stack. Event handlers can use property methods to manipulate the display + * properties of the mark: + * + * <pre>m.event("click", function() this.fillStyle("red"));</pre> + * + * Alternatively, the external data can be manipulated and the visualization + * redrawn: + * + * <pre>m.event("click", function(d) { + * data = all.filter(function(k) k.name == d); + * vis.render(); + * });</pre> + * + * TODO In the current event handler implementation, only the mark instance that + * triggered the event is updated, even if the event handler dirties the rest of + * the scene. While this can be ameliorated by explicitly re-rendering, it would + * be better and more efficient for the event dispatcher to handle dirtying and + * redraw automatically. + * + * <p>The complete set of event types is defined by SVG; see the reference + * below. The set of supported event types is:<ul> + * + * <li>click + * <li>mousedown + * <li>mouseup + * <li>mouseover + * <li>mousemove + * <li>mouseout + * + * </ul>Since Protovis does not specify any concept of focus, it does not + * support key events; these should be handled outside the visualization using + * standard JavaScript. In the future, support for interaction may be extended + * to support additional event types, particularly those most relevant to + * interactive visualization, such as selection. + * + * <p>TODO In the current implementation, event handlers are not inherited from + * prototype marks. They must be defined explicitly on each interactive mark. In + * addition, only one event handler for a given event type can be defined; when + * specifying multiple event handlers for the same type, only the last one will + * be used. + * + * @see <a href="http://www.w3.org/TR/SVGTiny12/interact.html#SVGEvents">SVG events</a>. + * @param {string} type the event type. + * @param {function} handler the event handler. + * @returns {pv.Mark} this. + */ +pv.Mark.prototype.event = function(type, handler) { + if (!this.events) this.events = {}; + this.events[type] = handler; + return this; +}; +/** + * Constructs a new area mark with default properties. Areas are not typically + * constructed directly, but by adding to a panel or an existing mark via + * {@link pv.Mark#add}. + * + * @class Represents an area mark: the solid area between two series of + * connected line segments. Unsurprisingly, areas are used most frequently for + * area charts. + * + * <p>Just as a line represents a polyline, the <tt>Area</tt> mark type + * represents a <i>polygon</i>. However, an area is not an arbitrary polygon; + * vertices are paired either horizontally or vertically into parallel + * <i>spans</i>, and each span corresponds to an associated datum. Either the + * width or the height must be specified, but not both; this determines whether + * the area is horizontally-oriented or vertically-oriented. Like lines, areas + * can be stroked and filled with arbitrary colors. + * + * <p>See also the <a href="../../api/Area.html">Area guide</a>. + * + * @extends pv.Mark + */ +pv.Area = function() { + pv.Mark.call(this); +}; +pv.Area.prototype = pv.extend(pv.Mark); +pv.Area.prototype.type = pv.Area; + +/** + * Returns "area". + * + * @returns {string} "area". + */ +pv.Area.toString = function() { return "area"; }; + +/** + * The width of a given span, in pixels; used for horizontal spans. If the width + * is specified, the height property should be 0 (the default). Either the top + * or bottom property should be used to space the spans vertically, typically as + * a multiple of the index. + * + * @type number + * @name pv.Area.prototype.width + */ +pv.Area.prototype.defineProperty("width"); + +/** + * The height of a given span, in pixels; used for vertical spans. If the height + * is specified, the width property should be 0 (the default). Either the left + * or right property should be used to space the spans horizontally, typically + * as a multiple of the index. + * + * @type number + * @name pv.Area.prototype.height + */ +pv.Area.prototype.defineProperty("height"); + +/** + * The width of stroked lines, in pixels; used in conjunction with + * <tt>strokeStyle</tt> to stroke the perimeter of the area. Unlike the + * {@link Line} mark type, the entire perimeter is stroked, rather than just one + * edge. The default value of this property is 1.5, but since the default stroke + * style is null, area marks are not stroked by default. + * + * <p>This property is <i>fixed</i>. See {@link pv.Mark}. + * + * @type number + * @name pv.Area.prototype.lineWidth + */ +pv.Area.prototype.defineProperty("lineWidth"); + +/** + * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to + * stroke the perimeter of the area. Unlike the {@link Line} mark type, the + * entire perimeter is stroked, rather than just one edge. The default value of + * this property is null, meaning areas are not stroked by default. + * + * <p>This property is <i>fixed</i>. See {@link pv.Mark}. + * + * @type string + * @name pv.Area.prototype.strokeStyle + * @see pv.color + */ +pv.Area.prototype.defineProperty("strokeStyle"); + +/** + * The area fill style; if non-null, the interior of the polygon forming the + * area is filled with the specified color. The default value of this property + * is a categorical color. + * + * <p>This property is <i>fixed</i>. See {@link pv.Mark}. + * + * @type string + * @name pv.Area.prototype.fillStyle + * @see pv.color + */ +pv.Area.prototype.defineProperty("fillStyle"); + +/** + * Default properties for areas. By default, there is no stroke and the fill + * style is a categorical color. + * + * @type pv.Area + */ +pv.Area.defaults = new pv.Area().extend(pv.Mark.defaults) + .lineWidth(1.5) + .fillStyle(pv.Colors.category20); + +/** + * Constructs a new area anchor with default properties. + * + * @class Represents an anchor for an area mark. Areas support five different + * anchors:<ul> + * + * <li>top + * <li>left + * <li>center + * <li>bottom + * <li>right + * + * </ul>In addition to positioning properties (left, right, top bottom), the + * anchors support text rendering properties (text-align, text-baseline). Text is + * rendered to appear inside the area polygon. + * + * <p>To facilitate stacking of areas, the anchors are defined in terms of their + * opposite edge. For example, the top anchor defines the bottom property, such + * that the area grows upwards; the bottom anchor instead defines the top + * property, such that the area grows downwards. Of course, in general it is + * more robust to use panels and the cousin accessor to define stacked area + * marks; see {@link pv.Mark#scene} for an example. + * + * @extends pv.Mark.Anchor + */ +pv.Area.Anchor = function() { + pv.Mark.Anchor.call(this); +}; +pv.Area.Anchor.prototype = pv.extend(pv.Mark.Anchor); +pv.Area.Anchor.prototype.type = pv.Area; + +/** + * The left property; null for "left" anchors, non-null otherwise. + * + * @type number + * @name pv.Area.Anchor.prototype.left + */ /** @private */ +pv.Area.Anchor.prototype.$left = function() { + var area = this.anchorTarget(); + switch (this.get("name")) { + case "bottom": + case "top": + case "center": return area.left() + area.width() / 2; + case "right": return area.left() + area.width(); + } + return null; +}; + +/** + * The right property; null for "right" anchors, non-null otherwise. + * + * @type number + * @name pv.Area.Anchor.prototype.right + */ /** @private */ +pv.Area.Anchor.prototype.$right = function() { + var area = this.anchorTarget(); + switch (this.get("name")) { + case "bottom": + case "top": + case "center": return area.right() + area.width() / 2; + case "left": return area.right() + area.width(); + } + return null; +}; + +/** + * The top property; null for "top" anchors, non-null otherwise. + * + * @type number + * @name pv.Area.Anchor.prototype.top + */ /** @private */ +pv.Area.Anchor.prototype.$top = function() { + var area = this.anchorTarget(); + switch (this.get("name")) { + case "left": + case "right": + case "center": return area.top() + area.height() / 2; + case "bottom": return area.top() + area.height(); + } + return null; +}; + +/** + * The bottom property; null for "bottom" anchors, non-null otherwise. + * + * @type number + * @name pv.Area.Anchor.prototype.bottom + */ /** @private */ +pv.Area.Anchor.prototype.$bottom = function() { + var area = this.anchorTarget(); + switch (this.get("name")) { + case "left": + case "right": + case "center": return area.bottom() + area.height() / 2; + case "top": return area.bottom() + area.height(); + } + return null; +}; + +/** + * The text-align property, for horizontal alignment inside the area. + * + * @type string + * @name pv.Area.Anchor.prototype.textAlign + */ /** @private */ +pv.Area.Anchor.prototype.$textAlign = function() { + switch (this.get("name")) { + case "left": return "left"; + case "bottom": + case "top": + case "center": return "center"; + case "right": return "right"; + } + return null; +}; + +/** + * The text-baseline property, for vertical alignment inside the area. + * + * @type string + * @name pv.Area.Anchor.prototype.textBasline + */ /** @private */ +pv.Area.Anchor.prototype.$textBaseline = function() { + switch (this.get("name")) { + case "right": + case "left": + case "center": return "middle"; + case "top": return "top"; + case "bottom": return "bottom"; + } + return null; +}; + +/** + * Overrides the default behavior of {@link pv.Mark#buildImplied} such that the + * width and height are set to zero if null. + * + * @param s a node in the scene graph; the instance of the mark to build. + */ +pv.Area.prototype.buildImplied = function(s) { + if (s.height == null) s.height = 0; + if (s.width == null) s.width = 0; + pv.Mark.prototype.buildImplied.call(this, s); +}; + +/** + * Override the default update implementation, since the area mark generates a + * single graphical element rather than multiple distinct elements. + */ +pv.Area.prototype.update = function() { + if (!this.scene.length) return; + + var s = this.scene[0], v = s.svg; + if (s.visible) { + + /* Create the <svg:polygon> element, if necessary. */ + if (!v) { + v = s.svg = document.createElementNS(pv.ns.svg, "polygon"); + s.parent.svg.appendChild(v); + } + + /* points */ + var p = ""; + for (var i = 0; i < this.scene.length; i++) { + var si = this.scene[i]; + p += si.left + "," + si.top + " "; + } + for (var i = this.scene.length - 1; i >= 0; i--) { + var si = this.scene[i]; + p += (si.left + si.width) + "," + (si.top + si.height) + " "; + } + v.setAttribute("points", p); + } + + this.updateInstance(s); +}; + +/** + * Updates the display for the (singleton) area instance. The area mark + * generates a single graphical element rather than multiple distinct elements. + * + * <p>TODO Recompute points? For efficiency, the points (the span positions) are + * not recomputed, and therefore cannot be updated automatically from event + * handlers without an explicit call to rebuild the area. + * + * @param s a node in the scene graph; the area to update. + */ +pv.Area.prototype.updateInstance = function(s) { + var v = s.svg; + + pv.Mark.prototype.updateInstance.call(this, s); + if (!s.visible) return; + + /* fill, stroke TODO gradient, patterns */ + var fill = pv.color(s.fillStyle); + v.setAttribute("fill", fill.color); + v.setAttribute("fill-opacity", fill.opacity); + var stroke = pv.color(s.strokeStyle); + v.setAttribute("stroke", stroke.color); + v.setAttribute("stroke-opacity", stroke.opacity); + v.setAttribute("stroke-width", s.lineWidth); +}; +/** + * Constructs a new bar mark with default properties. Bars are not typically + * constructed directly, but by adding to a panel or an existing mark via + * {@link pv.Mark#add}. + * + * @class Represents a bar: an axis-aligned rectangle that can be stroked and + * filled. Bars are used for many chart types, including bar charts, histograms + * and Gantt charts. Bars can also be used as decorations, for example to draw a + * frame border around a panel; in fact, a panel is a special type (a subclass) + * of bar. + * + * <p>Bars can be positioned in several ways. Most commonly, one of the four + * corners is fixed using two margins, and then the width and height properties + * determine the extent of the bar relative to this fixed location. For example, + * using the bottom and left properties fixes the bottom-left corner; the width + * then extends to the right, while the height extends to the top. As an + * alternative to the four corners, a bar can be positioned exclusively using + * margins; this is convenient as an inset from the containing panel, for + * example. See {@link pv.Mark#buildImplied} for details on the prioritization + * of redundant positioning properties. + * + * <p>See also the <a href="../../api/Bar.html">Bar guide</a>. + * + * @extends pv.Mark + */ +pv.Bar = function() { + pv.Mark.call(this); +}; +pv.Bar.prototype = pv.extend(pv.Mark); +pv.Bar.prototype.type = pv.Bar; + +/** + * Returns "bar". + * + * @returns {string} "bar". + */ +pv.Bar.toString = function() { return "bar"; }; + +/** + * The width of the bar, in pixels. If the left position is specified, the bar + * extends rightward from the left edge; if the right position is specified, the + * bar extends leftward from the right edge. + * + * @type number + * @name pv.Bar.prototype.width + */ +pv.Bar.prototype.defineProperty("width"); + +/** + * The height of the bar, in pixels. If the bottom position is specified, the + * bar extends upward from the bottom edge; if the top position is specified, + * the bar extends downward from the top edge. + * + * @type number + * @name pv.Bar.prototype.height + */ +pv.Bar.prototype.defineProperty("height"); + +/** + * The width of stroked lines, in pixels; used in conjunction with + * <tt>strokeStyle</tt> to stroke the bar's border. + * + * @type number + * @name pv.Bar.prototype.lineWidth + */ +pv.Bar.prototype.defineProperty("lineWidth"); + +/** + * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to + * stroke the bar's border. The default value of this property is null, meaning + * bars are not stroked by default. + * + * @type string + * @name pv.Bar.prototype.strokeStyle + * @see pv.color + */ +pv.Bar.prototype.defineProperty("strokeStyle"); + +/** + * The bar fill style; if non-null, the interior of the bar is filled with the + * specified color. The default value of this property is a categorical color. + * + * @type string + * @name pv.Bar.prototype.fillStyle + * @see pv.color + */ +pv.Bar.prototype.defineProperty("fillStyle"); + +/** + * Default properties for bars. By default, there is no stroke and the fill + * style is a categorical color. + * + * @type pv.Bar + */ +pv.Bar.defaults = new pv.Bar().extend(pv.Mark.defaults) + .lineWidth(1.5) + .fillStyle(pv.Colors.category20); + +/** + * Constructs a new bar anchor with default properties. + * + * @class Represents an anchor for a bar mark. Bars support five different + * anchors:<ul> + * + * <li>top + * <li>left + * <li>center + * <li>bottom + * <li>right + * + * </ul>In addition to positioning properties (left, right, top bottom), the + * anchors support text rendering properties (text-align, text-baseline). Text + * is rendered to appear inside the bar. + * + * <p>To facilitate stacking of bars, the anchors are defined in terms of their + * opposite edge. For example, the top anchor defines the bottom property, such + * that the bar grows upwards; the bottom anchor instead defines the top + * property, such that the bar grows downwards. Of course, in general it is more + * robust to use panels and the cousin accessor to define stacked bars; see + * {@link pv.Mark#scene} for an example. + * + * <p>Bar anchors also "smartly" specify position properties based on whether + * the derived mark type supports the width and height properties. If the + * derived mark type does not support these properties (e.g., dots), the + * position will be centered on the corresponding edge. Otherwise (e.g., bars), + * the position will be in the opposite side. + * + * @extends pv.Mark.Anchor + */ +pv.Bar.Anchor = function() { + pv.Mark.Anchor.call(this); +}; +pv.Bar.Anchor.prototype = pv.extend(pv.Mark.Anchor); +pv.Bar.Anchor.prototype.type = pv.Bar; + +/** + * The left property; null for "left" anchors, non-null otherwise. + * + * @type number + * @name pv.Bar.Anchor.prototype.left + */ /** @private */ +pv.Bar.Anchor.prototype.$left = function() { + var bar = this.anchorTarget(); + switch (this.get("name")) { + case "bottom": + case "top": + case "center": return bar.left() + (this.type.prototype.width ? 0 : (bar.width() / 2)); + case "right": return bar.left() + bar.width(); + } + return null; +}; + +/** + * The right property; null for "right" anchors, non-null otherwise. + * + * @type number + * @name pv.Bar.Anchor.prototype.right + */ /** @private */ +pv.Bar.Anchor.prototype.$right = function() { + var bar = this.anchorTarget(); + switch (this.get("name")) { + case "bottom": + case "top": + case "center": return bar.right() + (this.type.prototype.width ? 0 : (bar.width() / 2)); + case "left": return bar.right() + bar.width(); + } + return null; +}; + +/** + * The top property; null for "top" anchors, non-null otherwise. + * + * @type number + * @name pv.Bar.Anchor.prototype.top + */ /** @private */ +pv.Bar.Anchor.prototype.$top = function() { + var bar = this.anchorTarget(); + switch (this.get("name")) { + case "left": + case "right": + case "center": return bar.top() + (this.type.prototype.height ? 0 : (bar.height() / 2)); + case "bottom": return bar.top() + bar.height(); + } + return null; +}; + +/** + * The bottom property; null for "bottom" anchors, non-null otherwise. + * + * @type number + * @name pv.Bar.Anchor.prototype.bottom + */ /** @private */ +pv.Bar.Anchor.prototype.$bottom = function() { + var bar = this.anchorTarget(); + switch (this.get("name")) { + case "left": + case "right": + case "center": return bar.bottom() + (this.type.prototype.height ? 0 : (bar.height() / 2)); + case "top": return bar.bottom() + bar.height(); + } + return null; +}; + +/** + * The text-align property, for horizontal alignment inside the bar. + * + * @type string + * @name pv.Bar.Anchor.prototype.textAlign + */ /** @private */ +pv.Bar.Anchor.prototype.$textAlign = function() { + switch (this.get("name")) { + case "left": return "left"; + case "bottom": + case "top": + case "center": return "center"; + case "right": return "right"; + } + return null; +}; + +/** + * The text-baseline property, for vertical alignment inside the bar. + * + * @type string + * @name pv.Bar.Anchor.prototype.textBaseline + */ /** @private */ +pv.Bar.Anchor.prototype.$textBaseline = function() { + switch (this.get("name")) { + case "right": + case "left": + case "center": return "middle"; + case "top": return "top"; + case "bottom": return "bottom"; + } + return null; +}; + +/** + * Updates the display for the specified bar instance <tt>s</tt> in the scene + * graph. This implementation handles the fill and stroke style for the bar, as + * well as positional properties. + * + * @param s a node in the scene graph; the instance of the bar to update. + */ +pv.Bar.prototype.updateInstance = function(s) { + var v = s.svg; + if (s.visible && !v) { + v = s.svg = document.createElementNS(pv.ns.svg, "rect"); + s.parent.svg.appendChild(v); + } + + pv.Mark.prototype.updateInstance.call(this, s); + if (!s.visible) return; + + /* left, top */ + v.setAttribute("x", s.left); + v.setAttribute("y", s.top); + + /* If width and height are exactly zero, the rect is not stroked! */ + v.setAttribute("width", Math.max(1E-10, s.width)); + v.setAttribute("height", Math.max(1E-10, s.height)); + + /* fill, stroke TODO gradient, patterns */ + var fill = pv.color(s.fillStyle); + v.setAttribute("fill", fill.color); + v.setAttribute("fill-opacity", fill.opacity); + var stroke = pv.color(s.strokeStyle); + v.setAttribute("stroke", stroke.color); + v.setAttribute("stroke-opacity", stroke.opacity); + v.setAttribute("stroke-width", s.lineWidth); +}; +/** + * Constructs a new dot mark with default properties. Dots are not typically + * constructed directly, but by adding to a panel or an existing mark via + * {@link pv.Mark#add}. + * + * @class Represents a dot; a dot is simply a sized glyph centered at a given + * point that can also be stroked and filled. The <tt>size</tt> property is + * proportional to the area of the rendered glyph to encourage meaningful visual + * encodings. Dots can visually encode up to eight dimensions of data, though + * this may be unwise due to integrality. See {@link pv.Mark#buildImplied} for + * details on the prioritization of redundant positioning properties. + * + * <p>See also the <a href="../../api/Dot.html">Dot guide</a>. + * + * @extends pv.Mark + */ +pv.Dot = function() { + pv.Mark.call(this); +}; +pv.Dot.prototype = pv.extend(pv.Mark); +pv.Dot.prototype.type = pv.Dot; + +/** + * Returns "dot". + * + * @returns {string} "dot". + */ +pv.Dot.toString = function() { return "dot"; }; + +/** + * The size of the dot, in square pixels. Square pixels are used such that the + * area of the dot is linearly proportional to the value of the size property, + * facilitating representative encodings. + * + * @see #radius + * @type number + * @name pv.Dot.prototype.size + */ +pv.Dot.prototype.defineProperty("size"); + +/** + * The shape name. Several shapes are supported:<ul> + * + * <li>cross + * <li>triangle + * <li>diamond + * <li>square + * <li>tick + * <li>circle + * + * </ul>These shapes can be further changed using the {@link #angle} property; + * for instance, a cross can be turned into a plus by rotating. Similarly, the + * tick, which is vertical by default, can be rotated horizontally. Note that + * some shapes (cross and tick) do not have interior areas, and thus do not + * support fill style meaningfully. + * + * <p>TODO It's probably better to use the Rule mark type rather than a + * tick-shaped Dot. However, the Rule mark doesn't support the width and height + * properties, so it's a bit clumsy to use. It should be possible to add support + * for width and height to rule, and then remove the tick shape. + * + * @type string + * @name pv.Dot.prototype.shape + */ +pv.Dot.prototype.defineProperty("shape"); + +/** + * The rotation angle, in radians. Used to rotate shapes, such as to turn a + * cross into a plus. + * + * @type number + * @name pv.Dot.prototype.angle + */ +pv.Dot.prototype.defineProperty("angle"); + +/** + * The width of stroked lines, in pixels; used in conjunction with + * <tt>strokeStyle</tt> to stroke the dot's shape. + * + * @type number + * @name pv.Dot.prototype.lineWidth + */ +pv.Dot.prototype.defineProperty("lineWidth"); + +/** + * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to + * stroke the dot's shape. The default value of this property is a categorical + * color. + * + * @type string + * @name pv.Dot.prototype.strokeStyle + * @see pv.color + */ +pv.Dot.prototype.defineProperty("strokeStyle"); + +/** + * The fill style; if non-null, the interior of the dot is filled with the + * specified color. The default value of this property is null, meaning dots are + * not filled by default. + * + * @type string + * @name pv.Dot.prototype.fillStyle + * @see pv.color + */ +pv.Dot.prototype.defineProperty("fillStyle"); + +/** + * Default properties for dots. By default, there is no fill and the stroke + * style is a categorical color. The default shape is "circle" with size 20. + * + * @type pv.Dot + */ +pv.Dot.defaults = new pv.Dot().extend(pv.Mark.defaults) + .size(20) + .shape("circle") + .lineWidth(1.5) + .strokeStyle(pv.Colors.category10); + +/** + * Constructs a new dot anchor with default properties. + * + * @class Represents an anchor for a dot mark. Dots support five different + * anchors:<ul> + * + * <li>top + * <li>left + * <li>center + * <li>bottom + * <li>right + * + * </ul>In addition to positioning properties (left, right, top bottom), the + * anchors support text rendering properties (text-align, text-baseline). Text is + * rendered to appear outside the dot. Note that this behavior is different from + * other mark anchors, which default to rendering text <i>inside</i> the mark. + * + * <p>For consistency with the other mark types, the anchor positions are + * defined in terms of their opposite edge. For example, the top anchor defines + * the bottom property, such that a bar added to the top anchor grows upward. + * + * @extends pv.Mark.Anchor + */ +pv.Dot.Anchor = function() { + pv.Mark.Anchor.call(this); +}; +pv.Dot.Anchor.prototype = pv.extend(pv.Mark.Anchor); +pv.Dot.Anchor.prototype.type = pv.Dot; + +/** + * The left property; null for "left" anchors, non-null otherwise. + * + * @type number + * @name pv.Dot.Anchor.prototype.left + */ /** @private */ +pv.Dot.Anchor.prototype.$left = function(d) { + var dot = this.anchorTarget(); + switch (this.get("name")) { + case "bottom": + case "top": + case "center": return dot.left(); + case "right": return dot.left() + dot.radius(); + } + return null; +}; + +/** + * The right property; null for "right" anchors, non-null otherwise. + * + * @type number + * @name pv.Dot.Anchor.prototype.right + */ /** @private */ +pv.Dot.Anchor.prototype.$right = function(d) { + var dot = this.anchorTarget(); + switch (this.get("name")) { + case "bottom": + case "top": + case "center": return dot.right(); + case "left": return dot.right() + dot.radius(); + } + return null; +}; + +/** + * The top property; null for "top" anchors, non-null otherwise. + * + * @type number + * @name pv.Dot.Anchor.prototype.top + */ /** @private */ +pv.Dot.Anchor.prototype.$top = function(d) { + var dot = this.anchorTarget(); + switch (this.get("name")) { + case "left": + case "right": + case "center": return dot.top(); + case "bottom": return dot.top() + dot.radius(); + } + return null; +}; + +/** + * The bottom property; null for "bottom" anchors, non-null otherwise. + * + * @type number + * @name pv.Dot.Anchor.prototype.bottom + */ /** @private */ +pv.Dot.Anchor.prototype.$bottom = function(d) { + var dot = this.anchorTarget(); + switch (this.get("name")) { + case "left": + case "right": + case "center": return dot.bottom(); + case "top": return dot.bottom() + dot.radius(); + } + return null; +}; + +/** + * The text-align property, for horizontal alignment outside the dot. + * + * @type string + * @name pv.Dot.Anchor.prototype.textAlign + */ /** @private */ +pv.Dot.Anchor.prototype.$textAlign = function(d) { + switch (this.get("name")) { + case "left": return "right"; + case "bottom": + case "top": + case "center": return "center"; + case "right": return "left"; + } + return null; +}; + +/** + * The text-baseline property, for vertical alignment outside the dot. + * + * @type string + * @name pv.Dot.Anchor.prototype.textBasline + */ /** @private */ +pv.Dot.Anchor.prototype.$textBaseline = function(d) { + switch (this.get("name")) { + case "right": + case "left": + case "center": return "middle"; + case "top": return "bottom"; + case "bottom": return "top"; + } + return null; +}; + +/** + * Returns the radius of the dot, which is defined to be the square root of the + * {@link #size} property. + * + * @returns {number} the radius. + */ +pv.Dot.prototype.radius = function() { + return Math.sqrt(this.size()); +}; + +/** + * Updates the display for the specified dot instance <tt>s</tt> in the scene + * graph. This implementation handles the fill and stroke style for the dot, as + * well as positional properties. + * + * @param s a node in the scene graph; the instance of the dot to update. + */ +pv.Dot.prototype.updateInstance = function(s) { + var v = s.svg; + + /* Create the <svg:path> element, if necessary. */ + if (s.visible && !v) { + v = s.svg = document.createElementNS(pv.ns.svg, "path"); + s.parent.svg.appendChild(v); + } + + /* visible, cursor, title, event, etc. */ + pv.Mark.prototype.updateInstance.call(this, s); + if (!s.visible) return; + + /* left, top */ + v.setAttribute("transform", "translate(" + s.left + "," + s.top +")" + + (s.angle ? " rotate(" + 180 * s.angle / Math.PI + ")" : "")); + + /* fill, stroke TODO gradient, patterns? */ + var fill = pv.color(s.fillStyle); + v.setAttribute("fill", fill.color); + v.setAttribute("fill-opacity", fill.opacity); + var stroke = pv.color(s.strokeStyle); + v.setAttribute("stroke", stroke.color); + v.setAttribute("stroke-opacity", stroke.opacity); + v.setAttribute("stroke-width", s.lineWidth); + + /* shape, size */ + var radius = Math.sqrt(s.size); + var d; + switch (s.shape) { + case "cross": { + d = "M" + -radius + "," + -radius + + "L" + radius + "," + radius + + "M" + radius + "," + -radius + + "L" + -radius + "," + radius; + break; + } + case "triangle": { + var h = radius, w = radius * 2 / Math.sqrt(3); + d = "M0," + h + + "L" + w +"," + -h + + " " + -w + "," + -h + + "Z"; + break; + } + case "diamond": { + radius *= Math.sqrt(2); + d = "M0," + -radius + + "L" + radius + ",0" + + " 0," + radius + + " " + -radius + ",0" + + "Z"; + break; + } + case "square": { + d = "M" + -radius + "," + -radius + + "L" + radius + "," + -radius + + " " + radius + "," + radius + + " " + -radius + "," + radius + + "Z"; + break; + } + case "tick": { + d = "M0,0L0," + -s.size; + break; + } + default: { // circle + d = "M0," + radius + + "A" + radius + "," + radius + " 0 1,1 0," + (-radius) + + "A" + radius + "," + radius + " 0 1,1 0," + radius + + "Z"; + break; + } + } + v.setAttribute("d", d); +}; +/** + * Constructs a new dot mark with default properties. Images are not typically + * constructed directly, but by adding to a panel or an existing mark via + * {@link pv.Mark#add}. + * + * @class Represents an image. Images share the same layout and style properties as + * bars, in conjunction with an external image such as PNG or JPEG. The image is + * specified via the {@link #url} property. The fill, if specified, appears + * beneath the image, while the optional stroke appears above the image. + * + * <p>TODO Restore support for dynamic images (such as heatmaps). These were + * supported in the canvas implementation using the pixel buffer API; although + * SVG does not support pixel manipulation, it is possible to embed a canvas + * element in SVG using foreign objects. + * + * <p>TODO Allow different modes of image placement: "scale" -- scale and + * preserve aspect ratio, "tile" -- repeat the image, "center" -- center the + * image, "fill" -- scale without preserving aspect ratio. + * + * <p>See {@link pv.Bar} for details on positioning properties. + * + * @extends pv.Bar + */ +pv.Image = function() { + pv.Bar.call(this); +}; +pv.Image.prototype = pv.extend(pv.Bar); +pv.Image.prototype.type = pv.Image; + +/** + * Returns "image". + * + * @returns {string} "image". + */ +pv.Image.toString = function() { return "image"; }; + +/** + * The URL of the image to display. The set of supported image types is + * browser-dependent; PNG and JPEG are recommended. + * + * @type string + * @name pv.Image.prototype.url + */ +pv.Image.prototype.defineProperty("url"); + +/** + * Default properties for images. By default, there is no stroke or fill style. + * + * @type pv.Image + */ +pv.Image.defaults = new pv.Image().extend(pv.Bar.defaults) + .fillStyle(null); + +/** + * Updates the display for the specified image instance <tt>s</tt> in the scene + * graph. This implementation handles the fill and stroke style for the image, + * as well as positional properties. + * + * <p>Image rendering is a bit more complicated than most marks because it can + * entail up to four SVG elements: three for the fill, image and stroke, and the + * fourth an anchor element for the title tooltip. The anchor element is placed + * around the stroke rect element, if present, and otherwise the image element. + * Similarly the event handlers and cursor style is placed on the stroke + * element, if present, and otherwise the image element. Note that since the + * stroke element is transparent, the <tt>pointer-events</tt> attribute is used + * to capture events. + * + * @param s a node in the scene graph; the instance of the image to update. + */ +pv.Image.prototype.updateInstance = function(s) { + var v = s.svg; + + /* Create the svg:image element, if necessary. */ + if (s.visible && !v) { + v = s.svg = document.createElementNS(pv.ns.svg, "image"); + v.setAttribute("preserveAspectRatio", "none"); + s.parent.svg.appendChild(v); + } + + /* + * If no stroke is specified, then the event handlers and title anchor element + * can be placed on the image element. However, if there was previously a + * title anchor element around the stroke element, we must be careful to + * remove it. This logic could likely be simplified. + */ + if (!s.strokeStyle) { + if (v.$stroke) { + v.parentNode.removeChild(v.$stroke.$title || v.$stroke); + delete v.$stroke; + } + + /* cursor, title, events, etc. */ + pv.Mark.prototype.updateInstance.call(this, s); + } + + /* visible */ + function display(v) { + s.visible ? v.removeAttribute("display") : v.setAttribute("display", "none"); + } + if (v) { + display(v); + if (v.$stroke) display(v.$stroke); + if (v.$fill) display(v.$fill); + } + if (!s.visible) return; + + /* left, top, width, height */ + function position(v) { + v.setAttribute("x", s.left); + v.setAttribute("y", s.top); + v.setAttribute("width", s.width); + v.setAttribute("height", s.height); + } + position(v); + + /* fill (via an underlaid svg:rect element) */ + if (s.fillStyle) { + var f = v.$fill; + if (!f) { + f = v.$fill = document.createElementNS(pv.ns.svg, "rect"); + (v.$title || v).parentNode.insertBefore(f, (v.$title || v)); + } + position(f); + var fill = pv.color(s.fillStyle); + f.setAttribute("fill", fill.color); + f.setAttribute("fill-opacity", fill.opacity); + } else if (v.$fill) { + v.$fill.parentNode.removeChild(v.$fill); + delete v.$fill; + } + + /* stroke (via an overlaid svg:rect element) */ + if (s.strokeStyle) { + var f = v.$stroke; + + /* + * If the $title attribute is set, that means the title anchor element was + * previously on the image element; now that the stroke style is set, we + * must delete the old title element to make room for the new one. + */ + if (v.$title) { + var p = v.$title.parentNode; + p.insertBefore(v, v.$title); + p.removeChild(v.$title); + delete v.$title; + } + + /* Create the stroke svg:rect element, if necessary. */ + if (!f) { + f = v.$stroke = document.createElementNS(pv.ns.svg, "rect"); + f.setAttribute("fill", "none"); + f.setAttribute("pointer-events", "all"); + v.parentNode.insertBefore(f, v.nextElementSibling); + } + position(f); + var stroke = pv.color(s.strokeStyle); + f.setAttribute("stroke", stroke.color); + f.setAttribute("stroke-opacity", stroke.opacity); + f.setAttribute("stroke-width", s.lineWidth); + + /* cursor, title, events, etc. */ + try { + s.svg = f; + pv.Mark.prototype.updateInstance.call(this, s); + } finally { + s.svg = v; + } + } + + /* url */ + v.setAttributeNS(pv.ns.xlink, "href", s.url); +}; +/** + * Constructs a new label mark with default properties. Labels are not typically + * constructed directly, but by adding to a panel or an existing mark via + * {@link pv.Mark#add}. + * + * @class Represents a text label, allowing textual annotation of other marks or + * arbitrary text within the visualization. The character data must be plain + * text (unicode), though the text can be styled using the {@link #font} + * property. If rich text is needed, external HTML elements can be overlaid on + * the canvas by hand. + * + * <p>Labels are positioned using the box model, similarly to {@link Dot}. Thus, + * a label has no width or height, but merely a text anchor location. The text + * is positioned relative to this anchor location based on the + * {@link #textAlign}, {@link #textBaseline} and {@link #textMargin} properties. + * Furthermore, the text may be rotated using {@link #textAngle}. + * + * <p>Labels ignore events, so as to not interfere with event handlers on + * underlying marks, such as bars. In the future, we may support event handlers + * on labels. + * + * <p>See also the <a href="../../api/Label.html">Label guide</a>. + * + * @extends pv.Mark + */ +pv.Label = function() { + pv.Mark.call(this); +}; +pv.Label.prototype = pv.extend(pv.Mark); +pv.Label.prototype.type = pv.Label; + +/** + * Returns "label". + * + * @returns {string} "label". + */ +pv.Label.toString = function() { return "label"; }; + +/** + * The character data to render; a string. The default value of the text + * property is the identity function, meaning the label's associated datum will + * be rendered using its <tt>toString</tt>. + * + * @type string + * @name pv.Label.prototype.text + */ +pv.Label.prototype.defineProperty("text"); + +/** + * The font format, per the CSS Level 2 specification. The default font is "10px + * sans-serif", for consistency with the HTML 5 canvas element specification. + * Note that since text is not wrapped, any line-height property will be + * ignored. The other font-style, font-variant, font-weight, font-size and + * font-family properties are supported. + * + * @see <a href="http://www.w3.org/TR/CSS2/fonts.html#font-shorthand">CSS2 fonts</a>. + * @type string + * @name pv.Label.prototype.font + */ +pv.Label.prototype.defineProperty("font"); + +/** + * The rotation angle, in radians. Text is rotated clockwise relative to the + * anchor location. For example, with the default left alignment, an angle of + * Math.PI / 2 causes text to proceed downwards. The default angle is zero. + * + * @type number + * @name pv.Label.prototype.textAngle + */ +pv.Label.prototype.defineProperty("textAngle"); + +/** + * The text color. The name "textStyle" is used for consistency with "fillStyle" + * and "strokeStyle", although it might be better to rename this property (and + * perhaps use the same name as "strokeStyle"). The default color is black. + * + * @type string + * @name pv.Label.prototype.textStyle + * @see pv.color + */ +pv.Label.prototype.defineProperty("textStyle"); + +/** + * The horizontal text alignment. One of:<ul> + * + * <li>left + * <li>center + * <li>right + * + * </ul>The default horizontal alignment is left. + * + * @type string + * @name pv.Label.prototype.textAlign + */ +pv.Label.prototype.defineProperty("textAlign"); + +/** + * The vertical text alignment. One of:<ul> + * + * <li>top + * <li>middle + * <li>bottom + * + * </ul>The default vertical alignment is bottom. + * + * @type string + * @name pv.Label.prototype.textBaseline + */ +pv.Label.prototype.defineProperty("textBaseline"); + +/** + * The text margin; may be specified in pixels, or in font-dependent units + * (e.g., ".1ex"). The margin can be used to pad text away from its anchor + * location, in a direction dependent on the horizontal and vertical alignment + * properties. For example, if the text is left- and middle-aligned, the margin + * shifts the text to the right. The default margin is 3 pixels. + * + * @type number + * @name pv.Label.prototype.textMargin + */ +pv.Label.prototype.defineProperty("textMargin"); + +/** + * A list of shadow effects to be applied to text, per the CSS Text Level 3 + * text-shadow property. An example specification is "0.1em 0.1em 0.1em + * rgba(0,0,0,.5)"; the first length is the horizontal offset, the second the + * vertical offset, and the third the blur radius. + * + * @see <a href="http://www.w3.org/TR/css3-text/#text-shadow">CSS3 text</a>. + * @type string + * @name pv.Label.prototype.textShadow + */ +pv.Label.prototype.defineProperty("textShadow"); + +/** + * Default properties for labels. See the individual properties for the default + * values. + * + * @type pv.Label + */ +pv.Label.defaults = new pv.Label().extend(pv.Mark.defaults) + .text(pv.identity) + .font("10px sans-serif") + .textAngle(0) + .textStyle("black") + .textAlign("left") + .textBaseline("bottom") + .textMargin(3); + +/** + * Updates the display for the specified label instance <tt>s</tt> in the scene + * graph. This implementation handles the text formatting for the label, as well + * as positional properties. + * + * @param s a node in the scene graph; the instance of the dot to update. + */ +pv.Label.prototype.updateInstance = function(s) { + var v = s.svg; + + /* Create the svg:text element, if necessary. */ + if (s.visible && !v) { + v = s.svg = document.createElementNS(pv.ns.svg, "text"); + v.$text = document.createTextNode(""); + v.appendChild(v.$text); + s.parent.svg.appendChild(v); + } + + /* cursor, title, events, visible, etc. */ + pv.Mark.prototype.updateInstance.call(this, s); + if (!s.visible) return; + + /* left, top, angle */ + v.setAttribute("transform", "translate(" + s.left + "," + s.top + ")" + + (s.textAngle ? " rotate(" + 180 * s.textAngle / Math.PI + ")" : "")); + + /* text-baseline */ + switch (s.textBaseline) { + case "middle": { + v.removeAttribute("y"); + v.setAttribute("dy", ".35em"); + break; + } + case "top": { + v.setAttribute("y", s.textMargin); + v.setAttribute("dy", ".71em"); + break; + } + case "bottom": { + v.setAttribute("y", "-" + s.textMargin); + v.removeAttribute("dy"); + break; + } + } + + /* text-align */ + switch (s.textAlign) { + case "right": { + v.setAttribute("text-anchor", "end"); + v.setAttribute("x", "-" + s.textMargin); + break; + } + case "center": { + v.setAttribute("text-anchor", "middle"); + v.removeAttribute("x"); + break; + } + case "left": { + v.setAttribute("text-anchor", "start"); + v.setAttribute("x", s.textMargin); + break; + } + } + + /* font, text-shadow TODO centralize font definition? */ + v.$text.nodeValue = s.text; + var style = "font:" + s.font + ";"; + if (s.textShadow) { + style += "text-shadow:" + s.textShadow +";"; + } + v.setAttribute("style", style); + + /* fill */ + var fill = pv.color(s.textStyle); + v.setAttribute("fill", fill.color); + v.setAttribute("fill-opacity", fill.opacity); + + /* TODO enable interaction on labels? centralize this definition? */ + v.setAttribute("pointer-events", "none"); +}; +/** + * Constructs a new line mark with default properties. Lines are not typically + * constructed directly, but by adding to a panel or an existing mark via + * {@link pv.Mark#add}. + * + * @class Represents a series of connected line segments, or <i>polyline</i>, + * that can be stroked with a configurable color and thickness. Each + * articulation point in the line corresponds to a datum; for <i>n</i> points, + * <i>n</i>-1 connected line segments are drawn. The point is positioned using + * the box model. Arbitrary paths are also possible, allowing radar plots and + * other custom visualizations. + * + * <p>Like areas, lines can be stroked and filled with arbitrary colors. In most + * cases, lines are only stroked, but the fill style can be used to construct + * arbitrary polygons. + * + * <p>See also the <a href="../../api/Line.html">Line guide</a>. + * + * @extends pv.Mark + */ +pv.Line = function() { + pv.Mark.call(this); +}; +pv.Line.prototype = pv.extend(pv.Mark); +pv.Line.prototype.type = pv.Line; + +/** + * Returns "line". + * + * @returns {string} "line". + */ +pv.Line.toString = function() { return "line"; }; + +/** + * The width of stroked lines, in pixels; used in conjunction with + * <tt>strokeStyle</tt> to stroke the line. + * + * @type number + * @name pv.Line.prototype.lineWidth + */ +pv.Line.prototype.defineProperty("lineWidth"); + +/** + * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to + * stroke the line. The default value of this property is a categorical color. + * + * @type string + * @name pv.Line.prototype.strokeStyle + * @see pv.color + */ +pv.Line.prototype.defineProperty("strokeStyle"); + +/** + * The line fill style; if non-null, the interior of the line is closed and + * filled with the specified color. The default value of this property is a + * null, meaning that lines are not filled by default. + * + * @type string + * @name pv.Line.prototype.fillStyle + * @see pv.color + */ +pv.Line.prototype.defineProperty("fillStyle"); + +/** + * Default properties for lines. By default, there is no fill and the stroke + * style is a categorical color. + * + * @type pv.Line + */ +pv.Line.defaults = new pv.Line().extend(pv.Mark.defaults) + .lineWidth(1.5) + .strokeStyle(pv.Colors.category10); + +/** + * Override the default update implementation, since the line mark generates a + * single graphical element rather than multiple distinct elements. + */ +pv.Line.prototype.update = function() { + if (!this.scene.length) return; + + /* visible */ + var s = this.scene[0], v = s.svg; + if (s.visible) { + + /* Create the svg:polyline element, if necessary. */ + if (!v) { + v = s.svg = document.createElementNS(pv.ns.svg, "polyline"); + s.parent.svg.appendChild(v); + } + + /* left, top TODO allow points to be changed on events? */ + var p = ""; + for (var i = 0; i < this.scene.length; i++) { + var si = this.scene[i]; + if (isNaN(si.left)) si.left = 0; + if (isNaN(si.top)) si.top = 0; + p += si.left + "," + si.top + " "; + } + v.setAttribute("points", p); + + /* cursor, title, events, etc. */ + this.updateInstance(s); + v.removeAttribute("display"); + } else if (v) { + v.setAttribute("display", "none"); + } +}; + +/** + * Updates the display for the (singleton) line instance. The line mark + * generates a single graphical element rather than multiple distinct elements. + * + * <p>TODO Recompute points? For efficiency, the points are not recomputed, and + * therefore cannot be updated automatically from event handlers without an + * explicit call to rebuild the line. + * + * @param s a node in the scene graph; the instance of the mark to update. + */ +pv.Line.prototype.updateInstance = function(s) { + var v = s.svg; + + pv.Mark.prototype.updateInstance.call(this, s); + if (!s.visible) return; + + /* fill, stroke TODO gradient, patterns */ + var fill = pv.color(s.fillStyle); + v.setAttribute("fill", fill.color); + v.setAttribute("fill-opacity", fill.opacity); + var stroke = pv.color(s.strokeStyle); + v.setAttribute("stroke", stroke.color); + v.setAttribute("stroke-opacity", stroke.opacity); + v.setAttribute("stroke-width", s.lineWidth); +}; +/** + * Constructs a new, empty panel with default properties. Panels, with the + * exception of the root panel, are not typically constructed directly; instead, + * they are added to an existing panel or mark via {@link pv.Mark#add}. + * + * @class Represents a container mark. Panels allow repeated or nested + * structures, commonly used in small multiple displays where a small + * visualization is tiled to facilitate comparison across one or more + * dimensions. Other types of visualizations may benefit from repeated and + * possibly overlapping structure as well, such as stacked area charts. Panels + * can also offset the position of marks to provide padding from surrounding + * content. + * + * <p>All Protovis displays have at least one panel; this is the root panel to + * which marks are rendered. The box model properties (four margins, width and + * height) are used to offset the positions of contained marks. The data + * property determines the panel count: a panel is generated once per associated + * datum. When nested panels are used, property functions can declare additional + * arguments to access the data associated with enclosing panels. + * + * <p>Panels can be rendered inline, facilitating the creation of sparklines. + * This allows designers to reuse browser layout features, such as text flow and + * tables; designers can also overlay HTML elements such as rich text and + * images. + * + * <p>All panels have a <tt>children</tt> array (possibly empty) containing the + * child marks in the order they were added. Panels also have a <tt>root</tt> + * field which points to the root (outermost) panel; the root panel's root field + * points to itself. + * + * <p>See also the <a href="../../api/">Protovis guide</a>. + * + * @extends pv.Bar + */ +pv.Panel = function() { + pv.Bar.call(this); + + /** + * The child marks; zero or more {@link pv.Mark}s in the order they were + * added. + * + * @see #add + * @type pv.Mark[] + */ + this.children = []; + this.root = this; + + /** + * The internal $dom field is set by the Protovis loader; see lang/init.js. It + * refers to the script element that contains the Protovis specification, so + * that the panel knows where in the DOM to insert the generated SVG element. + * + * @private + */ + this.$dom = pv.Panel.$dom; +}; +pv.Panel.prototype = pv.extend(pv.Bar); +pv.Panel.prototype.type = pv.Panel; + +/** + * Returns "panel". + * + * @returns {string} "panel". + */ +pv.Panel.toString = function() { return "panel"; }; + +/** + * The canvas element; either the string ID of the canvas element in the current + * document, or a reference to the canvas element itself. If null, a canvas + * element will be created and inserted into the document at the location of the + * script element containing the current Protovis specification. This property + * only applies to root panels and is ignored on nested panels. + * + * <p>Note: the "canvas" element here refers to a <tt>div</tt> (or other suitable + * HTML container element), <i>not</i> a <tt>canvas</tt> element. The name of + * this property is a historical anachronism from the first implementation that + * used HTML 5 canvas, rather than SVG. + * + * @type string + * @name pv.Panel.prototype.canvas + */ +pv.Panel.prototype.defineProperty("canvas"); + +/** + * The reverse property; a boolean determining whether child marks are ordered + * from front-to-back or back-to-front. SVG does not support explicit + * z-ordering; shapes are rendered in the order they appear. Thus, by default, + * child marks are rendered in the order they are added to the panel. Setting + * the reverse property to false reverses the order in which they are added to + * the SVG element; however, the properties are still evaluated (i.e., built) in + * forward order. + * + * @type boolean + * @name pv.Panel.prototype.reverse + */ +pv.Panel.prototype.defineProperty("reverse"); + +/** + * Default properties for panels. By default, the margins are zero, the fill + * style is transparent, and the reverse property is false. + * + * @type pv.Panel + */ +pv.Panel.defaults = new pv.Panel().extend(pv.Bar.defaults) + .top(0).left(0).bottom(0).right(0) + .fillStyle(null) + .reverse(false); + +/** + * Adds a new mark of the specified type to this panel. Unlike the normal + * {@link Mark#add} behavior, adding a mark to a panel does not cause the mark + * to inherit from the panel. Since the contained marks are offset by the panel + * margins already, inheriting properties is generally undesirable; of course, + * it is always possible to change this behavior by calling {@link Mark#extend} + * explicitly. + * + * @param {function} type the type of the new mark to add. + * @returns {pv.Mark} the new mark. + */ +pv.Panel.prototype.add = function(type) { + var child = new type(); + child.parent = this; + child.root = this.root; + child.childIndex = this.children.length; + this.children.push(child); + return child; +}; + +/** + * Creates a new canvas (SVG) element with the specified width and height, and + * inserts it into the current document. If the <tt>$dom</tt> field is set, as + * for text/javascript+protovis scripts, the SVG element is inserted into the + * DOM before the script element. Otherwise, the SVG element is inserted into + * the last child element of the document, as for text/javascript scripts. + * + * @param w the width of the canvas to create, in pixels. + * @param h the height of the canvas to create, in pixels. + * @return the new canvas (SVG) element. + */ +pv.Panel.prototype.createCanvas = function(w, h) { + + /** + * Returns the last element in the current document's body. The canvas element + * is appended to this last element if another DOM element has not already + * been specified via the <tt>$dom</tt> field. + */ + function lastElement() { + var node = document.body; + while (node.lastElementChild && node.lastElementChild.tagName) { + node = node.lastElementChild; + } + return (node == document.body) ? node : node.parentNode; + } + + /* Create the SVG element. */ + var c = document.createElementNS(pv.ns.svg, "svg"); + c.setAttribute("width", w); + c.setAttribute("height", h); + + /* Insert it into the DOM at the appropriate location. */ + this.$dom // script element for text/javascript+protovis + ? this.$dom.parentNode.insertBefore(c, this.$dom) + : lastElement().appendChild(c); + + return c; +}; + +/** + * Evaluates all of the properties for this panel for the specified instance + * <tt>s</tt> in the scene graph, including recursively building the scene graph + * for child marks. + * + * @param s a node in the scene graph; the instance of the panel to build. + * @see Mark#scene + */ +pv.Panel.prototype.buildInstance = function(s) { + pv.Bar.prototype.buildInstance.call(this, s); + + /* + * Build each child, passing in the parent (this panel) scene graph node. The + * child mark's scene is initialized from the corresponding entry in the + * existing scene graph, such that properties from the previous build can be + * reused; this is largely to facilitate the recycling of SVG elements. + */ + for (var i = 0; i < this.children.length; i++) { + this.children[i].scene = s.children[i] || []; + this.children[i].build(s); + } + + /* + * Once the child marks have been built, the new scene graph nodes are removed + * from the child marks and placed into the scene graph. The nodes cannot + * remain on the child nodes because this panel (or a parent panel) may be + * instantiated multiple times! + */ + for (var i = 0; i < this.children.length; i++) { + s.children[i] = this.children[i].scene; + delete this.children[i].scene; + } + + /* Delete any expired child scenes, should child marks have been removed. */ + s.children.length = this.children.length; +}; + +/** + * Computes the implied properties for this panel for the specified instance + * <tt>s</tt> in the scene graph. Panels have two implied properties:<ul> + * + * <li>The <tt>canvas</tt> property references the DOM element, typically a DIV, + * that contains the SVG element that is used to display the visualization. This + * property may be specified as a string, referring to the unique ID of the + * element in the DOM. The string is converted to a reference to the DOM + * element. The width and height of the SVG element is inferred from this DOM + * element. If no canvas property is specified, a new SVG element is created and + * inserted into the document, using the panel dimensions; see + * {@link #createCanvas}. + * + * <li>The <tt>children</tt> array, while not a property per se, contains the + * scene graph for each child mark. This array is initialized to be empty, and + * is populated above in {@link #buildInstance}. + * + * </ul>The current implementation creates the SVG element, if necessary, during + * the build phase; in the future, it may be preferable to move this to the + * update phase, although then the canvas property would be undefined. In + * addition, DOM inspection is necessary to define the implied width and height + * properties that may be inferred from the DOM. + * + * @param s a node in the scene graph; the instance of the panel to build. + */ +pv.Panel.prototype.buildImplied = function(s) { + if (!s.children) s.children = []; + if (!s.parent) { + var c = s.canvas; + if (c) { + var d = (typeof c == "string") ? document.getElementById(c) : c; + + /* Clear the container if it's not already associated with this panel. */ + if (!d.$panel || d.$panel != this) { + d.$panel = this; + delete d.$canvas; + while (d.lastChild) { + d.lastChild.remove(); + } + } + + /* Construct the canvas if not already present. */ + if (!(c = d.$canvas)) { + d.$canvas = c = document.createElementNS(pv.ns.svg, "svg"); + d.appendChild(c); + } + + /** Returns the computed style for the given element and property. */ + let css = function(e, p) { + return parseFloat(self.getComputedStyle(e, null).getPropertyValue(p)); + }; + + /* If width and height weren't specified, inspect the container. */ + var w, h; + if (s.width == null) { + w = css(d, "width"); + s.width = w - s.left - s.right; + } else { + w = s.width + s.left + s.right; + } + if (s.height == null) { + h = css(d, "height"); + s.height = h - s.top - s.bottom; + } else { + h = s.height + s.top + s.bottom; + } + + c.setAttribute("width", w); + c.setAttribute("height", h); + s.canvas = c; + } else if (s.svg) { + s.canvas = s.svg.parentNode; + } else { + s.canvas = this.createCanvas( + s.width + s.left + s.right, + s.height + s.top + s.bottom); + } + } + pv.Bar.prototype.buildImplied.call(this, s); +}; + +/** + * Updates the display, propagating property values computed in the build phase + * to the SVG image. In addition to the SVG element that serves as the canvas, + * each panel instance has a corresponding <tt>g</tt> (container) element. The + * <tt>g</tt> element uses the <tt>transform</tt> attribute to offset the location + * of contained graphical elements. + */ +pv.Panel.prototype.update = function() { + var appends = []; + for (var i = 0; i < this.scene.length; i++) { + var s = this.scene[i]; + + /* Create the <svg:g> element, if necessary. */ + var v = s.svg; + if (!v) { + v = s.svg = document.createElementNS(pv.ns.svg, "g"); + appends.push(s); + } + + /* Update this instance, recursively including child marks. */ + this.updateInstance(s); + if (s.children) { // check visibility + for (var j = 0; j < this.children.length; j++) { + var c = this.children[j]; + c.scene = s.children[j]; + c.update(); + delete c.scene; + } + } + } + + /* + * WebKit appears has a bug where images are not rendered if the <g> element + * is appended before it contained any elements. Creating the child elements + * first and then appending them solves the problem and is likely more + * efficient. Also, it means we can reverse the order easily. + * + * TODO It would be nice to support arbitrary z-order here, at least within + * panel. Of course, the order of children may need to be updated not just on + * append. + */ + if (appends.length) { + if (appends[0].reverse) appends.reverse(); + for (var i = 0; i < appends.length; i++) { + var s = appends[i]; + (s.parent ? s.parent.svg : s.canvas).appendChild(s.svg); + } + } +}; + +/** + * Updates the display for the specified panel instance <tt>s</tt> in the scene + * graph. This implementation handles the fill and stroke style for the panel, + * as well as any necessary transform to offset the location of contained marks. + * + * <p>TODO As a performance optimization, it may also be possible to assign + * constant property values (or even the most common value for each property) as + * attributes on the <g> element so they can be inherited. + * + * @param s a node in the scene graph; the instance of the panel to update. + */ +pv.Panel.prototype.updateInstance = function(s) { + var v = s.svg; + + /* visible */ + if (!s.visible) { + if (v) v.setAttribute("display", "none"); + return; + } + v.removeAttribute("display"); + + /* fillStyle, strokeStyle */ + var r = v.$rect; + if (s.fillStyle || s.strokeStyle) { + if (!r) { + r = v.$rect = document.createElementNS(pv.ns.svg, "rect"); + v.insertBefore(r, v.firstElementChild); + } + + /* If width and height are exactly zero, the rect is not stroked! */ + r.setAttribute("width", Math.max(1E-10, s.width)); + r.setAttribute("height", Math.max(1E-10, s.height)); + + /* fill, stroke TODO gradient, patterns */ + var fill = pv.color(s.fillStyle); + r.setAttribute("fill", fill.color); + r.setAttribute("fill-opacity", fill.opacity); + var stroke = pv.color(s.strokeStyle); + r.setAttribute("stroke", stroke.color); + r.setAttribute("stroke-opacity", stroke.opacity); + r.setAttribute("stroke-width", s.lineWidth); + } else if (r) { + v.removeChild(r); + delete v.$rect; + r = null; + } + + /* cursor, title, event, etc. */ + if (r) { + try { + s.svg = r; + pv.Mark.prototype.updateInstance.call(this, s); + } finally { + s.svg = v; + } + } + + /* left, top */ + if (s.left || s.top) { + v.setAttribute("transform", "translate(" + s.left + "," + s.top +")"); + } else { + v.removeAttribute("transform"); + } +}; +/** + * Constructs a new rule with default properties. Rules are not typically + * constructed directly, but by adding to a panel or an existing mark via + * {@link pv.Mark#add}. + * + * @class Represents a horizontal or vertical rule. Rules are frequently used + * for axes and grid lines. For example, specifying only the bottom property + * draws horizontal rules, while specifying only the left draws vertical + * rules. Rules can also be used as thin bars. The visual style is controlled in + * the same manner as lines. + * + * <p>Rules are positioned exclusively using the four margins. The following + * combinations of properties are supported:<ul> + * + * <li>left (vertical) + * <li>right (vertical) + * <li>left, bottom, top (vertical) + * <li>right, bottom, top (vertical) + * <li>top (horizontal) + * <li>bottom (horizontal) + * <li>top, left, right (horizontal) + * <li>bottom, left, right (horizontal) + * + * </ul>TODO If rules supported width (for horizontal) and height (for vertical) + * properties, it might be easier to place them. Small rules can be used as tick + * marks; alternatively, a {@link Dot} with the "tick" shape can be used. + * + * <p>See also the <a href="../../api/Rule.html">Rule guide</a>. + * + * @see pv.Line + * @extends pv.Mark + */ +pv.Rule = function() { + pv.Mark.call(this); +}; +pv.Rule.prototype = pv.extend(pv.Mark); +pv.Rule.prototype.type = pv.Rule; + +/** + * Returns "rule". + * + * @returns {string} "rule". + */ +pv.Rule.toString = function() { return "rule"; }; + +/** + * The width of stroked lines, in pixels; used in conjunction with + * <tt>strokeStyle</tt> to stroke the rule. The default value is 1 pixel. + * + * @type number + * @name pv.Rule.prototype.lineWidth + */ +pv.Rule.prototype.defineProperty("lineWidth"); + +/** + * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to + * stroke the rule. The default value of this property is black. + * + * @type string + * @name pv.Rule.prototype.strokeStyle + * @see pv.color + */ +pv.Rule.prototype.defineProperty("strokeStyle"); + +/** + * Default properties for rules. By default, a single-pixel black line is + * stroked. + * + * @type pv.Rule + */ +pv.Rule.defaults = new pv.Rule().extend(pv.Mark.defaults) + .lineWidth(1) + .strokeStyle("black"); + +/** + * Constructs a new rule anchor with default properties. + * + * @class Represents an anchor for a rule mark. Rules support five different + * anchors:<ul> + * + * <li>top + * <li>left + * <li>center + * <li>bottom + * <li>right + * + * </ul>In addition to positioning properties (left, right, top bottom), the + * anchors support text rendering properties (text-align, text-baseline). Text is + * rendered to appear outside the rule. Note that this behavior is different + * from other mark anchors, which default to rendering text <i>inside</i> the + * mark. + * + * <p>For consistency with the other mark types, the anchor positions are + * defined in terms of their opposite edge. For example, the top anchor defines + * the bottom property, such that a bar added to the top anchor grows upward. + * + * @extends pv.Bar.Anchor + */ +pv.Rule.Anchor = function() { + pv.Bar.Anchor.call(this); +}; +pv.Rule.Anchor.prototype = pv.extend(pv.Bar.Anchor); +pv.Rule.Anchor.prototype.type = pv.Rule; + +/** + * The text-align property, for horizontal alignment outside the rule. + * + * @type string + * @name pv.Rule.Anchor.prototype.textAlign + */ /** @private */ +pv.Rule.Anchor.prototype.$textAlign = function(d) { + switch (this.get("name")) { + case "left": return "right"; + case "bottom": + case "top": + case "center": return "center"; + case "right": return "left"; + } + return null; +}; + +/** + * The text-baseline property, for vertical alignment outside the rule. + * + * @type string + * @name pv.Rule.Anchor.prototype.textBaseline + */ /** @private */ +pv.Rule.Anchor.prototype.$textBaseline = function(d) { + switch (this.get("name")) { + case "right": + case "left": + case "center": return "middle"; + case "top": return "bottom"; + case "bottom": return "top"; + } + return null; +}; + +/** + * Returns the pseudo-width of the rule in pixels; read-only. + * + * @returns {number} the pseudo-width, in pixels. + */ +pv.Rule.prototype.width = function() { + return this.scene[this.index].width; +}; + +/** + * Returns the pseudo-height of the rule in pixels; read-only. + * + * @returns {number} the pseudo-height, in pixels. + */ +pv.Rule.prototype.height = function() { + return this.scene[this.index].height; +}; + +/** + * Overrides the default behavior of {@link Mark#buildImplied} to determine the + * orientation (vertical or horizontal) of the rule. + * + * @param s a node in the scene graph; the instance of the rule to build. + */ +pv.Rule.prototype.buildImplied = function(s) { + s.width = s.height = 0; + + /* Determine horizontal or vertical orientation. */ + var l = s.left, r = s.right, t = s.top, b = s.bottom; + if (((l == null) && (r == null)) || ((r != null) && (l != null))) { + s.width = s.parent.width - (l = l || 0) - (r = r || 0); + } else { + s.height = s.parent.height - (t = t || 0) - (b = b || 0); + } + + s.left = l; + s.right = r; + s.top = t; + s.bottom = b; + + pv.Mark.prototype.buildImplied.call(this, s); +}; + +/** + * Updates the display for the specified rule instance <tt>s</tt> in the scene + * graph. This implementation handles the stroke style for the rule, as well as + * positional properties. + * + * @param s a node in the scene graph; the instance of the rule to update. + */ +pv.Rule.prototype.updateInstance = function(s) { + var v = s.svg; + + /* Create the svg:line element, if necessary. */ + if (s.visible && !v) { + v = s.svg = document.createElementNS(pv.ns.svg, "line"); + s.parent.svg.appendChild(v); + } + + /* visible, cursor, title, events, etc. */ + pv.Mark.prototype.updateInstance.call(this, s); + if (!s.visible) return; + + /* left, top */ + v.setAttribute("x1", s.left); + v.setAttribute("y1", s.top); + v.setAttribute("x2", s.left + s.width); + v.setAttribute("y2", s.top + s.height); + + /* stroke TODO gradient, patterns, dashes */ + var stroke = pv.color(s.strokeStyle); + v.setAttribute("stroke", stroke.color); + v.setAttribute("stroke-opacity", stroke.opacity); + v.setAttribute("stroke-width", s.lineWidth); +}; +/** + * Constructs a new wedge with default properties. Wedges are not typically + * constructed directly, but by adding to a panel or an existing mark via + * {@link pv.Mark#add}. + * + * @class Represents a wedge, or pie slice. Specified in terms of start and end + * angle, inner and outer radius, wedges can be used to construct donut charts + * and polar bar charts as well. If the {@link #angle} property is used, the end + * angle is implied by adding this value to start angle. By default, the start + * angle is the previously-generated wedge's end angle. This design allows + * explicit control over the wedge placement if desired, while offering + * convenient defaults for the construction of radial graphs. + * + * <p>The center point of the circle is positioned using the standard box model. + * The wedge can be stroked and filled, similar to {link Bar}. + * + * <p>See also the <a href="../../api/Wedge.html">Wedge guide</a>. + * + * @extends pv.Mark + */ +pv.Wedge = function() { + pv.Mark.call(this); +}; +pv.Wedge.prototype = pv.extend(pv.Mark); +pv.Wedge.prototype.type = pv.Wedge; + +/** + * Returns "wedge". + * + * @returns {string} "wedge". + */ +pv.Wedge.toString = function() { return "wedge"; }; + +/** + * The start angle of the wedge, in radians. The start angle is measured + * clockwise from the 3 o'clock position. The default value of this property is + * the end angle of the previous instance (the {@link Mark#sibling}), or -PI / 2 + * for the first wedge; for pie and donut charts, typically only the + * {@link #angle} property needs to be specified. + * + * @type number + * @name pv.Wedge.prototype.startAngle + */ +pv.Wedge.prototype.defineProperty("startAngle"); + +/** + * The end angle of the wedge, in radians. If not specified, the end angle is + * implied as the start angle plus the {@link #angle}. + * + * @type number + * @name pv.Wedge.prototype.endAngle + */ +pv.Wedge.prototype.defineProperty("endAngle"); + +/** + * The angular span of the wedge, in radians. This property is used if end angle + * is not specified. + * + * @type number + * @name pv.Wedge.prototype.angle + */ +pv.Wedge.prototype.defineProperty("angle"); + +/** + * The inner radius of the wedge, in pixels. The default value of this property + * is zero; a positive value will produce a donut slice rather than a pie slice. + * The inner radius can vary per-wedge. + * + * @type number + * @name pv.Wedge.prototype.innerRadius + */ +pv.Wedge.prototype.defineProperty("innerRadius"); + +/** + * The outer radius of the wedge, in pixels. This property is required. For + * pies, only this radius is required; for donuts, the inner radius must be + * specified as well. The outer radius can vary per-wedge. + * + * @type number + * @name pv.Wedge.prototype.outerRadius + */ +pv.Wedge.prototype.defineProperty("outerRadius"); + +/** + * The width of stroked lines, in pixels; used in conjunction with + * <tt>strokeStyle</tt> to stroke the wedge's border. + * + * @type number + * @name pv.Wedge.prototype.lineWidth + */ +pv.Wedge.prototype.defineProperty("lineWidth"); + +/** + * The style of stroked lines; used in conjunction with <tt>lineWidth</tt> to + * stroke the wedge's border. The default value of this property is null, + * meaning wedges are not stroked by default. + * + * @type string + * @name pv.Wedge.prototype.strokeStyle + * @see pv.color + */ +pv.Wedge.prototype.defineProperty("strokeStyle"); + +/** + * The wedge fill style; if non-null, the interior of the wedge is filled with + * the specified color. The default value of this property is a categorical + * color. + * + * @type string + * @name pv.Wedge.prototype.fillStyle + * @see pv.color + */ +pv.Wedge.prototype.defineProperty("fillStyle"); + +/** + * Default properties for wedges. By default, there is no stroke and the fill + * style is a categorical color. + * + * @type pv.Wedge + */ +pv.Wedge.defaults = new pv.Wedge().extend(pv.Mark.defaults) + .startAngle(function() { + var s = this.sibling(); + return s ? s.endAngle : -Math.PI / 2; + }) + .innerRadius(0) + .lineWidth(1.5) + .strokeStyle(null) + .fillStyle(pv.Colors.category20.unique); + +/** + * Returns the mid-radius of the wedge, which is defined as half-way between the + * inner and outer radii. + * + * @see #innerRadius + * @see #outerRadius + * @returns {number} the mid-radius, in pixels. + */ +pv.Wedge.prototype.midRadius = function() { + return (this.innerRadius() + this.outerRadius()) / 2; +}; + +/** + * Returns the mid-angle of the wedge, which is defined as half-way between the + * start and end angles. + * + * @see #startAngle + * @see #endAngle + * @returns {number} the mid-angle, in radians. + */ +pv.Wedge.prototype.midAngle = function() { + return (this.startAngle() + this.endAngle()) / 2; +}; + +/** + * Constructs a new wedge anchor with default properties. + * + * @class Represents an anchor for a wedge mark. Wedges support five different + * anchors:<ul> + * + * <li>outer + * <li>inner + * <li>center + * <li>start + * <li>end + * + * </ul>In addition to positioning properties (left, right, top bottom), the + * anchors support text rendering properties (text-align, text-baseline, + * textAngle). Text is rendered to appear inside the wedge. + * + * @extends pv.Mark.Anchor + */ +pv.Wedge.Anchor = function() { + pv.Mark.Anchor.call(this); +}; +pv.Wedge.Anchor.prototype = pv.extend(pv.Mark.Anchor); +pv.Wedge.Anchor.prototype.type = pv.Wedge; + +/** + * The left property; non-null. + * + * @type number + * @name pv.Wedge.Anchor.prototype.left + */ /** @private */ +pv.Wedge.Anchor.prototype.$left = function() { + var w = this.anchorTarget(); + switch (this.get("name")) { + case "outer": return w.left() + w.outerRadius() * Math.cos(w.midAngle()); + case "inner": return w.left() + w.innerRadius() * Math.cos(w.midAngle()); + case "start": return w.left() + w.midRadius() * Math.cos(w.startAngle()); + case "center": return w.left() + w.midRadius() * Math.cos(w.midAngle()); + case "end": return w.left() + w.midRadius() * Math.cos(w.endAngle()); + } + return null; +}; + +/** + * The right property; non-null. + * + * @type number + * @name pv.Wedge.Anchor.prototype.right + */ /** @private */ +pv.Wedge.Anchor.prototype.$right = function() { + var w = this.anchorTarget(); + switch (this.get("name")) { + case "outer": return w.right() + w.outerRadius() * Math.cos(w.midAngle()); + case "inner": return w.right() + w.innerRadius() * Math.cos(w.midAngle()); + case "start": return w.right() + w.midRadius() * Math.cos(w.startAngle()); + case "center": return w.right() + w.midRadius() * Math.cos(w.midAngle()); + case "end": return w.right() + w.midRadius() * Math.cos(w.endAngle()); + } + return null; +}; + +/** + * The top property; non-null. + * + * @type number + * @name pv.Wedge.Anchor.prototype.top + */ /** @private */ +pv.Wedge.Anchor.prototype.$top = function() { + var w = this.anchorTarget(); + switch (this.get("name")) { + case "outer": return w.top() + w.outerRadius() * Math.sin(w.midAngle()); + case "inner": return w.top() + w.innerRadius() * Math.sin(w.midAngle()); + case "start": return w.top() + w.midRadius() * Math.sin(w.startAngle()); + case "center": return w.top() + w.midRadius() * Math.sin(w.midAngle()); + case "end": return w.top() + w.midRadius() * Math.sin(w.endAngle()); + } + return null; +}; + +/** + * The bottom property; non-null. + * + * @type number + * @name pv.Wedge.Anchor.prototype.bottom + */ /** @private */ +pv.Wedge.Anchor.prototype.$bottom = function() { + var w = this.anchorTarget(); + switch (this.get("name")) { + case "outer": return w.bottom() + w.outerRadius() * Math.sin(w.midAngle()); + case "inner": return w.bottom() + w.innerRadius() * Math.sin(w.midAngle()); + case "start": return w.bottom() + w.midRadius() * Math.sin(w.startAngle()); + case "center": return w.bottom() + w.midRadius() * Math.sin(w.midAngle()); + case "end": return w.bottom() + w.midRadius() * Math.sin(w.endAngle()); + } + return null; +}; + +/** + * The text-align property, for horizontal alignment inside the wedge. + * + * @type string + * @name pv.Wedge.Anchor.prototype.textAlign + */ /** @private */ +pv.Wedge.Anchor.prototype.$textAlign = function() { + var w = this.anchorTarget(); + switch (this.get("name")) { + case "outer": return pv.Wedge.upright(w.midAngle()) ? "right" : "left"; + case "inner": return pv.Wedge.upright(w.midAngle()) ? "left" : "right"; + default: return "center"; + } +}; + +/** + * The text-baseline property, for vertical alignment inside the wedge. + * + * @type string + * @name pv.Wedge.Anchor.prototype.textBaseline + */ /** @private */ +pv.Wedge.Anchor.prototype.$textBaseline = function() { + var w = this.anchorTarget(); + switch (this.get("name")) { + case "start": return pv.Wedge.upright(w.startAngle()) ? "top" : "bottom"; + case "end": return pv.Wedge.upright(w.endAngle()) ? "bottom" : "top"; + default: return "middle"; + } +}; + +/** + * The text-angle property, for text rotation inside the wedge. + * + * @type number + * @name pv.Wedge.Anchor.prototype.textAngle + */ /** @private */ +pv.Wedge.Anchor.prototype.$textAngle = function() { + var w = this.anchorTarget(); + var a = 0; + switch (this.get("name")) { + case "center": + case "inner": + case "outer": a = w.midAngle(); break; + case "start": a = w.startAngle(); break; + case "end": a = w.endAngle(); break; + } + return pv.Wedge.upright(a) ? a : (a + Math.PI); +}; + +/** + * Returns true if the specified angle is considered "upright", as in, text + * rendered at that angle would appear upright. If the angle is not upright, + * text is rotated 180 degrees to be upright, and the text alignment properties + * are correspondingly changed. + * + * @param {number} angle an angle, in radius. + * @returns {boolean} true if the specified angle is upright. + */ +pv.Wedge.upright = function(angle) { + angle = angle % (2 * Math.PI); + angle = (angle < 0) ? (2 * Math.PI + angle) : angle; + return (angle < Math.PI / 2) || (angle > 3 * Math.PI / 2); +}; + +/** + * Overrides the default behavior of {@link Mark#buildImplied} such that the end + * angle is computed from the start angle and angle (angular span) if not + * specified. + * + * @param s a node in the scene graph; the instance of the wedge to build. + */ +pv.Wedge.prototype.buildImplied = function(s) { + pv.Mark.prototype.buildImplied.call(this, s); + if (s.endAngle == null) { + s.endAngle = s.startAngle + s.angle; + } +}; + +/** + * Updates the display for the specified wedge instance <tt>s</tt> in the scene + * graph. This implementation handles the fill and stroke style for the wedge, + * as well as positional properties. + * + * @param s a node in the scene graph; the instance of the bar to update. + */ +pv.Wedge.prototype.updateInstance = function(s) { + var v = s.svg; + + /* Create the <svg:path> element, if necessary. */ + if (s.visible && !v) { + v = s.svg = document.createElementNS(pv.ns.svg, "path"); + v.setAttribute("fill-rule", "evenodd"); + s.parent.svg.appendChild(v); + } + + /* visible, cursor, title, events, etc. */ + pv.Mark.prototype.updateInstance.call(this, s); + if (!s.visible) return; + + /* left, top */ + v.setAttribute("transform", "translate(" + s.left + "," + s.top +")"); + + /* + * TODO If the angle or endAngle is updated by an event handler, the implied + * properties won't recompute correctly, so this will lead to potentially + * buggy redraw. How to re-evaluate implied properties on update? + */ + + /* innerRadius, outerRadius, startAngle, endAngle */ + var r1 = s.innerRadius, r2 = s.outerRadius; + if (s.angle >= 2 * Math.PI) { + if (r1) { + v.setAttribute("d", "M0," + r2 + + "A" + r2 + "," + r2 + " 0 1,1 0," + (-r2) + + "A" + r2 + "," + r2 + " 0 1,1 0," + r2 + + "M0," + r1 + + "A" + r1 + "," + r1 + " 0 1,1 0," + (-r1) + + "A" + r1 + "," + r1 + " 0 1,1 0," + r1 + + "Z"); + } else { + v.setAttribute("d", "M0," + r2 + + "A" + r2 + "," + r2 + " 0 1,1 0," + (-r2) + + "A" + r2 + "," + r2 + " 0 1,1 0," + r2 + + "Z"); + } + } else { + var c1 = Math.cos(s.startAngle), c2 = Math.cos(s.endAngle), + s1 = Math.sin(s.startAngle), s2 = Math.sin(s.endAngle); + if (r1) { + v.setAttribute("d", "M" + r2 * c1 + "," + r2 * s1 + + "A" + r2 + "," + r2 + " 0 " + + ((s.angle < Math.PI) ? "0" : "1") + ",1 " + + r2 * c2 + "," + r2 * s2 + + "L" + r1 * c2 + "," + r1 * s2 + + "A" + r1 + "," + r1 + " 0 " + + ((s.angle < Math.PI) ? "0" : "1") + ",0 " + + r1 * c1 + "," + r1 * s1 + "Z"); + } else { + v.setAttribute("d", "M" + r2 * c1 + "," + r2 * s1 + + "A" + r2 + "," + r2 + " 0 " + + ((s.angle < Math.PI) ? "0" : "1") + ",1 " + + r2 * c2 + "," + r2 * s2 + "L0,0Z"); + } + } + + /* fill, stroke TODO gradient, patterns */ + var fill = pv.color(s.fillStyle); + v.setAttribute("fill", fill.color); + v.setAttribute("fill-opacity", fill.opacity); + var stroke = pv.color(s.strokeStyle); + v.setAttribute("stroke", stroke.color); + v.setAttribute("stroke-opacity", stroke.opacity); + v.setAttribute("stroke-width", s.lineWidth); +}; +pv.Scales = {}; +pv.Scales.epsilon = 1e-30; +pv.Scales.defaultBase = 10; + +/** + * Scale is a base class for scale objects. Scale objects are used to scale the + * data to a given range. The Scale object initially scales the value to the + * interval [0, 1]. The values are then mapped to a given range by the range() + * method. + */ +pv.Scales.Scale = function() { + // Pixel coordinate minimum + this._rMin = 0; + // Pixel coordinate maximum + this._rMax = 100; + // Round value? + this._round = true; +}; + +/** + * Sets the range to map the data to. + */ +pv.Scales.Scale.prototype.range = function(a, b) { + if (a == undefined) { + // use default values + // TODO: [0, 100] may not be the best default values. + // Find better default values, which may be different for each scale type. + } else if (b == undefined) { + this._rMin = 0; + this._rMax = a; + } else { + this._rMin = a; + this._rMax = b; + } + + return this; +}; + +// Accessor method for range min +pv.Scales.Scale.prototype.rangeMin = function(x) { + if (x == undefined) { + return this._rMin; + } else { + this._rMin = x; + return this; + } +}; + +// Accessor method for range max +pv.Scales.Scale.prototype.rangeMax = function(x) { + if (x == undefined) { + return this._rMax; + } else { + this._rMax = x; + return this; + } +}; + +// Accessor method for round +pv.Scales.Scale.prototype.round = function(x) { + if (x == undefined) { + return this._round; + } else { + this._round = x; + return this; + } +}; + +//Scales the input to the set range +pv.Scales.Scale.prototype.scale = function(x) { + var v = this._rMin + (this._rMax-this._rMin) * this.normalize(x); + return this._round ? Math.round(v) : v; +}; + +// Returns the inverse scaled value. +pv.Scales.Scale.prototype.invert = function(y) { + var n = (y - this._rMin) / (this._rMax - this._rMin); + return this.unnormalize(n); +}; +pv.Scale = {}; + +pv.Scale.linear = function() { + var min, max, nice = false, s, f = pv.identity; + + /* Property function. */ + function scale() { + if (s == undefined) { + if (min == undefined) min = pv.min(this.$$data, f); + if (max == undefined) max = pv.max(this.$$data, f); + if (nice) { // TODO Only "nice" bounds set automatically. + var step = Math.pow(10, Math.round(Math.log(max - min) / Math.log(10)) - 1); + min = Math.floor(min / step) * step; + max = Math.ceil(max / step) * step; + } + s = range.call(this) / (max - min); + } + return (f.apply(this, arguments) - min) * s; + } + + function range() { + switch (property) { + case "height": + case "top": + case "bottom": return this.parent.height(); + case "width": + case "left": + case "right": return this.parent.width(); + default: return 1; + } + } + + scale.by = function(v) { f = v; return this; }; + scale.min = function(v) { min = v; return this; }; + scale.max = function(v) { max = v; return this; }; + + scale.nice = function(v) { + nice = (arguments.length == 0) ? true : v; + return this; + }; + + scale.range = function() { + if (arguments.length == 1) { + o = 0; + s = arguments[0]; + } else { + o = arguments[0]; + s = arguments[1] - arguments[0]; + } + return this; + }; + + return scale; +}; +/** + * QuantitativeScale is a base class for representing quantitative numerical data + * scales. + */ +pv.Scales.QuantitativeScale = function(min, max, base) { + pv.Scales.Scale.call(this); + + this._min = min; + this._max = max; + this._base = base==undefined ? pv.Scales.defaultBase : base; +}; + +pv.Scales.QuantitativeScale.prototype = pv.extend(pv.Scales.Scale); + +// Accessor method for min +pv.Scales.QuantitativeScale.prototype.min = function(x) { + if (x == undefined) { + return this._min; + } else { + this._min = x; + return this; + } +}; + +// Accessor method for max +pv.Scales.QuantitativeScale.prototype.max = function(x) { + if (x == undefined) { + return this._max; + } else { + this._max = x; + return this; + } +}; + +// Accessor method for base +pv.Scales.QuantitativeScale.prototype.base = function(x) { + if (x == undefined) { + return this._base; + } else { + this._base = x; + return this; + } +}; + +// Checks if the mapped interval contains x +pv.Scales.QuantitativeScale.prototype.contains = function(x) { + return (x >= this._min && x <= this._max); +}; + +// Returns the step for the scale +pv.Scales.QuantitativeScale.prototype.step = function(min, max, base) { + if (!base) base = pv.Scales.defaultBase; + var exp = Math.round(Math.log(max-min)/Math.log(base)) - 1; + + return Math.pow(base, exp); +}; +pv.Scales.dateTime = function(min, max) { + return new pv.Scales.DateTimeScale(min, max); +} + +/** + * DateTimeScale DateTimeScale scales time data. + */ +pv.Scales.DateTimeScale = function(min, max) { + pv.Scales.Scale.call(this); + + this._min = min; + this._max = max; +}; + +pv.Scales.DateTimeScale.prototype = pv.extend(pv.Scales.Scale); + +// Accessor method for min +pv.Scales.DateTimeScale.prototype.min = function(x) { + if (x == undefined) { + return this._min; + } else { + this._min = x; + return this; + } +}; + +// Accessor method for max +pv.Scales.DateTimeScale.prototype.max = function(x) { + if (x == undefined) { + return this._max; + } else { + this._max = x; + return this; + } +}; + +// Normalizes DateTimeScale value +pv.Scales.DateTimeScale.prototype.normalize = function(x) { + var eps = pv.Scales.epsilon; + var range = this._max - this._min; + + return (range < eps && range > -eps) ? 0 : (x - this._min) / range; +}; + +// Un-normalizes the value +pv.Scales.DateTimeScale.prototype.unnormalize = function(n) { + return n * (this._max - this._min) + this._min; +}; + +// Checks if the mapped interval contains x +pv.Scales.DateTimeScale.prototype.contains = function(x) { + var t = x.valueOf(); + return (t >= this._min.valueOf() && t <= this._max.valueOf()); +}; + +// Sets min/max values to "nice" values +pv.Scales.DateTimeScale.prototype.nice = function() { + var span = this.span(this._min, this._max); + this._min = this.round(this._min, span, false); + this._max = this.round(this._max, span, true); +}; + +/** + * Calculate a list of rule values covering the time range spaced at a + * configurable span. + * + * @param [forceSpan] If you want to force rule-generation from a span other + * than the default calculated by span, pass the value here. + * @param [beNice] Round the min and max values based on the span in use. If + * you are passing a value for forceSpan, you may also want to pass true + * for this argument. + * + * @return a list of rule values + */ +pv.Scales.DateTimeScale.prototype.ruleValues = function(forceSpan, beNice) { + var min = this._min.valueOf(), max = this._max.valueOf(); + var span = (forceSpan == null) ? this.span(this._min, this._max) : forceSpan; + // We need to boost the step in order to avoid an infinite loop in the first + // case where we round. DST can cause a case where just one step is not + // enough to push round far enough. + var step = Math.floor(this.step(this._min, this._max, span) * 1.5); + var list = []; + + var d = this._min; + if (beNice) { + d = this.round(d, span, false); + max = this.round(this._max, span, true).valueOf(); + } + if (span < pv.Scales.DateTimeScale.Span.MONTHS) { + while (d.valueOf() <= max) { + list.push(d); + // we need to round to compensate for daylight savings time... + d = this.round(new Date(d.valueOf()+step), span, false); + } + } else if (span == pv.Scales.DateTimeScale.Span.MONTHS) { + // TODO: Handle quarters + step = 1; + while (d.valueOf() <= max) { + list.push(d); + d = new Date(d); + d.setMonth(d.getMonth() + step); + } + } else { // Span.YEARS + step = 1; + while (d.valueOf() <= max) { + list.push(d); + d = new Date(d); + d.setFullYear(d.getFullYear() + step); + } + } + + return list; +}; + +// Time Span Constants +pv.Scales.DateTimeScale.Span = {}; +pv.Scales.DateTimeScale.Span.YEARS = 0; +pv.Scales.DateTimeScale.Span.MONTHS = -1; +pv.Scales.DateTimeScale.Span.DAYS = -2; +pv.Scales.DateTimeScale.Span.HOURS = -3; +pv.Scales.DateTimeScale.Span.MINUTES = -4; +pv.Scales.DateTimeScale.Span.SECONDS = -5; +pv.Scales.DateTimeScale.Span.MILLISECONDS = -6; +pv.Scales.DateTimeScale.Span.WEEKS = -10; +pv.Scales.DateTimeScale.Span.QUARTERS = -11; + +// Rounds the date +pv.Scales.DateTimeScale.prototype.round = function(t, span, roundUp) { + var Span = pv.Scales.DateTimeScale.Span; + var d = t, bias = roundUp ? 1 : 0; + + if (span >= Span.YEARS) { + d = new Date(t.getFullYear() + bias, 0); + } else if (span == Span.MONTHS) { + d = new Date(t.getFullYear(), t.getMonth() + bias); + } else if (span == Span.DAYS) { + d = new Date(t.getFullYear(), t.getMonth(), t.getDate() + bias); + } else if (span == Span.HOURS) { + d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours() + bias); + } else if (span == Span.MINUTES) { + d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours(), t.getMinutes() + bias); + } else if (span == Span.SECONDS) { + d = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours(), t.getMinutes(), t.getSeconds() + bias); + } else if (span == Span.MILLISECONDS) { + d = new Date(d.time + (roundUp ? 1 : -1)); + } else if (span == Span.WEEKS) { + bias = roundUp ? 7 - d.getDay() : -d.getDay(); + d = new Date(t.getFullYear(), t.getMonth(), t.getDate() + bias); + } + return d; +}; + +// Returns the span of the given min/max values +pv.Scales.DateTimeScale.prototype.span = function(min, max) { + var MS_MIN = 60*1000, MS_HOUR = 60*MS_MIN, MS_DAY = 24*MS_HOUR, MS_WEEK = 7*MS_DAY; + var Span = pv.Scales.DateTimeScale.Span; + var span = max.valueOf() - min.valueOf(); + var days = span / MS_DAY; + + // TODO: handle Weeks/Quarters + if (days >= 365*2) return (1 + max.getFullYear()-min.getFullYear()); + else if (days >= 60) return Span.MONTHS; + else if (span/MS_WEEK > 1) return Span.WEEKS; + else if (span/MS_DAY > 1) return Span.DAYS; + else if (span/MS_HOUR > 1) return Span.HOURS; + else if (span/MS_MIN > 1) return Span.MINUTES; + else if (span/1000.0 > 1) return Span.SECONDS; + else return Span.MILLISECONDS; +} + +// Returns the step for the scale +pv.Scales.DateTimeScale.prototype.step = function(min, max, span) { + var Span = pv.Scales.DateTimeScale.Span; + + if (span > Span.YEARS) { + var exp = Math.round(Math.log(Math.max(1,span-1)/Math.log(10))) - 1; + return Math.pow(10, exp); + } else if (span == Span.MONTHS) { + return 0; + } else if (span == Span.WEEKS) { + return 7*24*60*60*1000; + } else if (span == Span.DAYS) { + return 24*60*60*1000; + } else if (span == Span.HOURS) { + return 60*60*1000; + } else if (span == Span.MINUTES) { + return 60*1000; + } else if (span == Span.SECONDS) { + return 1000; + } else { + return 1; + } +}; +pv.Scales.linear = function(min, max, base) { + return new pv.Scales.LinearScale(min, max, base); +}; + +pv.Scales.linear.fromData = function(data, f, base) { + return new pv.Scales.LinearScale(pv.min(data, f), pv.max(data, f), base); +} + +/** + * LinearScale is a QuantativeScale that spaces values linearly along the scale + * range. This is the default scale for numeric types. + */ +pv.Scales.LinearScale = function(min, max, base) { + pv.Scales.QuantitativeScale.call(this, min, max, base); +}; + +pv.Scales.LinearScale.prototype = pv.extend(pv.Scales.QuantitativeScale); + +// Normalizes the value +pv.Scales.LinearScale.prototype.normalize = function(x) { + var eps = pv.Scales.epsilon; + var range = this._max - this._min; + + return (range < eps && range > -eps) ? 0 : (x - this._min) / range; +}; + +// Un-normalizes the value +pv.Scales.LinearScale.prototype.unnormalize = function(n) { + return n * (this._max - this._min) + this._min; +}; + +// Sets min/max values to "nice numbers" +pv.Scales.LinearScale.prototype.nice = function() { + var step = this.step(this._min, this._max, this._base); + + this._min = Math.floor(this._min / step) * step; + this._max = Math.ceil(this._max / step) * step; + + return this; +}; + +// Returns a list of rule values +pv.Scales.LinearScale.prototype.ruleValues = function() { + var step = this.step(this._min, this._max, this._base); + + var start = Math.floor(this._min / step) * step; + var end = Math.ceil(this._max / step) * step; + + var list = pv.range(start, end+step, step); + + // Remove precision problems + // TODO move to tick rendering, not scales + if (step < 1) { + var exp = Math.round(Math.log(step)/Math.log(this._base)); + + for (var i = 0; i < list.length; i++) { + list[i] = list[i].toFixed(-exp); + } + } + + // check end points + if (list[0] < this._min) list.splice(0, 1); + if (list[list.length-1] > this._max) list.splice(list.length-1, 1); + + return list; +}; +pv.Scales.log = function(min, max, base) { + return new pv.Scales.LogScale(min, max, base); +}; + +pv.Scales.log.fromData = function(data, f, base) { + return new pv.Scales.LogScale(pv.min(data, f), pv.max(data, f), base); +} + +/* + * LogScale is a QuantativeScale that performs a log transformation of the + * data. The base of the logarithm is determined by the base property. + */ +pv.Scales.LogScale = function(min, max, base) { + pv.Scales.QuantitativeScale.call(this, min, max, base); + + this.update(); +}; + +// Zero-symmetric log function +pv.Scales.LogScale.log = function(x, b) { + return x==0 ? 0 : x>0 ? Math.log(x)/Math.log(b) : -Math.log(-x)/Math.log(b); +}; + +// Adjusted zero-symmetric log function +pv.Scales.LogScale.zlog = function(x, b) { + var s = (x < 0) ? -1 : 1; + x = s*x; + if (x < b) x += (b-x)/b; + return s * Math.log(x) / Math.log(b); +}; + +pv.Scales.LogScale.prototype = pv.extend(pv.Scales.QuantitativeScale); + +// Accessor method for min +pv.Scales.LogScale.prototype.min = function(x) { + var value = pv.Scales.QuantitativeScale.prototype.min.call(this, x); + + if (x != undefined) this.update(); + return value; +}; + +// Accessor method for max +pv.Scales.LogScale.prototype.max = function(x) { + var value = pv.Scales.QuantitativeScale.prototype.max.call(this, x); + + if (x != undefined) this.update(); + return value; +}; + +// Accessor method for base +pv.Scales.LogScale.prototype.base = function(x) { + var value = pv.Scales.QuantitativeScale.prototype.base.call(this, x); + + if (x != undefined) this.update(); + return value; +}; + +// Normalizes the value +pv.Scales.LogScale.prototype.normalize = function(x) { + var eps = pv.Scales.epsilon; + var range = this._lmax - this._lmin; + + return (range < eps && range > -eps) ? 0 : (this._log(x, this._base) - this._lmin) / range; +}; + +// Un-normalizes the value +pv.Scales.LogScale.prototype.unnormalize = function(n) { + // TODO: handle case where _log = zlog + return Math.pow(this._base, n * (this._lmax - this._lmin) + this._lmin); +}; + +/** + * Sets min/max values to "nice numbers" For LogScale, we compute "nice" min/max + * values for the log scale(_lmin, _lmax) first, then calculate the data min/max + * values from the log min/max values. + */ +pv.Scales.LogScale.prototype.nice = function() { + var step = 1; //this.step(this._lmin, this._lmax); + + this._lmin = Math.floor(this._lmin / step) * step; + this._lmax = Math.ceil(this._lmax / step) * step; + + // TODO: handle case where _log = zlog + this._min = Math.pow(this._base, this._lmin); + this._max = Math.pow(this._base, this._lmax); + + return this; +}; + +// Returns a list of rule values +pv.Scales.LogScale.prototype.ruleValues = function() { + var step = this.step(this._lmin, this._lmax); + if (step < 1) step = 1; // bound to 1 + + var start = Math.floor(this._lmin); + var end = Math.ceil(this._lmax); + + var list =[]; + var i, j, b; + for (i = start; i < end; i++) { // for each step + // add each rule value + // TODO: handle case where _log = zlog + b = Math.pow(this._base, i); + for (j = 1; j < this._base; j++) { + if (i >= 0) list.push(b*j); + else list.push((b*j).toFixed(-i)); + } + } + list.push(b*this._base); // add max value + + // check end points + if (list[0] < this._min) list.splice(0, 1); + if (list[list.length-1] > this._max) list.splice(list.length-1, 1); + + return list; +}; + +// Update log scale values +pv.Scales.LogScale.prototype.update = function() { + this._log = (this._min < 0 && this._max > 0) ? pv.Scales.LogScale.zlog : pv.Scales.LogScale.log; + this._lmin = this._log(this._min, this._base); + this._lmax = this._log(this._max, this._base); +}; +/** + * Returns a {@link pv.Nest} operator for the specified array. This is a + * convenience factory method, equivalent to <tt>new pv.Nest(array)</tt>. + * + * @see pv.Nest + * @param {array} array an array of elements to nest. + * @returns {pv.Nest} a nest operator for the specified array. + */ +pv.nest = function(array) { + return new pv.Nest(array); +}; + +/** + * Constructs a nest operator for the specified array. + * + * @class Represents a {@link Nest} operator for the specified array. Nesting + * allows elements in an array to be grouped into a hierarchical tree + * structure. The levels in the tree are specified by <i>key</i> functions. The + * leaf nodes of the tree can be sorted by value, while the internal nodes can + * be sorted by key. Finally, the tree can be returned either has a + * multidimensional array via {@link #entries}, or as a hierarchical map via + * {@link #map}. The {@link #rollup} routine similarly returns a map, collapsing + * the elements in each leaf node using a summary function. + * + * <p>For example, consider the following tabular data structure of Barley + * yields, from various sites in Minnesota during 1931-2: + * + * <pre>{ yield: 27.00, variety: "Manchuria", year: 1931, site: "University Farm" }, + * { yield: 48.87, variety: "Manchuria", year: 1931, site: "Waseca" }, + * { yield: 27.43, variety: "Manchuria", year: 1931, site: "Morris" }, ...</pre> + * + * To facilitate visualization, it may be useful to nest the elements first by + * year, and then by variety, as follows: + * + * <pre>var nest = pv.nest(yields) + * .key(function(d) d.year) + * .key(function(d) d.variety) + * .entries();</pre> + * + * This returns a nested array. Each element of the outer array is a key-values + * pair, listing the values for each distinct key: + * + * <pre>{ key: 1931, values: [ + * { key: "Manchuria", values: [ + * { yield: 27.00, variety: "Manchuria", year: 1931, site: "University Farm" }, + * { yield: 48.87, variety: "Manchuria", year: 1931, site: "Waseca" }, + * { yield: 27.43, variety: "Manchuria", year: 1931, site: "Morris" }, + * ... + * ]}, + * { key: "Glabron", values: [ + * { yield: 43.07, variety: "Glabron", year: 1931, site: "University Farm" }, + * { yield: 55.20, variety: "Glabron", year: 1931, site: "Waseca" }, + * ... + * ]}, + * ]}, + * { key: 1932, values: ... }</pre> + * + * Further details, including sorting and rollup, is provided below on the + * corresponding methods. + * + * @param {array} array an array of elements to nest. + */ +pv.Nest = function(array) { + this.array = array; + this.keys = []; +}; + +/** + * Nests using the specified key function. Multiple keys may be added to the + * nest; the array elements will be nested in the order keys are specified. + * + * @param {function} key a key function; must return a string or suitable map + * key. + * @return {pv.Nest} this. + */ +pv.Nest.prototype.key = function(key) { + this.keys.push(key); + return this; +}; + +/** + * Sorts the previously-added keys. The natural sort order is used by default + * (see {@link pv.naturalOrder}); if an alternative order is desired, + * <tt>order</tt> should be a comparator function. If this method is not called + * (i.e., keys are <i>unsorted</i>), keys will appear in the order they appear + * in the underlying elements array. For example, + * + * <pre>pv.nest(yields) + * .key(function(d) d.year) + * .key(function(d) d.variety) + * .sortKeys() + * .entries()</pre> + * + * groups yield data by year, then variety, and sorts the variety groups + * lexicographically (since the variety attribute is a string). + * + * <p>Key sort order is only used in conjunction with {@link #entries}, which + * returns an array of key-values pairs. If the nest is used to construct a + * {@link #map} instead, keys are unsorted. + * + * @param {function} [order] an optional comparator function. + * @returns {pv.Nest} this. + */ +pv.Nest.prototype.sortKeys = function(order) { + this.keys[this.keys.length - 1].order = order || pv.naturalOrder; + return this; +}; + +/** + * Sorts the leaf values. The natural sort order is used by default (see + * {@link pv.naturalOrder}); if an alternative order is desired, <tt>order</tt> + * should be a comparator function. If this method is not called (i.e., values + * are <i>unsorted</i>), values will appear in the order they appear in the + * underlying elements array. For example, + * + * <pre>pv.nest(yields) + * .key(function(d) d.year) + * .key(function(d) d.variety) + * .sortValues(function(a, b) a.yield - b.yield) + * .entries()</pre> + * + * groups yield data by year, then variety, and sorts the values for each + * variety group by yield. + * + * <p>Value sort order, unlike keys, applies to both {@link #entries} and + * {@link #map}. It has no effect on {@link #rollup}. + * + * @param {function} [order] an optional comparator function. + * @return {pv.Nest} this. + */ +pv.Nest.prototype.sortValues = function(order) { + this.order = order || pv.naturalOrder; + return this; +}; + +/** + * Returns a hierarchical map of values. Each key adds one level to the + * hierarchy. With only a single key, the returned map will have a key for each + * distinct value of the key function; the correspond value with be an array of + * elements with that key value. If a second key is added, this will be a nested + * map. For example: + * + * <pre>pv.nest(yields) + * .key(function(d) d.variety) + * .key(function(d) d.site) + * .map()</pre> + * + * returns a map <tt>m</tt> such that <tt>m[variety][site]</tt> is an array, a subset of + * <tt>yields</tt>, with each element having the given variety and site. + * + * @returns a hierarchical map of values. + */ +pv.Nest.prototype.map = function() { + var map = {}, values = []; + + /* Build the map. */ + for (var i, j = 0; j < this.array.length; j++) { + var x = this.array[j]; + var m = map; + for (i = 0; i < this.keys.length - 1; i++) { + var k = this.keys[i](x); + if (!m[k]) m[k] = {}; + m = m[k]; + } + k = this.keys[i](x); + if (!m[k]) { + var a = []; + values.push(a); + m[k] = a; + } + m[k].push(x); + } + + /* Sort each leaf array. */ + if (this.order) { + for (var i = 0; i < values.length; i++) { + values[i].sort(this.order); + } + } + + return map; +}; + +/** + * Returns a hierarchical nested array. This method is similar to + * {@link pv#entries}, but works recursively on the entire hierarchy. Rather + * than returning a map like {@link #map}, this method returns a nested + * array. Each element of the array has a <tt>key</tt> and <tt>values</tt> + * field. For leaf nodes, the <tt>values</tt> array will be a subset of the + * underlying elements array; for non-leaf nodes, the <tt>values</tt> array will + * contain more key-values pairs. + * + * <p>For an example usage, see the {@link Nest} constructor. + * + * @returns a hierarchical nested array. + */ +pv.Nest.prototype.entries = function() { + + /** Recursively extracts the entries for the given map. */ + function entries(map) { + var array = []; + for (var k in map) { + var v = map[k]; + array.push({ key: k, values: (v instanceof Array) ? v : entries(v) }); + }; + return array; + } + + /** Recursively sorts the values for the given key-values array. */ + function sort(array, i) { + var o = this.keys[i].order; + if (o) array.sort(function(a, b) { return o(a.key, b.key); }); + if (++i < this.keys.length) { + for (var j = 0; j < array.length; j++) { + sort.call(this, array[j].values, i); + } + } + return array; + } + + return sort.call(this, entries(this.map()), 0); +}; + +/** + * Returns a rollup map. The behavior of this method is the same as + * {@link #map}, except that the leaf values are replaced with the return value + * of the specified rollup function <tt>f</tt>. For example, + * + * <pre>pv.nest(yields) + * .key(function(d) d.site) + * .rollup(function(v) pv.median(v, function(d) d.yield))</pre> + * + * first groups yield data by site, and then returns a map from site to median + * yield for the given site. + * + * @see #map + * @param {function} f a rollup function. + * @returns a hierarhical map, with the leaf values computed by <tt>f</tt>. + */ +pv.Nest.prototype.rollup = function(f) { + + /** Recursively descends to the leaf nodes (arrays) and does rollup. */ + function rollup(map) { + for (var key in map) { + var value = map[key]; + if (value instanceof Array) { + map[key] = f(value); + } else { + rollup(value); + } + } + return map; + } + + return rollup(this.map()); +}; +pv.Scales.ordinal = function(ordinals) { + return new pv.Scales.OrdinalScale(ordinals); +}; + +/** + * OrdinalScale is a Scale for ordered sequential data. This supports both + * numeric and non-numeric data, and simply places each element in sequence + * using the ordering found in the input data array. + */ +pv.Scales.OrdinalScale = function(ordinals) { + pv.Scales.Scale.call(this); + + /* Filter the specified ordinals to their unique values. */ + var seen = {}; + this._ordinals = []; + for (var i = 0; i < ordinals.length; i++) { + var o = ordinals[i]; + if (seen[o] == undefined) { + seen[o] = true; + this._ordinals.push(o); + } + } + + this._map = pv.numerate(this._ordinals); +}; + +pv.Scales.OrdinalScale.prototype = pv.extend(pv.Scales.Scale); + +// Accessor method for ordinals +pv.Scales.OrdinalScale.prototype.ordinals = function(ordinals) { + if (ordinals == undefined) { + return this._ordinals; + } else { + this._ordinals = ordinals; + this._map = pv.numerate(ordinals); + return this; + } +}; + +// Normalizes the value +pv.Scales.OrdinalScale.prototype.normalize = function(x) { + var i = this._map[x]; + + // if x not an ordinal value(assume x is an index value) + if (i == undefined) i = x; + + // Not sure if the value should be shifted + return (i == undefined) ? -1 : (i + 0.5) / this._ordinals.length; +}; + +// Returns the ordinal values for i +pv.Scales.OrdinalScale.prototype.unnormalize = function(n) { + var i = Math.floor(n * this._ordinals.length - 0.5); + return this._ordinals[i]; +}; + +// Returns a list of rule values +pv.Scales.OrdinalScale.prototype.ruleValues = function() { + return pv.range(0.5, this._ordinals.length-0.5); +}; + +// Returns the width between rules +pv.Scales.OrdinalScale.prototype.ruleWidth = function() { + return this.scale(1/this._ordinals.length); +}; +pv.Scales.root = function(min, max, base) { + return new pv.Scales.RootScale(min, max, base); +}; + +pv.Scales.root.fromData = function(data, f, base) { + return new pv.Scales.RootScale(pv.min(data, f), pv.max(data, f), base); +} + +/** + * RootScale is a QuantativeScale that performs a root transformation of the + * data. This could be a square root or any arbitrary power. A root scale may + * be a many-to-one mapping where the reverse mapping will not be correct. + */ +pv.Scales.RootScale = function(min, max, base) { + if (min instanceof Array) { + if (max == undefined) max = 2; // default base for root is 2. + } else { + if (base == undefined) base = 2; // default base for root is 2. + } + + pv.Scales.QuantitativeScale.call(this, min, max, base); + + this.update(); +}; + +// Returns the root value with base b +pv.Scales.RootScale.root = function (x, b) { + var s = (x < 0) ? -1 : 1; + return s * Math.pow(s * x, 1 / b); +}; + +pv.Scales.RootScale.prototype = pv.extend(pv.Scales.QuantitativeScale); + +// Accessor method for min +pv.Scales.RootScale.prototype.min = function(x) { + var value = pv.Scales.QuantitativeScale.prototype.min.call(this, x); + if (x != undefined) this.update(); + return value; +}; + +// Accessor method for max +pv.Scales.RootScale.prototype.max = function(x) { + var value = pv.Scales.QuantitativeScale.prototype.max.call(this, x); + if (x != undefined) this.update(); + return value; +}; + +// Accessor method for base +pv.Scales.RootScale.prototype.base = function(x) { + var value = pv.Scales.QuantitativeScale.prototype.base.call(this, x); + if (x != undefined) this.update(); + return value; +}; + +// Normalizes the value +pv.Scales.RootScale.prototype.normalize = function(x) { + var eps = pv.Scales.epsilon; + var range = this._rmax - this._rmin; + + return (range < eps && range > -eps) ? 0 + : (pv.Scales.RootScale.root(x, this._base) - this._rmin) + / (this._rmax - this._rmin); +}; + +// Un-normalizes the value +pv.Scales.RootScale.prototype.unnormalize = function(n) { + return Math.pow(n * (this._rmax - this._rmin) + this._rmin, this._base); +}; + +// Sets min/max values to "nice numbers" +pv.Scales.RootScale.prototype.nice = function() { + var step = this.step(this._rmin, this._rmax); + + this._rmin = Math.floor(this._rmin / step) * step; + this._rmax = Math.ceil(this._rmax / step) * step; + + this._min = Math.pow(this._rmin, this._base); + this._max = Math.pow(this._rmax, this._base); + + return this; +}; + +// Returns a list of rule values +// The rule values of a root scale should be the powers +// of integers, e.g. 1, 4, 9, ... for base = 2 +// TODO: This function needs further testing +pv.Scales.RootScale.prototype.ruleValues = function() { + var step = this.step(this._rmin, this._rmax); +// if (step < 1) step = 1; // bound to 1 + // TODO: handle decimal values + + var s; + var list = pv.range(Math.floor(this._rmin), Math.ceil(this._rmax), step); + for (var i = 0; i < list.length; i++) { + s = (list[i] < 0) ? -1 : 1; + list[i] = s*Math.pow(list[i], this._base); + } + + // check end points + if (list[0] < this._min) list.splice(0, 1); + if (list[list.length-1] > this._max) list.splice(list.length-1, 1); + + return list; +}; + +// Update root scale values +pv.Scales.RootScale.prototype.update = function() { + var rt = pv.Scales.RootScale.root; + this._rmin = rt(this._min, this._base); + this._rmax = rt(this._max, this._base); +}; + return pv; +}(); diff --git a/comm/mail/base/content/quickFilterBar.inc.xhtml b/comm/mail/base/content/quickFilterBar.inc.xhtml new file mode 100644 index 0000000000..d7ddee8ef6 --- /dev/null +++ b/comm/mail/base/content/quickFilterBar.inc.xhtml @@ -0,0 +1,118 @@ +# 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/. + + <div id="quick-filter-bar" class="themeable-brighttext" hidden="hidden"> + <div id="quickFilterBarContainer"> + <button is="toggle-button" id="qfb-sticky" + class="button icon-button icon-only check-button" + data-l10n-id="quick-filter-bar-sticky"> + </button> + <xul:search-textbox id="qfb-qs-textbox" + class="themeableSearchBox" + timeout="500" + maxlength="192" + data-l10n-id="quick-filter-bar-textbox" + data-l10n-attrs="placeholder" /> + <button id="qfd-dropdown" + class="button button-flat icon-button icon-only" + data-l10n-id="quick-filter-bar-dropdown"> + </button> + <div class="roving-group button-group quickFilterButtons"> + <button is="toggle-button" id="qfb-unread" + class="button collapsible-button icon-button check-button" + data-l10n-id="quick-filter-bar-unread"> + <span data-l10n-id="quick-filter-bar-unread-label"></span> + </button> + <button is="toggle-button" id="qfb-starred" + class="button collapsible-button icon-button check-button" + data-l10n-id="quick-filter-bar-starred"> + <span data-l10n-id="quick-filter-bar-starred-label"></span> + </button> + <button is="toggle-button" id="qfb-inaddrbook" + class="button collapsible-button icon-button check-button" + data-l10n-id="quick-filter-bar-inaddrbook"> + <span data-l10n-id="quick-filter-bar-inaddrbook-label"></span> + </button> + <button is="toggle-button" id="qfb-tags" + class="button collapsible-button icon-button check-button" + data-l10n-id="quick-filter-bar-tags"> + <span data-l10n-id="quick-filter-bar-tags-label"></span> + </button> + <button is="toggle-button" id="qfb-attachment" + class="button collapsible-button icon-button check-button" + data-l10n-id="quick-filter-bar-attachment"> + <span data-l10n-id="quick-filter-bar-attachment-label"></span> + </button> + </div> + <span id="qfb-results-label"></span> + </div> + <div id="quickFilterBarSecondFilters"> + <div id="quick-filter-bar-filter-text-bar" hidden="hidden"> + <span id="qfb-qs-label" + data-l10n-id="quick-filter-bar-text-filter-explanation"></span> + <div class="roving-group button-group"> + <button is="toggle-button" id="qfb-qs-sender" + class="button check-button" + data-l10n-id="quick-filter-bar-text-filter-sender"></button> + <button is="toggle-button" id="qfb-qs-recipients" + class="button check-button" + data-l10n-id="quick-filter-bar-text-filter-recipients"></button> + <button is="toggle-button" id="qfb-qs-subject" + class="button check-button" + data-l10n-id="quick-filter-bar-text-filter-subject"></button> + <button is="toggle-button" id="qfb-qs-body" + class="button check-button" + data-l10n-id="quick-filter-bar-text-filter-body"></button> + </div> + </div> + <div id="quickFilterBarTagsContainer" hidden="hidden"> + <xul:menulist id="qfb-boolean-mode" value="OR"> + <xul:menupopup> + <xul:menuitem id="qfb-boolean-mode-or" + value="OR" + data-l10n-id="quick-filter-bar-boolean-mode-any" + default="default"/> + <xul:menuitem id="qfb-boolean-mode-and" + value="AND" + data-l10n-id="quick-filter-bar-boolean-mode-all"/> + </xul:menupopup> + </xul:menulist> + </div> + </div> + </div> + <xul:menupopup id="quickFilterButtonsContext" + class="no-accel-menupopup" + position="bottomleft topleft" + onpopupshowing="quickFilterBar.updateCheckedStateQuickFilterButtons();"> + <xul:menuitem id="quickFilterButtonsContextUnreadToggle" + class="quick-filter-menuitem" + type="checkbox" + value="unread" + closemenu="none" + data-l10n-id="quick-filter-bar-dropdown-unread"/> + <xul:menuitem id="quickFilterButtonsContextStarredToggle" + class="quick-filter-menuitem" + type="checkbox" + value="starred" + closemenu="none" + data-l10n-id="quick-filter-bar-dropdown-starred"/> + <xul:menuitem id="quickFilterButtonsContextInaddrbookToggle" + class="quick-filter-menuitem" + type="checkbox" + value="addrBook" + closemenu="none" + data-l10n-id="quick-filter-bar-dropdown-inaddrbook"/> + <xul:menuitem id="quickFilterButtonsContextTagsToggle" + class="quick-filter-menuitem" + type="checkbox" + value="tags" + closemenu="none" + data-l10n-id="quick-filter-bar-dropdown-tags"/> + <xul:menuitem id="quickFilterButtonsContextAttachmentToggle" + class="quick-filter-menuitem" + type="checkbox" + value="attachment" + closemenu="none" + data-l10n-id="quick-filter-bar-dropdown-attachment"/> + </xul:menupopup> 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") +); diff --git a/comm/mail/base/content/sanitize.js b/comm/mail/base/content/sanitize.js new file mode 100644 index 0000000000..68510faeac --- /dev/null +++ b/comm/mail/base/content/sanitize.js @@ -0,0 +1,241 @@ +/* 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 { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +function Sanitizer() {} +Sanitizer.prototype = { + // warning to the caller: this one may raise an exception (e.g. bug #265028) + clearItem(aItemName) { + if (this.items[aItemName].canClear) { + this.items[aItemName].clear(); + } + }, + + canClearItem(aItemName) { + return this.items[aItemName].canClear; + }, + + prefDomain: "", + + getNameFromPreference(aPreferenceName) { + return aPreferenceName.substr(this.prefDomain.length); + }, + + /** + * Deletes privacy sensitive data in a batch, according to user preferences + * + * @returns null if everything's fine; an object in the form + * { itemName: error, ... } on (partial) failure + */ + sanitize() { + var branch = Services.prefs.getBranch(this.prefDomain); + var errors = null; + + // Cache the range of times to clear + if (this.ignoreTimespan) { + // If we ignore timespan, clear everything. + var range = null; + } else { + range = this.range || Sanitizer.getClearRange(); + } + + for (var itemName in this.items) { + var item = this.items[itemName]; + item.range = range; + if ("clear" in item && item.canClear && branch.getBoolPref(itemName)) { + // Some of these clear() may raise exceptions (see bug #265028) + // to sanitize as much as possible, we catch and store them, + // rather than fail fast. + // Callers should check returned errors and give user feedback + // about items that could not be sanitized + try { + item.clear(); + } catch (er) { + if (!errors) { + errors = {}; + } + errors[itemName] = er; + dump("Error sanitizing " + itemName + ": " + er + "\n"); + } + } + } + return errors; + }, + + // Time span only makes sense in certain cases. Consumers who want + // to only clear some private data can opt in by setting this to false, + // and can optionally specify a specific range. If timespan is not ignored, + // and range is not set, sanitize() will use the value of the timespan + // pref to determine a range + ignoreTimespan: true, + range: null, + + items: { + cache: { + clear() { + try { + // Cache doesn't consult timespan, nor does it have the + // facility for timespan-based eviction. Wipe it. + Services.cache2.clear(); + } catch (ex) {} + }, + + get canClear() { + return true; + }, + }, + + cookies: { + clear() { + if (this.range) { + // Iterate through the cookies and delete any created after our cutoff. + for (let cookie of Services.cookies.cookies) { + if (cookie.creationTime > this.range[0]) { + // This cookie was created after our cutoff, clear it + Services.cookies.remove( + cookie.host, + cookie.name, + cookie.path, + cookie.originAttributes + ); + } + } + } else { + // Remove everything + Services.cookies.removeAll(); + } + }, + + get canClear() { + return true; + }, + }, + + history: { + clear() { + if (this.range) { + PlacesUtils.history.removeVisitsByFilter({ + beginDate: new Date(this.range[0]), + endDate: new Date(this.range[1]), + }); + } else { + PlacesUtils.history.clear(); + } + + try { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + } catch (e) {} + + try { + var predictor = Cc["@mozilla.org/network/predictor;1"].getService( + Ci.nsINetworkPredictor + ); + predictor.reset(); + } catch (e) {} + }, + + get canClear() { + // bug 347231: Always allow clearing history due to dependencies on + // the browser:purge-session-history notification. (like error console) + return true; + }, + }, + }, +}; + +// "Static" members +Sanitizer.prefDomain = "privacy.sanitize."; +Sanitizer.prefShutdown = "sanitizeOnShutdown"; +Sanitizer.prefDidShutdown = "didShutdownSanitize"; + +// Time span constants corresponding to values of the privacy.sanitize.timeSpan +// pref. Used to determine how much history to clear, for various items +Sanitizer.TIMESPAN_EVERYTHING = 0; +Sanitizer.TIMESPAN_HOUR = 1; +Sanitizer.TIMESPAN_2HOURS = 2; +Sanitizer.TIMESPAN_4HOURS = 3; +Sanitizer.TIMESPAN_TODAY = 4; + +// Return a 2 element array representing the start and end times, +// in the uSec-since-epoch format that PRTime likes. If we should +// clear everything, return null. Use ts if it is defined; otherwise +// use the timeSpan pref. +Sanitizer.getClearRange = function (ts) { + if (ts === undefined) { + ts = Sanitizer.prefs.getIntPref("timeSpan"); + } + if (ts === Sanitizer.TIMESPAN_EVERYTHING) { + return null; + } + + // PRTime is microseconds while JS time is milliseconds + var endDate = Date.now() * 1000; + switch (ts) { + case Sanitizer.TIMESPAN_HOUR: + var startDate = endDate - 3600000000; // 1*60*60*1000000 + break; + case Sanitizer.TIMESPAN_2HOURS: + startDate = endDate - 7200000000; // 2*60*60*1000000 + break; + case Sanitizer.TIMESPAN_4HOURS: + startDate = endDate - 14400000000; // 4*60*60*1000000 + break; + case Sanitizer.TIMESPAN_TODAY: + var d = new Date(); // Start with today + d.setHours(0); // zero us back to midnight... + d.setMinutes(0); + d.setSeconds(0); + startDate = d.valueOf() * 1000; // convert to epoch usec + break; + default: + throw new Error("Invalid time span for clear private data: " + ts); + } + return [startDate, endDate]; +}; + +Sanitizer._prefs = null; +Sanitizer.__defineGetter__("prefs", function () { + return Sanitizer._prefs + ? Sanitizer._prefs + : (Sanitizer._prefs = Services.prefs.getBranch(Sanitizer.prefDomain)); +}); + +// Shows sanitization UI +Sanitizer.showUI = function (aParentWindow) { + Services.ww.openWindow( + AppConstants.platform == "macosx" ? null : aParentWindow, + "chrome://messenger/content/sanitize.xhtml", + "Sanitize", + "chrome,titlebar,dialog,centerscreen,modal", + null + ); +}; + +/** + * Deletes privacy sensitive data in a batch, optionally showing the + * sanitize UI, according to user preferences + */ +Sanitizer.sanitize = function (aParentWindow) { + Sanitizer.showUI(aParentWindow); +}; + +// this is called on startup and shutdown, to perform pending sanitizations +Sanitizer._checkAndSanitize = function () { + const prefs = Sanitizer.prefs; + if ( + prefs.getBoolPref(Sanitizer.prefShutdown) && + !prefs.prefHasUserValue(Sanitizer.prefDidShutdown) + ) { + // this is a shutdown or a startup after an unclean exit + var s = new Sanitizer(); + s.prefDomain = "privacy.clearOnShutdown."; + s.sanitize() || prefs.setBoolPref(Sanitizer.prefDidShutdown, true); // sanitize() returns null on full success + } +}; diff --git a/comm/mail/base/content/sanitize.xhtml b/comm/mail/base/content/sanitize.xhtml new file mode 100644 index 0000000000..b9186841ab --- /dev/null +++ b/comm/mail/base/content/sanitize.xhtml @@ -0,0 +1,92 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css"?> +<?xml-stylesheet href="chrome://messenger/skin/variables.css"?> +<?xml-stylesheet href="chrome://messenger/skin/shared/sanitizeDialog.css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet href="chrome://messenger/skin/colors.css"?> +<?xml-stylesheet href="chrome://messenger/skin/themeableDialog.css"?> + +<!DOCTYPE window [ + <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> + <!ENTITY % sanitizeDTD SYSTEM "chrome://messenger/locale/sanitize.dtd"> + %brandDTD; + %sanitizeDTD; +]> + +<window type="child" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="&sanitizeDialog2.title;" + noneverythingtitle="&sanitizeDialog2.title;" + style="min-width: &dialog.width;" + lightweightthemes="true" + onload="gSanitizePromptDialog.init();"> +<dialog id="SanitizeDialog" + dlgbuttons="accept,cancel"> + + <vbox id="SanitizeDialogPane"> + <stringbundle id="bundleBrowser" + src="chrome://messenger/locale/messenger.properties"/> + + <script src="chrome://messenger/content/sanitize.js"/> + <script src="chrome://messenger/content/sanitizeDialog.js"/> + <script src="chrome://messenger/content/dialogShadowDom.js"/> + + <hbox id="SanitizeDurationBox" align="center"> + <label value="&clearTimeDuration.label;" + accesskey="&clearTimeDuration.accesskey;" + control="sanitizeDurationChoice" + id="sanitizeDurationLabel"/> + <menulist id="sanitizeDurationChoice" + onselect="gSanitizePromptDialog.selectByTimespan();" + flex="1"> + <menupopup id="sanitizeDurationPopup"> + <menuitem label="&clearTimeDuration.lastHour;" value="1"/> + <menuitem label="&clearTimeDuration.last2Hours;" value="2"/> + <menuitem label="&clearTimeDuration.last4Hours;" value="3"/> + <menuitem label="&clearTimeDuration.today;" value="4"/> + <menuseparator/> + <menuitem label="&clearTimeDuration.everything;" value="0"/> + </menupopup> + </menulist> + <label id="sanitizeDurationSuffixLabel" + value="&clearTimeDuration.suffix;"/> + </hbox> + + <vbox id="sanitizeEverythingWarningBox"> + <spacer flex="1"/> + <hbox align="center"> + <html:img id="sanitizeEverythingWarningIcon" alt="" /> + <vbox id="sanitizeEverythingWarningDescBox" flex="1"> + <description id="sanitizeEverythingWarning"/> + <description id="sanitizeEverythingUndoWarning">&sanitizeEverythingUndoWarning;</description> + </vbox> + </hbox> + <spacer flex="1"/> + </vbox> + + <label id="historyGroupLabel" value="&historyGroup.label;"/> + <vbox id="historyGroup"> + <checkbox label="&itemHistory.label;" + accesskey="&itemHistory.accesskey;" + preference="privacy.cpd.history" + oncommand="gSanitizePromptDialog.onReadGeneric();"/> + <checkbox label="&itemCookies.label;" + accesskey="&itemCookies.accesskey;" + preference="privacy.cpd.cookies" + oncommand="gSanitizePromptDialog.onReadGeneric();"/> + <checkbox label="&itemCache.label;" + accesskey="&itemCache.accesskey;" + preference="privacy.cpd.cache" + oncommand="gSanitizePromptDialog.onReadGeneric();"/> + </vbox> + <separator class="thin"/> + + </vbox> +</dialog> +</window> diff --git a/comm/mail/base/content/sanitizeDialog.js b/comm/mail/base/content/sanitizeDialog.js new file mode 100644 index 0000000000..457cd4dc45 --- /dev/null +++ b/comm/mail/base/content/sanitizeDialog.js @@ -0,0 +1,206 @@ +/* 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 sanitize.js */ + +var gSanitizePromptDialog = { + get bundleBrowser() { + if (!this._bundleBrowser) { + this._bundleBrowser = document.getElementById("bundleBrowser"); + } + return this._bundleBrowser; + }, + + get selectedTimespan() { + var durList = document.getElementById("sanitizeDurationChoice"); + return parseInt(durList.value); + }, + + get warningBox() { + return document.getElementById("sanitizeEverythingWarningBox"); + }, + + init() { + // This is used by selectByTimespan() to determine if the window has loaded. + this._inited = true; + + var s = new Sanitizer(); + s.prefDomain = "privacy.cpd."; + + document.getElementById("sanitizeDurationChoice").value = + Services.prefs.getIntPref("privacy.sanitize.timeSpan"); + + let sanitizeItemList = document.querySelectorAll( + "#historyGroup > [preference]" + ); + for (let prefItem of sanitizeItemList) { + let name = s.getNameFromPreference(prefItem.getAttribute("preference")); + if (!s.canClearItem(name)) { + prefItem.preference = null; + prefItem.checked = false; + prefItem.disabled = true; + } else { + prefItem.checked = Services.prefs.getBoolPref( + prefItem.getAttribute("preference") + ); + } + } + + this.onReadGeneric(); + + document.querySelector("dialog").getButton("accept").label = + this.bundleBrowser.getString("sanitizeButtonOK"); + + let warningIcon = document.getElementById("sanitizeEverythingWarningIcon"); + warningIcon.setAttribute( + "src", + "chrome://messenger/skin/icons/new/activity/warning.svg" + ); + + if (this.selectedTimespan === Sanitizer.TIMESPAN_EVERYTHING) { + this.prepareWarning(); + this.warningBox.hidden = false; + document.title = this.bundleBrowser.getString( + "sanitizeDialog2.everything.title" + ); + } else { + this.warningBox.hidden = true; + } + }, + + selectByTimespan() { + // This method is the onselect handler for the duration dropdown. As a + // result it's called a couple of times before onload calls init(). + if (!this._inited) { + return; + } + + var warningBox = this.warningBox; + + // If clearing everything + if (this.selectedTimespan === Sanitizer.TIMESPAN_EVERYTHING) { + this.prepareWarning(); + if (warningBox.hidden) { + warningBox.hidden = false; + } + window.sizeToContent(); + window.document.title = this.bundleBrowser.getString( + "sanitizeDialog2.everything.title" + ); + return; + } + + // If clearing a specific time range + if (!warningBox.hidden) { + window.resizeBy(0, -warningBox.getBoundingClientRect().height); + warningBox.hidden = true; + } + window.document.title = + window.document.documentElement.getAttribute("noneverythingtitle"); + }, + + sanitize() { + // Update pref values before handing off to the sanitizer (bug 453440) + this.updatePrefs(); + var s = new Sanitizer(); + s.prefDomain = "privacy.cpd."; + + s.range = Sanitizer.getClearRange(this.selectedTimespan); + s.ignoreTimespan = !s.range; + + try { + s.sanitize(); + } catch (er) { + console.error("Exception during sanitize: " + er); + } + }, + + /** + * If the panel that displays a warning when the duration is "Everything" is + * not set up, sets it up. Otherwise does nothing. + */ + prepareWarning() { + // If the date and time-aware locale warning string is ever used again, + // initialize it here. Currently we use the no-visits warning string, + // which does not include date and time. See bug 480169 comment 48. + + var warningStringID; + if (this.hasNonSelectedItems()) { + warningStringID = "sanitizeSelectedWarning"; + } else { + warningStringID = "sanitizeEverythingWarning2"; + } + + var warningDesc = document.getElementById("sanitizeEverythingWarning"); + warningDesc.textContent = this.bundleBrowser.getString(warningStringID); + }, + + /** + * Called when the value of a preference element is synced from the actual + * pref. Enables or disables the OK button appropriately. + */ + onReadGeneric() { + var found = false; + + // Find any other pref that's checked and enabled. + let sanitizeItemList = document.querySelectorAll( + "#historyGroup > [preference]" + ); + for (let prefItem of sanitizeItemList) { + found = !prefItem.disabled && prefItem.checked; + if (found) { + break; + } + } + + try { + document.querySelector("dialog").getButton("accept").disabled = !found; + } catch (e) {} + + // Update the warning prompt if needed + this.prepareWarning(); + + return undefined; + }, + + /** + * Sanitizer.prototype.sanitize() requires the prefs to be up-to-date. + * Because the type of this prefwindow is "child" -- and that's needed because + * without it the dialog has no OK and Cancel buttons -- the prefs are not + * updated on dialogaccept on platforms that don't support instant-apply + * (i.e., Windows). We must therefore manually set the prefs from their + * corresponding preference elements. + */ + updatePrefs() { + Sanitizer.prefs.setIntPref("timeSpan", this.selectedTimespan); + + // Now manually set the prefs from their corresponding preference elements. + let sanitizeItemList = document.querySelectorAll( + "#historyGroup > [preference]" + ); + for (let prefItem of sanitizeItemList) { + let prefName = prefItem.getAttribute("preference"); + Services.prefs.setBoolPref(prefName, prefItem.checked); + } + }, + + /** + * Check if all of the history items have been selected like the default status. + */ + hasNonSelectedItems() { + let sanitizeItemList = document.querySelectorAll( + "#historyGroup > [preference]" + ); + for (let prefItem of sanitizeItemList) { + if (!prefItem.checked) { + return true; + } + } + return false; + }, +}; + +document.addEventListener("dialogaccept", () => + gSanitizePromptDialog.sanitize() +); diff --git a/comm/mail/base/content/searchBar.js b/comm/mail/base/content/searchBar.js new file mode 100644 index 0000000000..48e066a05b --- /dev/null +++ b/comm/mail/base/content/searchBar.js @@ -0,0 +1,45 @@ +/* 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 gStatusBar = document.getElementById("statusbar-icon"); + +/** + * The glodasearch widget is a UI widget (the #searchInput textbox) which is + * outside of the mailTabType's display panel, but acts as though it were within + * it.. This means we need to use a tab monitor so that we can appropriately + * update the contents of the textbox. + * + * Every time a tab is changed, we save the state of the text box and restore + * its previous value for the tab we are switching to, as well as whether this + * value is a change to the currently-used value (if it is a faceted search) tab. + * The behaviour rationale for this is that the searchInput is like the + * URL bar. When you are on a glodaSearch tab, we need to show you your + * current value, including any "uncommitted" (you haven't hit enter yet) + * changes. + * + * In addition, we want to disable the quick-search modes when a tab is + * being displayed that lacks quick search abilities (but we'll leave the + * faceted search as it's always available). + */ + +var GlodaSearchBoxTabMonitor = { + monitorName: "glodaSearchBox", + + onTabSwitched(aTab, aOldTab) {}, + + onTabTitleChanged() {}, + + onTabOpened(aTab, aFirstTab, aOldTab) { + aTab._ext.glodaSearchBox = { + value: aTab.mode.name === "glodaFacet" ? aTab.searchString : "", + }; + + if (aTab.mode.name === "glodaFacet") { + let searchInput = aTab.panel.querySelector(".remote-gloda-search"); + if (searchInput) { + searchInput.value = aTab.searchString; + } + } + }, +}; diff --git a/comm/mail/base/content/selectionsummaries.js b/comm/mail/base/content/selectionsummaries.js new file mode 100644 index 0000000000..75461e3a54 --- /dev/null +++ b/comm/mail/base/content/selectionsummaries.js @@ -0,0 +1,103 @@ +/* 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/. */ + +/* globals gSummaryFrameManager */ // From messenger.js + +/** + * Summarize a set of selected messages. This can either be a single thread or + * multiple threads. + * + * @param aMessageDisplay The MessageDisplayWidget object responsible for + * showing messages. + */ +function summarizeSelection(aMessageDisplay) { + // Figure out if we're looking at one thread or more than one thread. We want + // the view's version of threading, not the database's version, in order to + // thread together cross-folder messages. XXX: This falls apart for group by + // sort; what we really want is a way to specify only the cross-folder view. + let folderDisplay = aMessageDisplay.folderDisplay; + let selectedIndices = folderDisplay.selectedIndices; + let dbView = folderDisplay.view.dbView; + + let getThreadId = function (index) { + return dbView.getThreadContainingIndex(index).getRootHdr().messageKey; + }; + + let firstThreadId = getThreadId(selectedIndices[0]); + let oneThread = true; + for (let i = 1; i < selectedIndices.length; i++) { + if (getThreadId(selectedIndices[i]) != firstThreadId) { + oneThread = false; + break; + } + } + + let selectedMessages = folderDisplay.selectedMessages; + if (oneThread) { + summarizeThread(selectedMessages, aMessageDisplay); + } else { + summarizeMultipleSelection(selectedMessages, aMessageDisplay); + } +} + +/** + * Given an array of messages which are all in the same thread, summarize them. + * + * @param aSelectedMessages Array of message headers. + * @param aMessageDisplay The MessageDisplayWidget object responsible for + * showing messages. + */ +function summarizeThread(aSelectedMessages, aMessageDisplay) { + const kSummaryURL = "chrome://messenger/content/multimessageview.xhtml"; + + aMessageDisplay.singleMessageDisplay = false; + gSummaryFrameManager.loadAndCallback(kSummaryURL, function () { + let childWindow = gSummaryFrameManager.iframe.contentWindow; + try { + childWindow.gMessageSummary.summarize( + "thread", + aSelectedMessages, + aMessageDisplay.folderDisplay.view.dbView, + aMessageDisplay.folderDisplay.selectMessages.bind( + aMessageDisplay.folderDisplay + ), + aMessageDisplay + ); + } catch (e) { + console.error(e); + throw e; + } + }); +} + +/** + * Given an array of message URIs, cause the message panel to display a summary + * of them. + * + * @param aSelectedMessages Array of message headers. + * @param aMessageDisplay The MessageDisplayWidget object responsible for + * showing messages. + */ +function summarizeMultipleSelection(aSelectedMessages, aMessageDisplay) { + const kSummaryURL = "chrome://messenger/content/multimessageview.xhtml"; + + aMessageDisplay.singleMessageDisplay = false; + gSummaryFrameManager.loadAndCallback(kSummaryURL, function () { + let childWindow = gSummaryFrameManager.iframe.contentWindow; + try { + childWindow.gMessageSummary.summarize( + "multipleselection", + aSelectedMessages, + aMessageDisplay.folderDisplay.view.dbView, + aMessageDisplay.folderDisplay.selectMessages.bind( + aMessageDisplay.folderDisplay + ), + aMessageDisplay + ); + } catch (e) { + console.error(e); + throw e; + } + }); +} diff --git a/comm/mail/base/content/shortcutsOverlay.js b/comm/mail/base/content/shortcutsOverlay.js new file mode 100644 index 0000000000..85bf20b5ec --- /dev/null +++ b/comm/mail/base/content/shortcutsOverlay.js @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + XPCOMUtils.defineLazyModuleGetters(this, { + ShortcutsManager: "resource:///modules/ShortcutsManager.jsm", + }); + + function setupShortcuts() { + // Set up all dedicated shortcuts. + setupSpacesShortcuts(); + + // Set up the event listener. + setupEventListener(); + } + + /** + * Use the ShortcutManager to set up all keyboard shortcuts for the spaces + * toolbar buttons. + */ + async function setupSpacesShortcuts() { + // Set up all shortcut strings for the various spaces buttons. + let buttons = { + "space-toggle": ["collapseButton", "spacesToolbarReveal"], + "space-mail": ["mailButton"], + "space-addressbook": ["addressBookButton"], + "space-calendar": ["calendarButton"], + "space-tasks": ["tasksButton"], + "space-chat": ["chatButton"], + }; + for (let [string, ids] of Object.entries(buttons)) { + let shortcut = await ShortcutsManager.getShortcutStrings(string); + if (!shortcut) { + continue; + } + + for (let id of ids) { + let button = document.getElementById(id); + button.setAttribute("aria-label", button.title); + document.l10n.setAttributes(button, "button-shortcut-string", { + title: button.title, + shortcut: shortcut.localizedShortcut, + }); + button.setAttribute("aria-keyshortcuts", shortcut.ariaKeyShortcuts); + } + } + + // Set up all shortcut strings for the various spaces menuitems. + let menuitems = { + "space-toggle": ["spacesPopupButtonReveal"], + "space-mail": ["spacesPopupButtonMail"], + "space-addressbook": ["spacesPopupButtonAddressBook"], + "space-calendar": [ + "spacesPopupButtonCalendar", + "calMenuSwitchToCalendar", + ], + "space-tasks": ["spacesPopupButtonTasks", "calMenuSwitchToTask"], + "space-chat": ["spacesPopupButtonChat", "menu_goChat"], + }; + for (let [string, ids] of Object.entries(menuitems)) { + let shortcut = await ShortcutsManager.getShortcutStrings(string); + if (!shortcut) { + continue; + } + + for (let id of ids) { + let menuitem = document.getElementById(id); + if (!menuitem.label) { + await document.l10n.translateElements([menuitem]); + } + document.l10n.setAttributes(menuitem, "menuitem-shortcut-string", { + label: menuitem.label, + shortcut: shortcut.localizedShortcut, + }); + } + } + } + + /** + * Set up the keydown event to intercept shortcuts. + */ + function setupEventListener() { + let tabmail = document.getElementById("tabmail"); + + window.addEventListener("keydown", event => { + let shortcut = ShortcutsManager.matches(event); + // FIXME: Temporarily ignore numbers coming from the Numpad to prevent + // hijacking Alt characters typing in Windows. This can be removed once + // we implement customizable shortcuts. + if (!shortcut || event.location == 3) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + switch (shortcut.id) { + case "space-toggle": + window.gSpacesToolbar.toggleToolbar(!window.gSpacesToolbar.isHidden); + break; + case "space-mail": + case "space-addressbook": + case "space-calendar": + case "space-tasks": + case "space-chat": + let space = window.gSpacesToolbar.spaces.find( + space => space.name == shortcut.id.replace("space-", "") + ); + window.gSpacesToolbar.openSpace(tabmail, space); + break; + } + }); + } + + window.addEventListener("load", setupShortcuts); +} diff --git a/comm/mail/base/content/spacesToolbar.inc.xhtml b/comm/mail/base/content/spacesToolbar.inc.xhtml new file mode 100644 index 0000000000..440cfe9ee6 --- /dev/null +++ b/comm/mail/base/content/spacesToolbar.inc.xhtml @@ -0,0 +1,166 @@ +# 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/. + +<popupset id="spacesToolbarPopupSet"> + <menupopup id="spacesContextMenu" + class="no-icon-menupopup no-accel-menupopup"> + <menuitem id="spacesContextNewTabItem" + class="menuitem-iconic" + data-l10n-id="spaces-context-new-tab-item"/> + <menuitem id="spacesContextNewWindowItem" + class="menuitem-iconic" + data-l10n-id="spaces-context-new-window-item"/> + <menuseparator id="spacesContextMenuSeparator" hidden="true"/> + </menupopup> + + <menupopup id="settingsContextMenu" + class="no-icon-menupopup no-accel-menupopup"> + <menuitem id="settingsContextOpenSettingsItem" + class="menuitem-iconic" + data-l10n-id="settings-context-open-settings-item2"/> + <menuitem id="settingsContextOpenAccountSettingsItem" + class="menuitem-iconic" + data-l10n-id="settings-context-open-account-settings-item2"/> + <menuitem id="settingsContextOpenAddonsItem" + class="menuitem-iconic" + data-l10n-id="settings-context-open-addons-item2"/> + <menuseparator/> + <menuitem id="settingsContextOpenCustomizeItem" + class="menuitem-iconic" + data-l10n-id="menuitem-customize-label"/> + </menupopup> + + <menupopup id="spacesToolbarContextMenu" + class="no-icon-menupopup no-accel-menupopup"> + <menuitem id="spacesToolbarContextCustomize" + class="menuitem-iconic" + data-l10n-id="menuitem-customize-label" + oncommand="gSpacesToolbar.showCustomize();"/> + </menupopup> + + <menupopup id="spacesToolbarAddonsPopup" type="arrow" + class="no-accel-menupopup" + onpopuphidden="gSpacesToolbar.spacesToolbarAddonsPopupClosed();"> + </menupopup> +</popupset> + +<html:div role="toolbar" id="spacesToolbar" xmlns="http://www.w3.org/1999/xhtml" + class="spaces-toolbar" + data-l10n-id="spaces-toolbar-element" + data-l10n-attrs="toolbarname" + aria-orientation="vertical" + hidden="hidden"> + <!-- Hidden element used to fetch the style of an "active" button. --> + <span id="spacesAccentPlaceholder" hidden="hidden"></span> + + <!-- Primary tabs. --> + <div class="spaces-toolbar-container spaces-toolbar-top-container"> + <button id="mailButton" + data-l10n-id="spaces-toolbar-button-mail2" + class="spaces-toolbar-button" + tabindex="0"> + <img src="" alt="" /> + </button> + <button id="addressBookButton" + data-l10n-id="spaces-toolbar-button-address-book2" + class="spaces-toolbar-button" + tabindex="-1"> + <img src="" alt="" /> + </button> + <button id="calendarButton" + data-l10n-id="spaces-toolbar-button-calendar2" + class="spaces-toolbar-button" + tabindex="-1"> + <img src="" alt="" /> + </button> + <button id="tasksButton" + data-l10n-id="spaces-toolbar-button-tasks2" + class="spaces-toolbar-button" + tabindex="-1"> + <img src="" alt="" /> + </button> + <button id="chatButton" + data-l10n-id="spaces-toolbar-button-chat2" + class="spaces-toolbar-button" + tabindex="-1"> + <span class="spaces-badge-container"></span> + <img src="" alt="" /> + </button> + </div> + + <div id="spacesToolbarAddonsContainer" class="spaces-toolbar-container"> + <!-- Special container to allow add-ons to add buttons. --> + </div> + <button id="spacesToolbarAddonsOverflowButton" + data-l10n-id="spaces-toolbar-button-overflow" + class="spaces-toolbar-button" + aria-expanded="false" + aria-haspopup="menu" + aria-controls="spacesToolbarAddonsPopup" + hidden="hidden" + tabindex="-1"> + <img src="" alt="" /> + </button> + + <div class="spaces-toolbar-container spaces-toolbar-bottom-container"> + <!-- Settings button. --> + <button id="settingsButton" + data-l10n-id="spaces-toolbar-button-settings2" + class="spaces-toolbar-button" + tabindex="-1"> + <img src="" alt="" /> + </button> + + <!-- Collapse button. --> + <button id="collapseButton" + data-l10n-id="spaces-toolbar-button-hide" + class="spaces-toolbar-button" + tabindex="-1"> + <img src="" alt="" /> + </button> + </div> +</html:div> + +<panel id="spacesToolbarCustomizationPanel" + type="arrow" + orient="vertical" + class="cui-widget-panel popup-panel panel-no-padding" + noautohide="true" + onpopuphidden="gSpacesToolbar.onCustomizePopupHidden();"> + <!-- Keep the noautohide attribute to true to prevent the panel from closing + when the color picker appears. --> + <html:div class="popup-panel-body" xmlns="http://www.w3.org/1999/xhtml"> + <h3 data-l10n-id="spaces-customize-panel-title"></h3> + + <div class="popup-panel-options-grid"> + <label for="spacesBackgroundColor" + data-l10n-id="spaces-customize-background-color"></label> + <input type="color" id="spacesBackgroundColor" /> + + <label for="spacesIconsColor" + data-l10n-id="spaces-customize-icon-color"></label> + <input type="color" id="spacesIconsColor" /> + + <label for="spacesAccentBgColor" + data-l10n-id="spaces-customize-accent-background-color"></label> + <input type="color" id="spacesAccentBgColor" /> + + <label for="spacesAccentTextColor" + data-l10n-id="spaces-customize-accent-text-color"></label> + <input type="color" id="spacesAccentTextColor" /> + </div> + + <div class="popup-panel-buttons-container"> + <button type="button" + data-l10n-id="spaces-customize-button-restore" + onclick="gSpacesToolbar.resetColorCustomization();"> + </button> + <button type="button" + data-l10n-id="customize-panel-button-save" + onclick="gSpacesToolbar.closeCustomize();" + class="primary"> + </button> + </div> + </html:div> +</panel> diff --git a/comm/mail/base/content/spacesToolbar.js b/comm/mail/base/content/spacesToolbar.js new file mode 100644 index 0000000000..4155d7d0dc --- /dev/null +++ b/comm/mail/base/content/spacesToolbar.js @@ -0,0 +1,1325 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* import-globals-from mailCore.js */ +/* import-globals-from utilityOverlay.js */ + +/** + * Special vertical toolbar to organize all the buttons opening a tab. + */ +var gSpacesToolbar = { + SUPPORTED_BADGE_STYLES: ["--spaces-button-badge-bg-color"], + SUPPORTED_ICON_STYLES: [ + "--webextension-toolbar-image", + "--webextension-toolbar-image-dark", + "--webextension-toolbar-image-light", + "--webextension-toolbar-image-2x", + "--webextension-toolbar-image-2x-dark", + "--webextension-toolbar-image-2x-light", + ], + docURL: "chrome://messenger/content/messenger.xhtml", + /** + * The spaces toolbar DOM element. + * + * @type {?HTMLElement} + */ + element: null, + /** + * If the spaces toolbar has already been loaded. + * + * @type {boolean} + */ + isLoaded: false, + /** + * If the spaces toolbar is hidden or visible. + * + * @type {boolean} + */ + isHidden: false, + /** + * If the spaces toolbar is currently being customized. + * + * @type {boolean} + */ + isCustomizing: false, + /** + * The DOM element panel collecting all customization options. + */ + customizePanel: null, + /** + * The object storing all saved customization options: + * - background: The toolbar background color. + * - color: The default icon color of the buttons. + * - accentColor: The background color of an active/current button. + * - accentBackground: The icon color of an active/current button. + */ + customizeData: {}, + + /** + * @callback TabInSpace + * @param {object} tabInfo - The tabInfo object (a member of tabmail.tabInfo) + * for the tab. + * @returns {0|1|2} - The relation between the tab and the space. 0 means it + * does not belong to the space. 1 means it is a primary tab of the space. + * 2 means it is a secondary tab of the space. + */ + /** + * @callback OpenSpace + * @param {"tab"|"window"} where - Where to open the space: in a new tab or in + * a new window. + * @returns {?object | Window} - The tabInfo for the newly opened tab, or the + * newly opened messenger window, or null if neither was created. + */ + /** + * Data and methods for a space. + * + * @typedef {object} SpaceInfo + * @property {string} name - The name for this space. + * @property {boolean} allowMultipleTabs - Whether to allow the user to open + * multiple tabs in this space. + * @property {HTMLButtonElement} button - The toolbar button for this space. + * @property {?XULMenuItem} menuitem - The menuitem for this space, if + * available. + * @property {TabInSpace} tabInSpace - A callback that determines whether an + * existing tab is considered outside this space, a primary tab of this + * space (a tab that is similar to the tab created by the open method) or + * a secondary tab of this space (a related tab that still belongs to this + * space). + * @property {OpenSpace} open - A callback to open this space. + */ + /** + * The main spaces in this toolbar. This will be constructed on load. + * + * @type {?SpaceInfo[]} + */ + spaces: null, + /** + * The current space the window is in, or undefined if it is not in any of the + * known spaces. + * + * @type {SpaceInfo|undefined} + */ + currentSpace: undefined, + /** + * The number of buttons created by add-ons. + * + * @returns {integer} + */ + get addonButtonCount() { + return document.querySelectorAll(".spaces-addon-button").length; + }, + /** + * The number of pixel spacing to add to the add-ons button height calculation + * based on the current UI density. + * + * @type {integer} + */ + densitySpacing: 0, + /** + * The button that can receive focus. Used for managing focus with a roving + * tabindex. + * + * @type {?HTMLElement} + */ + focusButton: null, + + tabMonitor: { + monitorName: "spacesToolbarMonitor", + + onTabTitleChanged() {}, + onTabOpened() {}, + onTabPersist() {}, + onTabRestored() {}, + onTabClosing() {}, + + onTabSwitched(newTabInfo, oldTabInfo) { + // Bail out if for whatever reason something went wrong. + if (!newTabInfo) { + console.error( + "Spaces Toolbar: Missing new tab on monitored tab switching" + ); + return; + } + + let tabSpace = gSpacesToolbar.spaces.find(space => + space.tabInSpace(newTabInfo) + ); + if (gSpacesToolbar.currentSpace != tabSpace) { + gSpacesToolbar.currentSpace?.button.classList.remove("current"); + gSpacesToolbar.currentSpace?.menuitem?.classList.remove("current"); + gSpacesToolbar.currentSpace = tabSpace; + if (gSpacesToolbar.currentSpace) { + gSpacesToolbar.currentSpace.button.classList.add("current"); + gSpacesToolbar.currentSpace.menuitem?.classList.add("current"); + gSpacesToolbar.setFocusButton(gSpacesToolbar.currentSpace.button); + } + + const spaceChangeEvent = new CustomEvent("spacechange", { + detail: tabSpace, + }); + gSpacesToolbar.element.dispatchEvent(spaceChangeEvent); + } + }, + }, + + /** + * Convert an rgb() string to an hexadecimal color string. + * + * @param {string} color - The RGBA color string that needs conversion. + * @returns {string} - The converted hexadecimal color. + */ + _rgbToHex(color) { + let rgb = color.split("(")[1].split(")")[0].split(","); + + // For each array element convert ot a base16 string and add zero if we get + // only one character. + let hash = rgb.map(x => parseInt(x).toString(16).padStart(2, "0")); + + return `#${hash.join("")}`; + }, + + onLoad() { + if (this.isLoaded) { + return; + } + + this.element = document.getElementById("spacesToolbar"); + this.focusButton = document.getElementById("mailButton"); + let tabmail = document.getElementById("tabmail"); + + this.spaces = [ + { + name: "mail", + button: document.getElementById("mailButton"), + menuitem: document.getElementById("spacesPopupButtonMail"), + tabInSpace(tabInfo) { + switch (tabInfo.mode.name) { + case "folder": + case "mail3PaneTab": + case "mailMessageTab": + return 1; + default: + return 0; + } + }, + open(where) { + // Prefer the current tab, else the earliest tab. + let existingTab = [tabmail.currentTabInfo, ...tabmail.tabInfo].find( + tabInfo => this.tabInSpace(tabInfo) == 1 + ); + let folderURI = null; + switch (existingTab?.mode.name) { + case "folder": + folderURI = + existingTab.folderDisplay.displayedFolder?.URI || null; + break; + case "mail3PaneTab": + folderURI = existingTab.folder.URI || null; + break; + } + if (where == "window") { + return window.openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,dialog=no,all", + folderURI, + -1 + ); + } + return openTab("mail3PaneTab", { folderURI }, "tab"); + }, + allowMultipleTabs: true, + }, + { + name: "addressbook", + button: document.getElementById("addressBookButton"), + menuitem: document.getElementById("spacesPopupButtonAddressBook"), + tabInSpace(tabInfo) { + if (tabInfo.mode.name == "addressBookTab") { + return 1; + } + return 0; + }, + open(where) { + return openTab("addressBookTab", {}, where); + }, + }, + { + name: "calendar", + button: document.getElementById("calendarButton"), + menuitem: document.getElementById("spacesPopupButtonCalendar"), + tabInSpace(tabInfo) { + return tabInfo.mode.name == "calendar" ? 1 : 0; + }, + open(where) { + return openTab("calendar", {}, where); + }, + }, + { + name: "tasks", + button: document.getElementById("tasksButton"), + menuitem: document.getElementById("spacesPopupButtonTasks"), + tabInSpace(tabInfo) { + return tabInfo.mode.name == "tasks" ? 1 : 0; + }, + open(where) { + return openTab("tasks", {}, where); + }, + }, + { + name: "chat", + button: document.getElementById("chatButton"), + menuitem: document.getElementById("spacesPopupButtonChat"), + tabInSpace(tabInfo) { + return tabInfo.mode.name == "chat" ? 1 : 0; + }, + open(where) { + return openTab("chat", {}, where); + }, + }, + { + name: "settings", + button: document.getElementById("settingsButton"), + menuitem: document.getElementById("spacesPopupButtonSettings"), + tabInSpace(tabInfo) { + switch (tabInfo.mode.name) { + case "preferencesTab": + // A primary tab that the open method creates. + return 1; + case "contentTab": + let url = tabInfo.urlbar?.value; + if (url == "about:accountsettings" || url == "about:addons") { + // A secondary tab, that is related to this space. + return 2; + } + } + return 0; + }, + open(where) { + return openTab("preferencesTab", {}, where); + }, + }, + ]; + + this.setupEventListeners(); + this.toggleToolbar( + Services.xulStore.getValue(this.docURL, "spacesToolbar", "hidden") == + "true" + ); + + // The tab monitor will inform us when a different tab is selected. + tabmail.registerTabMonitor(this.tabMonitor); + + this.customizePanel = document.getElementById( + "spacesToolbarCustomizationPanel" + ); + this.loadCustomization(); + + this.isLoaded = true; + window.dispatchEvent(new CustomEvent("spaces-toolbar-ready")); + // Update the window UI after the spaces toolbar has been loaded. + this.updateUI(); + }, + + setupEventListeners() { + this.element.addEventListener("contextmenu", event => + this._showContextMenu(event) + ); + this.element.addEventListener("keydown", event => { + this._onSpacesToolbarKeyDown(event); + }); + + // Prevent buttons from stealing the focus on click since the focus is + // handled when a specific tab is opened or switched to. + for (let button of document.querySelectorAll(".spaces-toolbar-button")) { + button.onmousedown = event => event.preventDefault(); + } + + let tabmail = document.getElementById("tabmail"); + let contextMenu = document.getElementById("spacesContextMenu"); + let newTabItem = document.getElementById("spacesContextNewTabItem"); + let newWindowItem = document.getElementById("spacesContextNewWindowItem"); + let separator = document.getElementById("spacesContextMenuSeparator"); + + // The space that we (last) opened the context menu for, which we share + // between methods. + let contextSpace; + newTabItem.addEventListener("command", () => contextSpace.open("tab")); + newWindowItem.addEventListener("command", () => + contextSpace.open("window") + ); + + let settingsContextMenu = document.getElementById("settingsContextMenu"); + document + .getElementById("settingsContextOpenSettingsItem") + .addEventListener("command", () => openTab("preferencesTab", {})); + document + .getElementById("settingsContextOpenAccountSettingsItem") + .addEventListener("command", () => + openTab("contentTab", { url: "about:accountsettings" }) + ); + document + .getElementById("settingsContextOpenAddonsItem") + .addEventListener("command", () => + openTab("contentTab", { url: "about:addons" }) + ); + document + .getElementById("settingsContextOpenCustomizeItem") + .addEventListener("command", () => this.showCustomize()); + + for (let space of this.spaces) { + this._addButtonClickListener(space.button, () => { + this.openSpace(tabmail, space); + }); + space.menuitem?.addEventListener("command", () => { + this.openSpace(tabmail, space); + }); + if (space.name == "settings") { + space.button.addEventListener("contextmenu", event => { + event.stopPropagation(); + settingsContextMenu.openPopupAtScreen( + event.screenX, + event.screenY, + true, + event + ); + }); + continue; + } + space.button.addEventListener("contextmenu", event => { + event.stopPropagation(); + contextSpace = space; + // Clean up old items. + for (let menuitem of contextMenu.querySelectorAll(".switch-to-tab")) { + menuitem.remove(); + } + + let existingTabs = tabmail.tabInfo.filter(space.tabInSpace); + // Show opening in new tab if no existing tabs or can open multiple. + // NOTE: We always show at least one item: either the switch to tab + // items, or the new tab item. + newTabItem.hidden = !!existingTabs.length && !space.allowMultipleTabs; + newWindowItem.hidden = !space.allowMultipleTabs; + + for (let tabInfo of existingTabs) { + let menuitem = document.createXULElement("menuitem"); + document.l10n.setAttributes( + menuitem, + "spaces-context-switch-tab-item", + { tabName: tabInfo.title } + ); + menuitem.classList.add("switch-to-tab", "menuitem-iconic"); + menuitem.addEventListener("command", () => + tabmail.switchToTab(tabInfo) + ); + contextMenu.appendChild(menuitem); + } + // The separator splits the "Open in new tab" and "Open in new window" + // items from the switch-to-tab items. Only show separator if there + // are non-hidden items on both sides. + separator.hidden = !existingTabs.length || !space.allowMultipleTabs; + + contextMenu.openPopupAtScreen( + event.screenX, + event.screenY, + true, + event + ); + }); + } + + this._addButtonClickListener( + document.getElementById("collapseButton"), + () => this.toggleToolbar(true) + ); + + document + .getElementById("spacesPopupButtonReveal") + .addEventListener("command", () => { + this.toggleToolbar(false); + }); + this._addButtonClickListener( + document.getElementById("spacesToolbarAddonsOverflowButton"), + event => this.openSpacesToolbarAddonsPopup(event) + ); + + // Allow opening the pinned menu with Space or Enter keypress. + document + .getElementById("spacesPinnedButton") + .addEventListener("keypress", event => { + // Don't show the panel if the window is in customization mode. + if ( + document.getElementById("toolbar-menubar").hasAttribute("customizing") + ) { + return; + } + + if (event.key == " " || event.key == "Enter") { + let panel = document.getElementById("spacesButtonMenuPopup"); + if (panel.state == "open") { + panel.hidePopup(); + } else if (panel.state == "closed") { + panel.openPopup(event.target, "after_start"); + } + } + }); + }, + + /** + * Handle the keypress event on the spaces toolbar. + * + * @param {Event} event - The keypress DOMEvent. + */ + _onSpacesToolbarKeyDown(event) { + if ( + !["ArrowUp", "ArrowDown", "Home", "End", " ", "Enter"].includes(event.key) + ) { + return; + } + + // NOTE: Normally a button click handler would cover Enter and Space key + // events, however we need to prevent the default behavior and explicitly + // trigger the button click because in some tabs XUL keys or Window event + // listeners are attached to this keys triggering specific actions. + // TODO: Remove once we have a properly mapped global shortcut object not + // relying on XUL keys. + if (event.key == " " || event.key == "Enter") { + event.preventDefault(); + event.target.click(); + return; + } + + // Collect all currently visible buttons of the spaces toolbar. + let buttons = [ + ...document.querySelectorAll(".spaces-toolbar-button:not([hidden])"), + ]; + let elementIndex = buttons.indexOf(this.focusButton); + + // Find the adjacent focusable element based on the pressed key. + switch (event.key) { + case "ArrowUp": + elementIndex--; + if (elementIndex == -1) { + elementIndex = buttons.length - 1; + } + break; + + case "ArrowDown": + elementIndex++; + if (elementIndex > buttons.length - 1) { + elementIndex = 0; + } + break; + + case "Home": + elementIndex = 0; + break; + + case "End": + elementIndex = buttons.length - 1; + break; + } + + this.setFocusButton(buttons[elementIndex], true); + }, + + /** + * Move the focus to a new toolbar button and update the tabindex attribute. + * + * @param {HTMLElement} buttonToFocus - The new button to receive focus. + * @param {boolean} [forceFocus=false] - Whether to force the focus to move + * onto the new button, otherwise focus will only move if the previous + * focusButton had focus. + */ + setFocusButton(buttonToFocus, forceFocus = false) { + let prevHadFocus = false; + if (buttonToFocus != this.focusButton) { + prevHadFocus = document.activeElement == this.focusButton; + this.focusButton.tabIndex = -1; + this.focusButton = buttonToFocus; + buttonToFocus.tabIndex = 0; + } + // Only move the focus if the currently focused button was the active + // element. + if (forceFocus || prevHadFocus) { + buttonToFocus.focus(); + } + }, + + /** + * Add a click event listener to a spaces toolbar button. + * + * This method will insert focus controls for when the button is clicked. + * + * @param {HTMLButtonElement} button - A button that belongs to the spaces + * toolbar. + * @param {Function} listener - An event listener to call when the button's + * click event is fired. + */ + _addButtonClickListener(button, listener) { + button.addEventListener("click", event => { + // Since the button may have tabIndex = -1, we must manually move the + // focus into the button and set it as the focusButton. + // NOTE: We do *not* force the focus to move onto the button if it is not + // currently already within the spaces toolbar. This is mainly to avoid + // changing the document.activeElement before the tab's lastActiveElement + // is set in tabmail.js. + // NOTE: We do this before activating the button, which may move the focus + // elsewhere, such as into a space. + this.setFocusButton(button); + listener(event); + }); + }, + + /** + * Open a space by creating a new tab or switching to an existing tab. + * + * @param {XULElement} tabmail - The tabmail element. + * @param {SpaceInfo} space - The space to open. + */ + openSpace(tabmail, space) { + // Find the earliest primary tab that belongs to this space. + let existing = tabmail.tabInfo.find( + tabInfo => space.tabInSpace(tabInfo) == 1 + ); + if (!existing) { + return space.open("tab"); + } else if (this.currentSpace != space) { + // Only switch to the tab if it is in a different space to the + // current one. In particular, if we are in a later tab we won't + // switch to the earliest tab. + tabmail.switchToTab(existing); + return existing; + } + return tabmail.currentTabInfo; + }, + + /** + * Open a popup context menu at the location of the right on the toolbar. + * + * @param {DOMEvent} event - The click event. + */ + _showContextMenu(event) { + document + .getElementById("spacesToolbarContextMenu") + .openPopupAtScreen(event.screenX, event.screenY, true); + }, + + /** + * Load the saved customization from the xulStore, if we have any. + */ + async loadCustomization() { + let xulStore = Services.xulStore; + if (xulStore.hasValue(this.docURL, "spacesToolbar", "colors")) { + this.customizeData = JSON.parse( + xulStore.getValue(this.docURL, "spacesToolbar", "colors") + ); + this.updateCustomization(); + } + }, + + /** + * Reset the colors shown on the button colors to the default state to remove + * any previously applied custom color. + */ + _resetColorInputs() { + // Update colors with the current values. If we don't have any customization + // data, we fetch the current colors from the DOM elements. + // IMPORTANT! Always clear the onchange method before setting a new value + // since this method might be called after the popup is already opened. + let bgButton = document.getElementById("spacesBackgroundColor"); + bgButton.onchange = null; + bgButton.value = + this.customizeData.background || + this._rgbToHex(getComputedStyle(this.element).backgroundColor); + bgButton.onchange = event => { + this.customizeData.background = event.target.value; + this.updateCustomization(); + }; + + let iconButton = document.getElementById("spacesIconsColor"); + iconButton.onchange = null; + iconButton.value = + this.customizeData.color || + this._rgbToHex( + getComputedStyle( + document.querySelector(".spaces-toolbar-button:not(.current)") + ).color + ); + iconButton.onchange = event => { + this.customizeData.color = event.target.value; + this.updateCustomization(); + }; + + let accentStyle = getComputedStyle( + document.getElementById("spacesAccentPlaceholder") + ); + let accentBgButton = document.getElementById("spacesAccentBgColor"); + accentBgButton.onchange = null; + accentBgButton.value = + this.customizeData.accentBackground || + this._rgbToHex(accentStyle.backgroundColor); + accentBgButton.onchange = event => { + this.customizeData.accentBackground = event.target.value; + this.updateCustomization(); + }; + + let accentFgButton = document.getElementById("spacesAccentTextColor"); + accentFgButton.onchange = null; + accentFgButton.value = + this.customizeData.accentColor || this._rgbToHex(accentStyle.color); + accentFgButton.onchange = event => { + this.customizeData.accentColor = event.target.value; + this.updateCustomization(); + }; + }, + + /** + * Update the color buttons to reflect the current state of the toolbar UI, + * then open the customization panel. + */ + showCustomize() { + this.isCustomizing = true; + // Reset the color inputs to be sure we're showing the correct colors. + this._resetColorInputs(); + + // Since we're forcing the panel to stay open with noautohide, we need to + // listen for the Escape keypress to maintain that usability exit point. + window.addEventListener("keypress", this.onWindowKeypress); + this.customizePanel.openPopup( + document.getElementById("collapseButton"), + "end_after", + 6, + 0, + false + ); + }, + + /** + * Listen for the keypress event on the window after the customize panel was + * opened to enable the closing on Escape. + * + * @param {Event} event - The DOM Event. + */ + onWindowKeypress(event) { + if (event.key == "Escape") { + gSpacesToolbar.customizePanel.hidePopup(); + } + }, + + /** + * Close the customization panel. + */ + closeCustomize() { + this.customizePanel.hidePopup(); + }, + + /** + * Reset all event listeners and store the custom colors. + */ + onCustomizePopupHidden() { + this.isCustomizing = false; + // Always remove the keypress event listener set on opening. + window.removeEventListener("keypress", this.onWindowKeypress); + + // Save the custom colors, or delete it if we don't have any. + if (!Object.keys(this.customizeData).length) { + Services.xulStore.removeValue(this.docURL, "spacesToolbar", "colors"); + return; + } + + Services.xulStore.setValue( + this.docURL, + "spacesToolbar", + "colors", + JSON.stringify(this.customizeData) + ); + }, + + /** + * Apply the customization to the CSS file. + */ + updateCustomization() { + let data = this.customizeData; + let style = document.documentElement.style; + + // Toolbar background color. + style.setProperty("--spaces-bg-color", data.background ?? null); + // Icons color. + style.setProperty("--spaces-button-text-color", data.color ?? null); + // Icons color for current/active buttons. + style.setProperty( + "--spaces-button-active-text-color", + data.accentColor ?? null + ); + // Background color for current/active buttons. + style.setProperty( + "--spaces-button-active-bg-color", + data.accentBackground ?? null + ); + }, + + /** + * Reset all color customizations to show the user the default UI. + */ + resetColorCustomization() { + if (!matchMedia("(prefers-reduced-motion)").matches) { + // We set an event listener for the transition of any element inside the + // toolbar so we can reset the color for the buttons only after the + // toolbar and its elements reverted to their original colors. + this.element.addEventListener( + "transitionend", + () => { + this._resetColorInputs(); + }, + { + once: true, + } + ); + } + + this.customizeData = {}; + this.updateCustomization(); + + // If the user required reduced motion, the transitionend listener will not + // work. + if (matchMedia("(prefers-reduced-motion)").matches) { + this._resetColorInputs(); + } + }, + + /** + * Toggle the spaces toolbar and toolbar buttons visibility. + * + * @param {boolean} state - The visibility state to update the elements. + */ + toggleToolbar(state) { + // Prevent the visibility change state of the spaces toolbar if we're + // currently customizing it, in order to avoid weird positioning outcomes + // with the customize popup panel. + if (this.isCustomizing) { + return; + } + + this.isHidden = state; + + // The focused element, prior to toggling. + let activeElement = document.activeElement; + + let pinnedButton = document.getElementById("spacesPinnedButton"); + pinnedButton.hidden = !state; + let revealButton = document.getElementById("spacesToolbarReveal"); + revealButton.hidden = !state; + this.element.hidden = state; + + if (state && this.element.contains(activeElement)) { + // If the toolbar is being hidden and one of its child element was + // focused, move the focus to the pinned button without changing the + // focusButton attribute of this object. + pinnedButton.focus(); + } else if ( + !state && + (activeElement == pinnedButton || activeElement == revealButton) + ) { + // If the the toolbar is being shown and the focus is on the pinned or + // reveal button, move the focus to the previously focused button. + this.focusButton?.focus(); + } + + // Update the window UI after the visibility state of the spaces toolbar + // has changed. + this.updateUI(); + }, + + /** + * Toggle the spaces toolbar from a menuitem. + */ + toggleToolbarFromMenu() { + this.toggleToolbar(!this.isHidden); + }, + + /** + * Update the addons buttons and propagate toolbar visibility to a global + * attribute. + */ + updateUI() { + // Interrupt if the spaces toolbar isn't loaded yet. + if (!this.isLoaded) { + return; + } + + let density = Services.prefs.getIntPref("mail.uidensity", 1); + switch (density) { + case 0: + this.densitySpacing = 10; + break; + case 1: + this.densitySpacing = 15; + break; + case 2: + this.densitySpacing = 20; + break; + } + + // Toggle the window attribute for those CSS selectors that need it. + if (this.isHidden) { + document.documentElement.removeAttribute("spacestoolbar"); + } else { + document.documentElement.setAttribute("spacestoolbar", "true"); + this.updateAddonButtonsUI(); + } + }, + + /** + * Reset the inline style of the various titlebars and toolbars that interact + * with the spaces toolbar. + */ + resetInlineStyle() { + document.getElementById("tabmail-tabs").removeAttribute("style"); + }, + + /** + * Update the UI based on the window sizing. + */ + onWindowResize() { + if (!this.isLoaded) { + return; + } + + this.updateUImacOS(); + this.updateAddonButtonsUI(); + }, + + /** + * Update the location of buttons added by addons based on the space available + * in the toolbar. If the number of buttons is greater than the height of the + * visible container, move those buttons inside an overflow popup. + */ + updateAddonButtonsUI() { + if (this.isHidden) { + return; + } + + let overflowButton = document.getElementById( + "spacesToolbarAddonsOverflowButton" + ); + let separator = document.getElementById("spacesPopupAddonsSeparator"); + let popup = document.getElementById("spacesToolbarAddonsPopup"); + // Bail out if we don't have any add-ons button. + if (!this.addonButtonCount) { + if (this.focusButton == overflowButton) { + this.setFocusButton( + this.element.querySelector(".spaces-toolbar-button:not([hidden])") + ); + } + overflowButton.hidden = true; + separator.collapsed = true; + popup.hidePopup(); + return; + } + + separator.collapsed = false; + // Use the first available button's height as reference, and include the gap + // defined by the UIDensity pref. + let buttonHeight = + document.querySelector(".spaces-toolbar-button").getBoundingClientRect() + .height + this.densitySpacing; + + let containerHeight = document + .getElementById("spacesToolbarAddonsContainer") + .getBoundingClientRect().height; + + // Calculate the visible threshold of add-on buttons by: + // - Multiplying the space occupied by one button for the number of the + // add-on buttons currently present. + // - Subtracting the height of the add-ons container from the height + // occupied by all add-on buttons. + // - Dividing the returned value by the height of a single button. + // Doing so we will get an integer representing how many buttons might or + // might not fit in the available area. + let threshold = Math.ceil( + (buttonHeight * this.addonButtonCount - containerHeight) / buttonHeight + ); + + // Always reset the visibility of all buttons to avoid unnecessary + // calculations when needing to reveal hidden buttons. + for (let btn of document.querySelectorAll(".spaces-addon-button[hidden]")) { + btn.hidden = false; + } + + // If we get a negative threshold, it means we have plenty of empty space + // so we don't need to do anything. + if (threshold <= 0) { + // If the overflow button was the currently focused button, move the focus + // to an arbitrary first available button. + if (this.focusButton == overflowButton) { + this.setFocusButton( + this.element.querySelector(".spaces-toolbar-button:not([hidden])") + ); + } + overflowButton.hidden = true; + popup.hidePopup(); + return; + } + + overflowButton.hidden = false; + // Hide as many buttons as needed based on the threshold value. + for (let i = 0; i <= threshold; i++) { + let btn = document.querySelector( + `.spaces-addon-button:nth-last-child(${i})` + ); + if (btn) { + // If one of the hidden add-on buttons was the focused one, move the + // focus to the overflow button. + if (btn == this.focusButton) { + this.setFocusButton(overflowButton); + } + btn.hidden = true; + } + } + }, + + /** + * Update the spacesToolbar UI and adjacent tabs exclusively for macOS. This + * is necessary mostly to tackle the changes when switching fullscreen mode. + */ + updateUImacOS() { + // No need to to anything if we're not on macOS. + if (AppConstants.platform != "macosx") { + return; + } + + // Add inline styling to the tabmail tabs only if we're on macOS and the + // app is in full screen mode. + if (window.fullScreen) { + let size = this.element.getBoundingClientRect().width; + let style = `margin-inline-start: ${size}px;`; + document.getElementById("tabmail-tabs").setAttribute("style", style); + return; + } + + // Reset the style if we exited full screen mode. + this.resetInlineStyle(); + }, + + /** + * @typedef NativeButtonProperties + * @property {string} title - The text of the button tooltip and menuitem value. + * @property {string} url - The URL of the content tab to open. + * @property {Map} iconStyles - The icon styles Map. + * @property {?string} badgeText - The optional badge text. + * @property {?Map} badgeStyles - The optional badge styles Map. + */ + + /** + * Helper function for extensions in order to add buttons to the spaces + * toolbar. + * + * @param {string} id - The ID of the newly created button. + * @param {NativeButtonProperties} properties - The properties of the new button. + * + * @returns {Promise} - A Promise that resolves when the button is created. + */ + async createToolbarButton(id, properties = {}) { + return new Promise((resolve, reject) => { + if (!this.isLoaded) { + return reject("Unable to add spaces toolbar button! Toolbar not ready"); + } + if ( + !id || + !properties.title || + !properties.url || + !properties.iconStyles + ) { + return reject( + "Unable to add spaces toolbar button! Missing ID, Title, IconStyles, or space URL" + ); + } + + // Create the button. + let button = document.createElement("button"); + button.classList.add("spaces-toolbar-button", "spaces-addon-button"); + button.id = id; + button.title = properties.title; + button.tabIndex = -1; + + let badge = document.createElement("span"); + badge.classList.add("spaces-badge-container"); + button.appendChild(badge); + + let img = document.createElement("img"); + img.setAttribute("alt", ""); + button.appendChild(img); + document + .getElementById("spacesToolbarAddonsContainer") + .appendChild(button); + + // Create the menuitem. + let menuitem = document.createXULElement("menuitem"); + menuitem.classList.add( + "spaces-addon-menuitem", + "menuitem-iconic", + "spaces-popup-menuitem" + ); + menuitem.id = `${id}-menuitem`; + menuitem.label = properties.title; + document + .getElementById("spacesButtonMenuPopup") + .insertBefore( + menuitem, + document.getElementById("spacesPopupRevealSeparator") + ); + + // Set icons. The unified toolbar customization also relies on the CSS + // variables of the img. + for (let style of this.SUPPORTED_ICON_STYLES) { + if (properties.iconStyles.has(style)) { + img.style.setProperty(style, properties.iconStyles.get(style)); + menuitem.style.setProperty(style, properties.iconStyles.get(style)); + } + } + + // Add space. + gSpacesToolbar.spaces.push({ + name: id, + button, + menuitem, + url: properties.url, + isExtensionSpace: true, + tabInSpace(tabInfo) { + // TODO: Store the spaceButtonId in the XULStore (or somewhere), so the + // space is recognized after a restart. Or force closing of all spaces + // on shutdown. + return tabInfo.spaceButtonId == this.name ? 1 : 0; + }, + open(where) { + // The check if we should switch to an existing tab in this space was + // done in openSpace() and this function here should always open a new + // tab and not switch to a tab which might have loaded the same url, + // but belongs to a different space. + let tab = openTab( + "contentTab", + { url: this.url, duplicate: true }, + where + ); + tab.spaceButtonId = this.name; + // TODO: Make sure the spaceButtonId is set during load, and not here, + // where it might be too late. + gSpacesToolbar.currentSpace = this; + button.classList.add("current"); + return tab; + }, + }); + + // Set click actions. + let tabmail = document.getElementById("tabmail"); + this._addButtonClickListener(button, () => { + let space = gSpacesToolbar.spaces.find(space => space.name == id); + this.openSpace(tabmail, space); + }); + menuitem.addEventListener("command", () => { + let space = gSpacesToolbar.spaces.find(space => space.name == id); + this.openSpace(tabmail, space); + }); + + // Set badge. + if (properties.badgeText) { + button.classList.add("has-badge"); + badge.textContent = properties.badgeText; + } + + if (properties.badgeStyles) { + for (let style of this.SUPPORTED_BADGE_STYLES) { + if (properties.badgeStyles.has(style)) { + badge.style.setProperty(style, properties.badgeStyles.get(style)); + } + } + } + + this.updateAddonButtonsUI(); + return resolve(); + }); + }, + + /** + * Helper function for extensions in order to update buttons previously added + * to the spaces toolbar. + * + * @param {string} id - The ID of the button that needs to be updated. + * @param {NativeButtonProperties} properties - The new properties of the button. + * Not specifying the optional badgeText or badgeStyles will remove them. + * + * @returns {Promise} - A promise that resolves when the button is updated. + */ + async updateToolbarButton(id, properties = {}) { + return new Promise((resolve, reject) => { + if ( + !id || + !properties.title || + !properties.url || + !properties.iconStyles + ) { + return reject( + "Unable to update spaces toolbar button! Missing ID, Title, IconsStyles, or space URL" + ); + } + + let button = document.getElementById(`${id}`); + let menuitem = document.getElementById(`${id}-menuitem`); + if (!button || !menuitem) { + return reject( + "Unable to update spaces toolbar button! Button or menuitem don't exist" + ); + } + + button.title = properties.title; + menuitem.label = properties.title; + + // Update icons. + let img = button.querySelector("img"); + for (let style of this.SUPPORTED_ICON_STYLES) { + let value = properties.iconStyles.get(style); + img.style.setProperty(style, value ?? null); + menuitem.style.setProperty(style, value ?? null); + } + + // Update url. + let space = gSpacesToolbar.spaces.find(space => space.name == id); + if (space.url != properties.url) { + // TODO: Reload the space, when the url is changed (or close and re-open + // the tab). + space.url = properties.url; + } + + // Update badge. + let badge = button.querySelector(".spaces-badge-container"); + if (properties.badgeText) { + button.classList.add("has-badge"); + badge.textContent = properties.badgeText; + } else { + button.classList.remove("has-badge"); + badge.textContent = ""; + } + + for (let style of this.SUPPORTED_BADGE_STYLES) { + badge.style.setProperty( + style, + properties.badgeStyles?.get(style) ?? null + ); + } + + return resolve(); + }); + }, + + /** + * Helper function for extensions allowing the removal of previously created + * buttons. + * + * @param {string} id - The ID of the button that needs to be removed. + * @returns {Promise} - A promise that resolves when the button is removed. + */ + async removeToolbarButton(id) { + return new Promise((resolve, reject) => { + if (!this.isLoaded) { + return reject( + "Unable to remove spaces toolbar button! Toolbar not ready" + ); + } + if (!id) { + return reject("Unable to remove spaces toolbar button! Missing ID"); + } + + let button = document.getElementById(`${id}`); + // If the button being removed is the currently focused one, move the + // focus on an arbitrary first available spaces button. + if (this.focusButton == button) { + this.setFocusButton( + this.element.querySelector(".spaces-toolbar-button:not([hidden])") + ); + } + + button?.remove(); + document.getElementById(`${id}-menuitem`)?.remove(); + + let space = gSpacesToolbar.spaces.find(space => space.name == id); + let tabmail = document.getElementById("tabmail"); + let existing = tabmail.tabInfo.find( + tabInfo => space.tabInSpace(tabInfo) == 1 + ); + if (existing) { + tabmail.closeTab(existing); + } + + gSpacesToolbar.spaces = gSpacesToolbar.spaces.filter(e => e.name != id); + this.updateAddonButtonsUI(); + + return resolve(); + }); + }, + + /** + * Populate the overflow container with a copy of all the currently hidden + * buttons generated by add-ons. + * + * @param {DOMEvent} event - The DOM click event. + */ + openSpacesToolbarAddonsPopup(event) { + let popup = document.getElementById("spacesToolbarAddonsPopup"); + + for (let button of document.querySelectorAll( + ".spaces-addon-button[hidden]" + )) { + let menuitem = document.createXULElement("menuitem"); + menuitem.classList.add("menuitem-iconic", "spaces-popup-menuitem"); + menuitem.label = button.title; + + let img = button.querySelector("img"); + for (let style of this.SUPPORTED_ICON_STYLES) { + menuitem.style.setProperty( + style, + img.style.getPropertyValue(style) ?? null + ); + } + + menuitem.addEventListener("command", () => button.click()); + popup.appendChild(menuitem); + } + + popup.openPopup(event.target, "after_start", 0, 0); + }, + + /** + * Empty the overflow container. + */ + spacesToolbarAddonsPopupClosed() { + document.getElementById("spacesToolbarAddonsPopup").replaceChildren(); + }, + + /** + * Copy the badges from the contained menu items to the pinned button. + * Should be called whenever one of the menu item's badge state changes. + */ + updatePinnedBadgeState() { + let hasBadge = Boolean( + document.querySelector("#spacesButtonMenuPopup .has-badge") + ); + let spacesPinnedButton = document.getElementById("spacesPinnedButton"); + spacesPinnedButton.classList.toggle("has-badge", hasBadge); + }, + + /** + * Save the preferred state when the app is closed. + */ + onUnload() { + Services.xulStore.setValue( + this.docURL, + "spacesToolbar", + "hidden", + this.isHidden + ); + }, +}; diff --git a/comm/mail/base/content/spacesToolbarPin.inc.xhtml b/comm/mail/base/content/spacesToolbarPin.inc.xhtml new file mode 100644 index 0000000000..95b0389561 --- /dev/null +++ b/comm/mail/base/content/spacesToolbarPin.inc.xhtml @@ -0,0 +1,46 @@ +# 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/. + +<toolbarbutton id="spacesPinnedButton" + type="menu" + wantdropmarker="false" + class="button toolbar-button" + data-l10n-id="spaces-toolbar-pinned-tab-button" + hidden="true" + badged="true" + tabindex="0"> + <menupopup id="spacesButtonMenuPopup"> + <menuitem id="spacesPopupButtonMail" + data-l10n-id="spaces-pinned-button-menuitem-mail2" + data-l10n-attrs="acceltext" + class="menuitem-iconic spaces-popup-menuitem"/> + <menuitem id="spacesPopupButtonAddressBook" + data-l10n-id="spaces-pinned-button-menuitem-address-book2" + data-l10n-attrs="acceltext" + class="menuitem-iconic spaces-popup-menuitem"/> + <menuitem id="spacesPopupButtonCalendar" + data-l10n-id="spaces-pinned-button-menuitem-calendar2" + data-l10n-attrs="acceltext" + class="menuitem-iconic spaces-popup-menuitem"/> + <menuitem id="spacesPopupButtonTasks" + data-l10n-id="spaces-pinned-button-menuitem-tasks2" + data-l10n-attrs="acceltext" + class="menuitem-iconic spaces-popup-menuitem"/> + <menuitem id="spacesPopupButtonChat" + data-l10n-id="spaces-pinned-button-menuitem-chat2" + data-l10n-attrs="acceltext" + class="menuitem-iconic spaces-popup-menuitem"/> + <menuseparator id="spacesPopupSettingsSeparator"/> + <menuitem id="spacesPopupButtonSettings" + data-l10n-id="spaces-pinned-button-menuitem-settings2" + data-l10n-attrs="acceltext" + class="menuitem-iconic spaces-popup-menuitem"/> + <menuseparator id="spacesPopupAddonsSeparator" collapsed="true"/> + <menuseparator id="spacesPopupRevealSeparator"/> + <menuitem id="spacesPopupButtonReveal" + data-l10n-id="spaces-pinned-button-menuitem-show" + data-l10n-attrs="acceltext" + class="menuitem-iconic spaces-popup-menuitem"/> + </menupopup> +</toolbarbutton> diff --git a/comm/mail/base/content/specialTabs.js b/comm/mail/base/content/specialTabs.js new file mode 100644 index 0000000000..15a163c981 --- /dev/null +++ b/comm/mail/base/content/specialTabs.js @@ -0,0 +1,1320 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozElements, openOptionsDialog */ + +/* import-globals-from utilityOverlay.js */ + +/* globals ZoomManager */ // From viewZoomOverlay.js +/* globals PrintUtils */ // From printUtils.js + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +var { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" +); +var { MailE10SUtils } = ChromeUtils.import( + "resource:///modules/MailE10SUtils.jsm" +); + +function tabProgressListener(aTab, aStartsBlank) { + this.mTab = aTab; + this.mBrowser = aTab.browser; + this.mBlank = aStartsBlank; + this.mProgressListener = null; +} + +tabProgressListener.prototype = { + mTab: null, + mBrowser: null, + mBlank: null, + mProgressListener: null, + + // cache flags for correct status bar update after tab switching + mStateFlags: 0, + mStatus: 0, + mMessage: "", + + // count of open requests (should always be 0 or 1) + mRequestCount: 0, + + addProgressListener(aProgressListener) { + this.mProgressListener = aProgressListener; + }, + + onProgressChange( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ) { + if (this.mProgressListener) { + this.mProgressListener.onProgressChange( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ); + } + }, + onProgressChange64( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ) { + if (this.mProgressListener) { + this.mProgressListener.onProgressChange64( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ); + } + }, + onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { + if (this.mProgressListener) { + this.mProgressListener.onLocationChange( + aWebProgress, + aRequest, + aLocationURI, + aFlags + ); + } + // onLocationChange is called for both the top-level content + // and the subframes. + if (aWebProgress.isTopLevel) { + // Don't clear the favicon if this onLocationChange was triggered + // by a pushState or a replaceState. See bug 550565. + if ( + aWebProgress.isLoadingDocument && + !(aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) && + !(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) + ) { + this.mTab.favIconUrl = null; + } + + var location = aLocationURI ? aLocationURI.spec : ""; + if (aLocationURI && !aLocationURI.schemeIs("about")) { + this.mTab.backButton.disabled = !this.mBrowser.canGoBack; + this.mTab.forwardButton.disabled = !this.mBrowser.canGoForward; + this.mTab.urlbar.value = location; + this.mTab.root.removeAttribute("collapsed"); + } else { + this.mTab.root.setAttribute("collapsed", "false"); + } + + // Although we're unlikely to be loading about:blank, we'll check it + // anyway just in case. The second condition is for new tabs, otherwise + // the reload function is enabled until tab is refreshed. + this.mTab.reloadEnabled = !( + (location == "about:blank" && !this.mBrowser.browsingContext.opener) || + location == "" + ); + } + }, + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + if (this.mProgressListener) { + this.mProgressListener.onStateChange( + aWebProgress, + aRequest, + aStateFlags, + aStatus + ); + } + + if (!aRequest) { + return; + } + + let tabmail = document.getElementById("tabmail"); + + if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.mRequestCount++; + } else if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + // Since we (try to) only handle STATE_STOP of the last request, + // the count of open requests should now be 0. + this.mRequestCount = 0; + } + + if ( + aStateFlags & Ci.nsIWebProgressListener.STATE_START && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ) { + if (!this.mBlank) { + this.mTab.title = specialTabs.contentTabType.loadingTabString; + this.mTab.securityIcon.setLoading(true); + tabmail.setTabBusy(this.mTab, true); + tabmail.setTabTitle(this.mTab); + } + + // Set our unit testing variables accordingly + this.mTab.pageLoading = true; + this.mTab.pageLoaded = false; + } else if ( + aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK + ) { + this.mBlank = false; + this.mTab.securityIcon.setLoading(false); + tabmail.setTabBusy(this.mTab, false); + this.mTab.title = this.mTab.browser.contentTitle; + tabmail.setTabTitle(this.mTab); + + // Set our unit testing variables accordingly + this.mTab.pageLoading = false; + this.mTab.pageLoaded = true; + + // If we've finished loading, and we've not had an icon loaded from a + // link element, then we try using the default icon for the site. + if (aWebProgress.isTopLevel && !this.mTab.favIconUrl) { + specialTabs.useDefaultFavIcon(this.mTab); + } + } + }, + onStatusChange(aWebProgress, aRequest, aStatus, aMessage) { + if (this.mProgressListener) { + this.mProgressListener.onStatusChange( + aWebProgress, + aRequest, + aStatus, + aMessage + ); + } + }, + onSecurityChange(aWebProgress, aRequest, aState) { + if (this.mProgressListener) { + this.mProgressListener.onSecurityChange(aWebProgress, aRequest, aState); + } + + const wpl = Ci.nsIWebProgressListener; + const wpl_security_bits = + wpl.STATE_IS_SECURE | wpl.STATE_IS_BROKEN | wpl.STATE_IS_INSECURE; + let level = ""; + switch (aState & wpl_security_bits) { + case wpl.STATE_IS_SECURE: + level = "high"; + break; + case wpl.STATE_IS_BROKEN: + level = "broken"; + break; + } + this.mTab.securityIcon.setSecurityLevel(level); + }, + onContentBlockingEvent(aWebProgress, aRequest, aEvent) { + if (this.mProgressListener) { + this.mProgressListener.onContentBlockingEvent( + aWebProgress, + aRequest, + aEvent + ); + } + }, + onRefreshAttempted(aWebProgress, aURI, aDelay, aSameURI) { + if (this.mProgressListener) { + return this.mProgressListener.onRefreshAttempted( + aWebProgress, + aURI, + aDelay, + aSameURI + ); + } + return true; + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsIWebProgressListener2", + "nsISupportsWeakReference", + ]), +}; + +/** + * Handles tab icons for parent process browsers. The DOMLinkAdded event won't + * fire for child process browsers, that is handled by LinkHandlerParent. + */ +var DOMLinkHandler = { + handleEvent(event) { + switch (event.type) { + case "DOMLinkAdded": + case "DOMLinkChanged": + this.onLinkAdded(event); + break; + } + }, + onLinkAdded(event) { + let link = event.target; + let rel = link.rel && link.rel.toLowerCase(); + if (!link || !link.ownerDocument || !rel || !link.href) { + return; + } + + if (rel.split(/\s+/).includes("icon")) { + if (!Services.prefs.getBoolPref("browser.chrome.site_icons")) { + return; + } + + let targetDoc = link.ownerDocument; + + let uri = Services.io.newURI(link.href, targetDoc.characterSet); + + // Verify that the load of this icon is legal. + // Some error or special pages can load their favicon. + // To be on the safe side, only allow chrome:// favicons. + let isAllowedPage = + targetDoc.documentURI == "about:home" || + ["about:neterror?", "about:blocked?", "about:certerror?"].some( + function (aStart) { + targetDoc.documentURI.startsWith(aStart); + } + ); + + if (!isAllowedPage || !uri.schemeIs("chrome")) { + // Be extra paraniod and just make sure we're not going to load + // something we shouldn't. Firefox does this, so we're doing the same. + try { + Services.scriptSecurityManager.checkLoadURIWithPrincipal( + targetDoc.nodePrincipal, + uri, + Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT + ); + } catch (ex) { + return; + } + } + + try { + var contentPolicy = Cc[ + "@mozilla.org/layout/content-policy;1" + ].getService(Ci.nsIContentPolicy); + } catch (e) { + // Refuse to load if we can't do a security check. + return; + } + + // Security says okay, now ask content policy. This is probably trying to + // ensure that the image loaded always obeys the content policy. There + // may have been a chance that it was cached and we're trying to load it + // direct from the cache and not the normal route. + let { NetUtil } = ChromeUtils.import( + "resource://gre/modules/NetUtil.jsm" + ); + let tmpChannel = NetUtil.newChannel({ + uri, + loadingNode: targetDoc, + securityFlags: Ci.nsILoadInfo.SEC_ONLY_FOR_EXPLICIT_CONTENTSEC_CHECK, + contentPolicyType: Ci.nsIContentPolicy.TYPE_IMAGE, + }); + let tmpLoadInfo = tmpChannel.loadInfo; + if ( + contentPolicy.shouldLoad(uri, tmpLoadInfo, link.type) != + Ci.nsIContentPolicy.ACCEPT + ) { + return; + } + + let tab = document + .getElementById("tabmail") + .getBrowserForDocument(targetDoc.defaultView); + + // If we don't have a browser/tab, then don't load the icon. + if (!tab) { + return; + } + + // Just set the url on the browser and we'll display the actual icon + // when we finish loading the page. + specialTabs.setFavIcon(tab, link.href); + } + }, +}; + +var contentTabBaseType = { + // List of URLs that will receive special treatment when opened in a tab. + // Note that about:preferences is loaded via a different mechanism. + inContentWhitelist: [ + "about:addons", + "about:addressbook", + "about:blank", + "about:profiles", + "about:*", + ], + + // Code to run if a particular document is loaded in a tab. + // The array members (functions) are for the respective document URLs + // as specified in inContentWhitelist. + inContentOverlays: [ + // about:addons + function (aDocument, aTab) { + Services.scriptloader.loadSubScript( + "chrome://messenger/content/aboutAddonsExtra.js", + aDocument.defaultView + ); + }, + + // about:addressbook provides its own context menu. + function (aDocument, aTab) { + aTab.browser.removeAttribute("context"); + }, + + // Let's not mess with about:blank. + null, + + // about:profiles + function (aDocument, aTab) { + let win = aDocument.defaultView; + // Need a timeout to let the script run to create the needed buttons. + win.setTimeout(() => { + win.MozXULElement.insertFTLIfNeeded("messenger/aboutProfilesExtra.ftl"); + for (let button of aDocument.querySelectorAll( + `[data-l10n-id="profiles-launch-profile"]` + )) { + win.document.l10n.setAttributes( + button, + "profiles-launch-profile-plain" + ); + } + }, 500); + }, + + // Other about:* pages. + function (aDocument, aTab) { + // Provide context menu for about:* pages. + aTab.browser.setAttribute("context", "aboutPagesContext"); + }, + ], + + shouldSwitchTo({ url, duplicate }) { + if (duplicate) { + return -1; + } + + let tabmail = document.getElementById("tabmail"); + let tabInfo = tabmail.tabInfo; + let uri; + + try { + uri = Services.io.newURI(url); + } catch (ex) { + return -1; + } + + for ( + let selectedIndex = 0; + selectedIndex < tabInfo.length; + ++selectedIndex + ) { + // Reuse the same tab, if only the anchors differ - especially for the + // about: pages, we just want to re-use the same tab. + if ( + tabInfo[selectedIndex].mode.name == this.name && + tabInfo[selectedIndex].browser.currentURI?.specIgnoringRef == + uri.specIgnoringRef + ) { + // Go to the correct location on the page, but only if it's not the + // current location. This should NOT cause the page to reload. + if (tabInfo[selectedIndex].browser.currentURI.spec != uri.spec) { + MailE10SUtils.loadURI(tabInfo[selectedIndex].browser, uri.spec); + } + return selectedIndex; + } + } + return -1; + }, + + closeTab(aTab) { + aTab.browser.removeEventListener( + "pagetitlechanged", + aTab.titleListener, + true + ); + aTab.browser.removeEventListener( + "DOMWindowClose", + aTab.closeListener, + true + ); + aTab.browser.removeEventListener("DOMLinkAdded", DOMLinkHandler); + aTab.browser.removeEventListener("DOMLinkChanged", DOMLinkHandler); + aTab.browser.webProgress.removeProgressListener(aTab.filter); + aTab.filter.removeProgressListener(aTab.progressListener); + aTab.browser.destroy(); + }, + + saveTabState(aTab) { + aTab.browser.setAttribute("type", "content"); + aTab.browser.removeAttribute("primary"); + }, + + showTab(aTab) { + aTab.browser.setAttribute("type", "content"); + aTab.browser.setAttribute("primary", "true"); + if (aTab.browser.currentURI.spec.startsWith("about:preferences")) { + aTab.browser.contentDocument.documentElement.focus(); + } + }, + + getBrowser(aTab) { + return aTab.browser; + }, + + _setUpLoadListener(aTab) { + let self = this; + + function onLoad(aEvent) { + let doc = aEvent.target; + let url = doc.defaultView.location.href; + + // If this document has an overlay defined, run it now. + let ind = self.inContentWhitelist.indexOf(url); + if (ind < 0) { + // Try a wildcard. + ind = self.inContentWhitelist.indexOf(url.replace(/:.*/, ":*")); + } + if (ind >= 0) { + let overlayFunction = self.inContentOverlays[ind]; + if (overlayFunction) { + overlayFunction(doc, aTab); + } + } + } + + aTab.loadListener = onLoad; + aTab.browser.addEventListener("load", aTab.loadListener, true); + }, + + // Internal function used to set up the title listener on a content tab. + _setUpTitleListener(aTab) { + function onDOMTitleChanged(aEvent) { + aTab.title = aTab.browser.contentTitle; + document.getElementById("tabmail").setTabTitle(aTab); + } + // Save the function we'll use as listener so we can remove it later. + aTab.titleListener = onDOMTitleChanged; + // Add the listener. + aTab.browser.addEventListener("pagetitlechanged", aTab.titleListener, true); + }, + + /** + * Internal function used to set up the close window listener on a content + * tab. + */ + _setUpCloseWindowListener(aTab) { + function onDOMWindowClose(aEvent) { + if (!aEvent.isTrusted) { + return; + } + + // Redirect any window.close events to closing the tab. As a 3-pane tab + // must be open, we don't need to worry about being the last tab open. + document.getElementById("tabmail").closeTab(aTab); + aEvent.preventDefault(); + } + // Save the function we'll use as listener so we can remove it later. + aTab.closeListener = onDOMWindowClose; + // Add the listener. + aTab.browser.addEventListener("DOMWindowClose", aTab.closeListener, true); + }, + + supportsCommand(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + case "cmd_fullZoomToggle": + case "cmd_find": + case "cmd_findAgain": + case "cmd_findPrevious": + case "cmd_print": + case "button_print": + case "cmd_stop": + case "cmd_reload": + case "Browser:Back": + case "Browser:Forward": + return true; + default: + return false; + } + }, + + isCommandEnabled(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + case "cmd_fullZoomToggle": + case "cmd_find": + case "cmd_findAgain": + case "cmd_findPrevious": + return true; + case "cmd_print": + case "button_print": { + let uri = aTab.browser?.currentURI; + if (!uri || !uri.schemeIs("about")) { + return true; + } + return [ + "addressbook", + "certificate", + "crashes", + "credits", + "license", + "profiles", + "support", + "telemetry", + ].includes(uri.filePath); + } + case "cmd_reload": + return aTab.reloadEnabled; + case "cmd_stop": + return aTab.busy; + case "Browser:Back": + return aTab.browser?.canGoBack; + case "Browser:Forward": + return aTab.browser?.canGoForward; + default: + return false; + } + }, + + doCommand(aCommand, aTab) { + switch (aCommand) { + case "cmd_fullZoomReduce": + ZoomManager.reduce(); + break; + case "cmd_fullZoomEnlarge": + ZoomManager.enlarge(); + break; + case "cmd_fullZoomReset": + ZoomManager.reset(); + break; + case "cmd_fullZoomToggle": + ZoomManager.toggleZoom(); + break; + case "cmd_find": + aTab.findbar.onFindCommand(); + break; + case "cmd_findAgain": + aTab.findbar.onFindAgainCommand(false); + break; + case "cmd_findPrevious": + aTab.findbar.onFindAgainCommand(true); + break; + case "cmd_print": + PrintUtils.startPrintWindow(this.getBrowser(aTab).browsingContext, {}); + break; + case "cmd_stop": + aTab.browser.stop(); + break; + case "cmd_reload": + aTab.browser.reload(); + break; + case "Browser:Back": + specialTabs.browserBack(); + break; + case "Browser:Forward": + specialTabs.browserForward(); + break; + } + }, +}; + +/** + * Class that wraps the content page loading/security icon. + */ +// Ideally, this could be moved into a sub-class for content tabs. +class SecurityIcon { + constructor(icon) { + this.icon = icon; + this.loading = false; + this.securityLevel = ""; + this.updateIcon(); + } + + /** + * Set whether the page is loading. + * + * @param {boolean} loading - Whether the page is loading. + */ + setLoading(loading) { + if (this.loading !== loading) { + this.loading = loading; + this.updateIcon(); + } + } + + /** + * Set the security level of the page. + * + * @param {"high"|"broken"|""} - The security level for the page, or empty if + * it is to be ignored. + */ + setSecurityLevel(securityLevel) { + if (this.securityLevel !== securityLevel) { + this.securityLevel = securityLevel; + this.updateIcon(); + } + } + + updateIcon() { + let src; + let srcSet; + let l10nId; + let secure = false; + if (this.loading) { + src = "chrome://global/skin/icons/loading.png"; + srcSet = "chrome://global/skin/icons/loading@2x.png 2x"; + l10nId = "content-tab-page-loading-icon"; + } else { + switch (this.securityLevel) { + case "high": + secure = true; + src = "chrome://messenger/skin/icons/connection-secure.svg"; + l10nId = "content-tab-security-high-icon"; + break; + case "broken": + src = "chrome://messenger/skin/icons/connection-insecure.svg"; + l10nId = "content-tab-security-broken-icon"; + break; + } + } + if (srcSet) { + this.icon.setAttribute("srcset", srcSet); + } else { + this.icon.removeAttribute("srcset"); + } + if (src) { + this.icon.setAttribute("src", src); + // Set alt. + document.l10n.setAttributes(this.icon, l10nId); + } else { + this.icon.removeAttribute("src"); + this.icon.removeAttribute("data-l10n-id"); + this.icon.removeAttribute("alt"); + } + this.icon.classList.toggle("secure-connection-icon", secure); + } +} + +var specialTabs = { + _kAboutRightsVersion: 1, + get _protocolSvc() { + delete this._protocolSvc; + return (this._protocolSvc = Cc[ + "@mozilla.org/uriloader/external-protocol-service;1" + ].getService(Ci.nsIExternalProtocolService)); + }, + + get msgNotificationBar() { + if (!this._notificationBox) { + this._notificationBox = new MozElements.NotificationBox(element => { + element.setAttribute("notificationside", "bottom"); + document + .getElementById("messenger-notification-bottom") + .append(element); + }); + } + return this._notificationBox; + }, + + // This will open any special tabs if necessary on startup. + openSpecialTabsOnStartup() { + let tabmail = document.getElementById("tabmail"); + + tabmail.registerTabType(this.contentTabType); + + this.showWhatsNewPage(); + + // Show the about rights notification if we need to. + if (this.shouldShowAboutRightsNotification()) { + this.showAboutRightsNotification(); + } + if (this.shouldShowPolicyNotification()) { + // Do it on a timeout to workaround that open in background do not work when called too early. + setTimeout(this.showPolicyNotification, 10000); + } + }, + + /** + * A tab to show content pages. + */ + contentTabType: { + __proto__: contentTabBaseType, + name: "contentTab", + perTabPanel: "vbox", + lastBrowserId: 0, + get loadingTabString() { + delete this.loadingTabString; + return (this.loadingTabString = document + .getElementById("bundle_messenger") + .getString("loadingTab")); + }, + + modes: { + contentTab: { + type: "contentTab", + }, + }, + + /** + * This is the internal function used by content tabs to open a new tab. To + * open a contentTab, use specialTabs.openTab("contentTab", aArgs) + * + * @param {object} aArgs - The options that content tabs accept. + * @param {string} aArgs.url - The URL that is to be opened + * @param {nsIOpenWindowInfo} [aArgs.openWindowInfo] - The opener window + * @param {"single-site"|"single-page"|null} [aArgs.linkHandler="single-site"] + * Restricts navigation in the browser to be opened: + * - "single-site" allows only URLs in the same domain as + * aArgs.url (including subdomains). + * - "single-page" allows only URLs matching aArgs.url. + * - `null` applies no such restrictions. + * All other links are sent to an external browser. + * @param {Function} [aArgs.onLoad] - A function that takes an Event and a + * DOMNode. It is called when the content page is done loading. The + * first argument is the load event, and the second argument is the + * xul:browser that holds the page. You can access the inner tab's + * window object by accessing the second parameter's contentWindow + * property. + */ + openTab(aTab, aArgs) { + if (!("url" in aArgs)) { + throw new Error("url must be specified"); + } + + // First clone the page and set up the basics. + let clone = document + .getElementById("contentTab") + .firstElementChild.cloneNode(true); + + clone.setAttribute("id", "contentTab" + this.lastBrowserId); + clone.setAttribute("collapsed", false); + + let toolbox = clone.firstElementChild; + toolbox.setAttribute("id", "contentTabToolbox" + this.lastBrowserId); + toolbox.firstElementChild.setAttribute( + "id", + "contentTabToolbar" + this.lastBrowserId + ); + + aTab.linkedBrowser = aTab.browser = document.createXULElement("browser"); + aTab.browser.setAttribute("id", "contentTabBrowser" + this.lastBrowserId); + aTab.browser.setAttribute("type", "content"); + aTab.browser.setAttribute("flex", "1"); + aTab.browser.setAttribute("autocompletepopup", "PopupAutoComplete"); + aTab.browser.setAttribute("context", "browserContext"); + aTab.browser.setAttribute("maychangeremoteness", "true"); + aTab.browser.setAttribute("onclick", "return contentAreaClick(event);"); + aTab.browser.openWindowInfo = aArgs.openWindowInfo || null; + clone.querySelector("stack").appendChild(aTab.browser); + + if (aArgs.skipLoad) { + clone.querySelector("browser").setAttribute("nodefaultsrc", "true"); + } + if (aArgs.userContextId) { + aTab.browser.setAttribute("usercontextid", aArgs.userContextId); + } + aTab.panel.setAttribute("id", "contentTabWrapper" + this.lastBrowserId); + aTab.panel.appendChild(clone); + aTab.root = clone; + + ExtensionParent.apiManager.emit( + "extension-browser-inserted", + aTab.browser + ); + + // For pdf.js use the aboutPagesContext context menu. + if (aArgs.url.includes("type=application/pdf")) { + aTab.browser.setAttribute("context", "aboutPagesContext"); + } + + // Start setting up the browser. + aTab.toolbar = aTab.panel.querySelector(".contentTabToolbar"); + aTab.backButton = aTab.toolbar.querySelector(".back-btn"); + aTab.backButton.addEventListener("command", () => aTab.browser.goBack()); + aTab.forwardButton = aTab.toolbar.querySelector(".forward-btn"); + aTab.forwardButton.addEventListener("command", () => + aTab.browser.goForward() + ); + aTab.securityIcon = new SecurityIcon( + aTab.toolbar.querySelector(".contentTabSecurity") + ); + aTab.urlbar = aTab.toolbar.querySelector(".contentTabUrlInput"); + aTab.urlbar.value = aArgs.url; + + // As we're opening this tab, showTab may not get called, so set + // the type according to if we're opening in background or not. + let background = "background" in aArgs && aArgs.background; + if (background) { + aTab.browser.removeAttribute("primary"); + } else { + aTab.browser.setAttribute("primary", "true"); + } + + if (aArgs.linkHandler == "single-page") { + aTab.browser.setAttribute("messagemanagergroup", "single-page"); + } else if (aArgs.linkHandler === null) { + aTab.browser.setAttribute("messagemanagergroup", "browsers"); + } else { + aTab.browser.setAttribute("messagemanagergroup", "single-site"); + } + + aTab.browser.addEventListener("DOMLinkAdded", DOMLinkHandler); + aTab.browser.addEventListener("DOMLinkChanged", DOMLinkHandler); + + // Now initialise the find bar. + aTab.findbar = document.createXULElement("findbar"); + aTab.findbar.setAttribute( + "browserid", + "contentTabBrowser" + this.lastBrowserId + ); + clone.appendChild(aTab.findbar); + + // Default to reload being disabled. + aTab.reloadEnabled = false; + + // Now set up the listeners. + this._setUpLoadListener(aTab); + this._setUpTitleListener(aTab); + this._setUpCloseWindowListener(aTab); + + /** + * Override the browser custom element's version, which returns gBrowser. + */ + aTab.browser.getTabBrowser = function () { + return document.getElementById("tabmail"); + }; + + if ("onLoad" in aArgs) { + aTab.browser.addEventListener( + "load", + function _contentTab_onLoad(event) { + aArgs.onLoad(event, aTab.browser); + aTab.browser.removeEventListener("load", _contentTab_onLoad, true); + }, + true + ); + } + + // Create a filter and hook it up to our browser + let filter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + aTab.filter = filter; + aTab.browser.webProgress.addProgressListener( + filter, + Ci.nsIWebProgress.NOTIFY_ALL + ); + + // Wire up a progress listener to the filter for this browser + aTab.progressListener = new tabProgressListener(aTab, false); + + filter.addProgressListener( + aTab.progressListener, + Ci.nsIWebProgress.NOTIFY_ALL + ); + + if ("onListener" in aArgs) { + aArgs.onListener(aTab.browser, aTab.progressListener); + } + + // Initialize our unit testing variables. + aTab.pageLoading = false; + aTab.pageLoaded = false; + + // Now start loading the content. + aTab.title = this.loadingTabString; + + if (!aArgs.skipLoad) { + MailE10SUtils.loadURI(aTab.browser, aArgs.url, { + csp: aArgs.csp, + referrerInfo: aArgs.referrerInfo, + triggeringPrincipal: aArgs.triggeringPrincipal, + }); + } + + this.lastBrowserId++; + }, + tryCloseTab(aTab) { + return aTab.browser.permitUnload(); + }, + persistTab(aTab) { + if (aTab.browser.currentURI.spec == "about:blank") { + return null; + } + + // Extension pages of temporarily installed extensions cannot be restored. + if ( + aTab.browser.currentURI.scheme == "moz-extension" && + WebExtensionPolicy.getByHostname(aTab.browser.currentURI.host) + ?.temporarilyInstalled + ) { + return null; + } + + return { + tabURI: aTab.browser.currentURI.spec, + linkHandler: aTab.browser.getAttribute("messagemanagergroup"), + userContextId: `${ + aTab.browser.getAttribute("usercontextid") || + Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID + }`, + }; + }, + restoreTab(aTabmail, aPersistedState) { + let tab = aTabmail.openTab("contentTab", { + background: true, + duplicate: aPersistedState.duplicate, + linkHandler: aPersistedState.linkHandler, + url: aPersistedState.tabURI, + userContextId: aPersistedState.userContextId, + }); + + if (aPersistedState.tabURI == "about:addons") { + // Also in `openAddonsMgr` in mailCore.js. + tab.browser.droppedLinkHandler = event => + tab.browser.contentWindow.gDragDrop.onDrop(event); + } + }, + }, + + /** + * Shows the what's new page in the system browser if we should. + * Will update the mstone pref to a new version if needed. + * + * @see {BrowserContentHandler.needHomepageOverride} + */ + showWhatsNewPage() { + let old_mstone = Services.prefs.getCharPref( + "mailnews.start_page_override.mstone", + "" + ); + + let mstone = Services.appinfo.version; + if (mstone != old_mstone) { + Services.prefs.setCharPref("mailnews.start_page_override.mstone", mstone); + } + + if (AppConstants.MOZ_UPDATER) { + let update = Cc["@mozilla.org/updates/update-manager;1"].getService( + Ci.nsIUpdateManager + ).readyUpdate; + + if (update && Services.vc.compare(update.appVersion, old_mstone) > 0) { + let overridePage = Services.urlFormatter.formatURLPref( + "mailnews.start_page.override_url" + ); + overridePage = this.getPostUpdateOverridePage(update, overridePage); + overridePage = overridePage.replace("%OLD_VERSION%", old_mstone); + if (overridePage) { + openLinkExternally(overridePage); + } + } + } + }, + + /** + * Gets the override page for the first run after the application has been + * updated. + * + * @param {nsIUpdate} update - The nsIUpdate for the update that has been applied. + * @param {string} defaultOverridePage - The default override page. + * @returns {string} The override page. + */ + getPostUpdateOverridePage(update, defaultOverridePage) { + update = update.QueryInterface(Ci.nsIWritablePropertyBag); + let actions = update.getProperty("actions"); + // When the update doesn't specify actions fallback to the original behavior + // of displaying the default override page. + if (!actions) { + return defaultOverridePage; + } + + // The existence of silent or the non-existence of showURL in the actions both + // mean that an override page should not be displayed. + if (actions.includes("silent") || !actions.includes("showURL")) { + return ""; + } + + // If a policy was set to not allow the update.xml-provided + // URL to be used, use the default fallback (which will also + // be provided by the policy). + if (!Services.policies.isAllowed("postUpdateCustomPage")) { + return defaultOverridePage; + } + + return update.getProperty("openURL") || defaultOverridePage; + }, + + /** + * Looks at the existing prefs and determines if we should show the policy or not. + */ + shouldShowPolicyNotification() { + let dataSubmissionEnabled = Services.prefs.getBoolPref( + "datareporting.policy.dataSubmissionEnabled", + true + ); + let dataSubmissionPolicyBypassNotification = Services.prefs.getBoolPref( + "datareporting.policy.dataSubmissionPolicyBypassNotification", + false + ); + let dataSubmissionPolicyAcceptedVersion = Services.prefs.getIntPref( + "datareporting.policy.dataSubmissionPolicyAcceptedVersion", + 0 + ); + let currentPolicyVersion = Services.prefs.getIntPref( + "datareporting.policy.currentPolicyVersion", + 1 + ); + if ( + !AppConstants.MOZ_DATA_REPORTING || + !dataSubmissionEnabled || + dataSubmissionPolicyBypassNotification + ) { + return false; + } + if (dataSubmissionPolicyAcceptedVersion >= currentPolicyVersion) { + return false; + } + return true; + }, + + showPolicyNotification() { + try { + let firstRunURL = Services.prefs.getStringPref( + "datareporting.policy.firstRunURL" + ); + document.getElementById("tabmail").openTab("contentTab", { + background: true, + url: firstRunURL, + }); + } catch (e) { + // Show the infobar if it fails to show the privacy policy in the new tab. + this.showTelemetryNotification(); + } + let currentPolicyVersion = Services.prefs.getIntPref( + "datareporting.policy.currentPolicyVersion", + 1 + ); + Services.prefs.setIntPref( + "datareporting.policy.dataSubmissionPolicyAcceptedVersion", + currentPolicyVersion + ); + Services.prefs.setStringPref( + "datareporting.policy.dataSubmissionPolicyNotifiedTime", + new Date().getTime().toString() + ); + }, + + showTelemetryNotification() { + let brandBundle = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + let telemetryBundle = Services.strings.createBundle( + "chrome://messenger/locale/telemetry.properties" + ); + + let productName = brandBundle.GetStringFromName("brandFullName"); + let serverOwner = Services.prefs.getCharPref( + "toolkit.telemetry.server_owner" + ); + let telemetryText = telemetryBundle.formatStringFromName("telemetryText", [ + productName, + serverOwner, + ]); + + // TODO: sync up this bar with Firefox: + // https://searchfox.org/mozilla-central/rev/227f22acef5c4865503bde9f835452bf38332c8e/browser/locales/en-US/chrome/browser/browser.properties#697-698 + let buttons = [ + { + label: telemetryBundle.GetStringFromName("telemetryLinkLabel"), + popup: null, + callback: () => { + openOptionsDialog("panePrivacy", "privacyDataCollectionCategory"); + }, + }, + ]; + + let notification = this.msgNotificationBar.appendNotification( + "telemetry", + { + label: telemetryText, + priority: this.msgNotificationBar.PRIORITY_INFO_LOW, + }, + buttons + ); + // Arbitrary number, just so bar sticks around for a bit. + notification.persistence = 3; + }, + + /** + * Looks at the existing prefs and determines if we should show about:rights + * or not. + * + * This is controlled by two prefs: + * + * mail.rights.override + * If this pref is set to false, always show the about:rights + * notification. + * If this pref is set to true, never show the about:rights notification. + * If the pref doesn't exist, then we fallback to checking + * mail.rights.version. + * + * mail.rights.version + * If this pref isn't set or the value is less than the current version + * then we show the about:rights notification. + */ + shouldShowAboutRightsNotification() { + try { + return !Services.prefs.getBoolPref("mail.rights.override"); + } catch (e) {} + + return ( + Services.prefs.getIntPref("mail.rights.version") < + this._kAboutRightsVersion + ); + }, + + async showAboutRightsNotification() { + var rightsBundle = Services.strings.createBundle( + "chrome://messenger/locale/aboutRights.properties" + ); + + var buttons = [ + { + label: rightsBundle.GetStringFromName("buttonLabel"), + accessKey: rightsBundle.GetStringFromName("buttonAccessKey"), + popup: null, + callback(aNotificationBar, aButton) { + // Show the about:rights tab + document.getElementById("tabmail").openTab("contentTab", { + url: "about:rights", + }); + }, + }, + ]; + + let notifyRightsText = await document.l10n.formatValue( + "about-rights-notification-text" + ); + let notification = this.msgNotificationBar.appendNotification( + "about-rights", + { + label: notifyRightsText, + priority: this.msgNotificationBar.PRIORITY_INFO_LOW, + }, + buttons + ); + // Arbitrary number, just so bar sticks around for a bit. + notification.persistence = 3; + + // Set the pref to say we've displayed the notification. + Services.prefs.setIntPref("mail.rights.version", this._kAboutRightsVersion); + }, + + /** + * Determine if we should load fav icons or not. + * + * @param aURI An nsIURI containing the current url. + */ + _shouldLoadFavIcon(aURI) { + return ( + aURI && + Services.prefs.getBoolPref("browser.chrome.site_icons") && + Services.prefs.getBoolPref("browser.chrome.favicons") && + "schemeIs" in aURI && + (aURI.schemeIs("http") || aURI.schemeIs("https")) + ); + }, + + /** + * Tries to use the default favicon for a webpage for the specified tab. + * We'll use the site's favicon.ico if prefs allow us to. + */ + useDefaultFavIcon(aTab) { + // Use documentURI in the check for shouldLoadFavIcon so that we do the + // right thing with about:-style error pages. + let docURIObject = aTab.browser.documentURI; + let icon = null; + if (this._shouldLoadFavIcon(docURIObject)) { + icon = docURIObject.prePath + "/favicon.ico"; + } + + this.setFavIcon(aTab, icon); + }, + + /** + * This sets the specified tab to load and display the given icon for the + * page shown in the browser. It is assumed that the preferences have already + * been checked before calling this function appropriately. + * + * @param aTab The tab to set the icon for. + * @param aIcon A string based URL of the icon to try and load. + */ + setFavIcon(aTab, aIcon) { + if (aIcon) { + PlacesUtils.favicons.setAndFetchFaviconForPage( + aTab.browser.currentURI, + Services.io.newURI(aIcon), + false, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + aTab.browser.contentPrincipal + ); + } + document + .getElementById("tabmail") + .setTabFavIcon( + aTab, + aIcon, + "chrome://messenger/skin/icons/new/compact/draft.svg" + ); + }, + + browserForward() { + let tabmail = document.getElementById("tabmail"); + if ( + !["contentTab", "mail3PaneTab"].includes( + tabmail?.currentTabInfo.mode.name + ) + ) { + return; + } + let browser = tabmail.getBrowserForSelectedTab(); + if (!browser) { + return; + } + if (browser.webNavigation) { + browser.webNavigation.goForward(); + } + }, + + browserBack() { + let tabmail = document.getElementById("tabmail"); + if ( + !["contentTab", "mail3PaneTab"].includes( + tabmail?.currentTabInfo.mode.name + ) + ) { + return; + } + let browser = tabmail.getBrowserForSelectedTab(); + if (!browser) { + return; + } + if (browser.webNavigation) { + browser.webNavigation.goBack(); + } + }, +}; diff --git a/comm/mail/base/content/sync.js b/comm/mail/base/content/sync.js new file mode 100644 index 0000000000..2a53e10856 --- /dev/null +++ b/comm/mail/base/content/sync.js @@ -0,0 +1,157 @@ +/* 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/. */ + +/** + * AppMenu UI for Sync. This file is only loaded if NIGHTLY_BUILD is set. + */ + +/* import-globals-from utilityOverlay.js */ + +ChromeUtils.defineESModuleGetters(this, { + EnsureFxAccountsWebChannel: + "resource://gre/modules/FxAccountsWebChannel.sys.mjs", + FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs", + UIState: "resource://services-sync/UIState.sys.mjs", + Weave: "resource://services-sync/main.sys.mjs", +}); + +var gSync = { + handleEvent(event) { + if (event.type == "load") { + this.updateFxAPanel(); + Services.obs.addObserver(this, UIState.ON_UPDATE); + window.addEventListener("unload", this, { once: true }); + } else if (event.type == "unload") { + Services.obs.removeObserver(this, UIState.ON_UPDATE); + } + }, + + observe(subject, topic, data) { + this.updateFxAPanel(); + }, + + /** + * Update the app menu items to match the current state. + */ + updateFxAPanel() { + let state = UIState.get(); + let isSignedIn = state.status == UIState.STATUS_SIGNED_IN; + document.getElementById("appmenu_signin").hidden = isSignedIn; + document.getElementById("appmenu_sync").hidden = !isSignedIn; + document.getElementById("syncSeparator").hidden = false; + document.querySelectorAll(".appmenu-sync-account-email").forEach(el => { + el.value = state.email; + el.removeAttribute("data-l10n-id"); + }); + let button = document.getElementById("appmenu-submenu-sync-now"); + if (button) { + if (state.syncing) { + button.setAttribute("syncstatus", "active"); + } else { + button.removeAttribute("syncstatus"); + } + } + }, + + /** + * Opens the FxA log-in page in a tab. + * + * @param {string = ""} entryPoint + */ + async initFxA() { + EnsureFxAccountsWebChannel(); + let url = await FxAccounts.config.promiseConnectAccountURI(""); + openContentTab(url); + }, + + /** + * Opens the FxA account management page in a tab. + * + * @param {string = ""} entryPoint + */ + async openFxAManagePage(entryPoint = "") { + EnsureFxAccountsWebChannel(); + const url = await FxAccounts.config.promiseManageURI(entryPoint); + openContentTab(url); + }, + + /** + * Opens the FxA avatar management page in a tab. + * + * @param {string = ""} entryPoint + */ + async openFxAAvatarPage(entryPoint = "") { + EnsureFxAccountsWebChannel(); + const url = await FxAccounts.config.promiseChangeAvatarURI(entryPoint); + openContentTab(url); + }, + + /** + * Disconnect from sync, and optionally disconnect from the FxA account. + * + * @param {boolean} confirm - Should the user be asked to confirm the + * disconnection? + * @param {boolean} disconnectAccount - If true, disconnect from FxA as well + * as Sync. If false, just disconnect from Sync. + * @returns {boolean} - true if the disconnection happened (ie, if the user + * didn't decline when asked to confirm) + */ + async disconnect({ confirm = false, disconnectAccount = true }) { + if (confirm) { + let title, body, button; + if (disconnectAccount) { + [title, body, button] = await document.l10n.formatValues([ + "fxa-signout-dialog-title", + "fxa-signout-dialog-body", + "fxa-signout-dialog-button", + ]); + } else { + [title, body, button] = await document.l10n.formatValues([ + "sync-disconnect-dialog-title", + "sync-disconnect-dialog-body", + "sync-disconnect-dialog-button", + ]); + } + + let flags = + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1; + + // buttonPressed will be 0 for disconnect, 1 for cancel. + let buttonPressed = Services.prompt.confirmEx( + window, + title, + body, + flags, + button, + null, + null, + null, + {} + ); + if (buttonPressed != 0) { + return false; + } + } + + let fxAccounts = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); + + if (disconnectAccount) { + const { SyncDisconnect } = ChromeUtils.importESModule( + "resource://services-sync/SyncDisconnect.sys.mjs" + ); + await fxAccounts.telemetry.recordDisconnection(null, "ui"); + await SyncDisconnect.disconnect(false); + return true; + } + + await fxAccounts.telemetry.recordDisconnection("sync", "ui"); + await Weave.Service.promiseInitialized; + await Weave.Service.startOver(); + return true; + }, +}; +window.addEventListener("load", gSync, { once: true }); diff --git a/comm/mail/base/content/systemIntegrationDialog.js b/comm/mail/base/content/systemIntegrationDialog.js new file mode 100644 index 0000000000..41455b3db5 --- /dev/null +++ b/comm/mail/base/content/systemIntegrationDialog.js @@ -0,0 +1,188 @@ +/* -*- Mode: Javascript; 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/. */ + +// This dialog can only be opened if we have a shell service. + +var { SearchIntegration } = ChromeUtils.import( + "resource:///modules/SearchIntegration.jsm" +); + +var gSystemIntegrationDialog = { + _shellSvc: Cc["@mozilla.org/mail/shell-service;1"].getService( + Ci.nsIShellService + ), + + _mailCheckbox: null, + + _newsCheckbox: null, + + _rssCheckbox: null, + + _startupCheckbox: null, + + _searchCheckbox: null, + + onLoad() { + // initialize elements + this._mailCheckbox = document.getElementById("checkMail"); + this._newsCheckbox = document.getElementById("checkNews"); + this._rssCheckbox = document.getElementById("checkRSS"); + this._calendarCheckbox = document.getElementById("checkCalendar"); + this._startupCheckbox = document.getElementById("checkOnStartup"); + this._searchCheckbox = document.getElementById("searchIntegration"); + + // Initialize the check boxes based on the default app states. + this._mailCheckbox.disabled = this._shellSvc.isDefaultClient( + false, + this._shellSvc.MAIL + ); + + let calledFromPrefs = + "arguments" in window && window.arguments[0] == "calledFromPrefs"; + + if (!calledFromPrefs) { + // As an optimization, if we aren't already the default mail client, + // then pre-check that option for the user. We'll leave News and RSS alone. + // Do this only if we are not called from the Preferences (Options) dialog. + // In that case, the user may want to just check what the current state is. + this._mailCheckbox.checked = true; + } else { + this._mailCheckbox.checked = this._mailCheckbox.disabled; + + // If called from preferences, use only a simpler "Cancel" label on the + // cancel button. + document.querySelector("dialog").getButton("cancel").label = document + .querySelector("dialog") + .getAttribute("buttonlabelcancel2"); + } + + if (!this._mailCheckbox.disabled) { + this._mailCheckbox.removeAttribute("tooltiptext"); + } + + this._newsCheckbox.checked = this._newsCheckbox.disabled = + this._shellSvc.isDefaultClient(false, this._shellSvc.NEWS); + if (!this._newsCheckbox.disabled) { + this._newsCheckbox.removeAttribute("tooltiptext"); + } + + this._rssCheckbox.checked = this._rssCheckbox.disabled = + this._shellSvc.isDefaultClient(false, this._shellSvc.RSS); + if (!this._rssCheckbox.disabled) { + this._rssCheckbox.removeAttribute("tooltiptext"); + } + + this._calendarCheckbox.checked = this._calendarCheckbox.disabled = + this._shellSvc.isDefaultClient(false, this._shellSvc.CALENDAR); + + // read the raw pref value and not shellSvc.shouldCheckDefaultMail + this._startupCheckbox.checked = Services.prefs.getBoolPref( + "mail.shell.checkDefaultClient" + ); + + // Search integration - check whether we should show/disable integration options + if (SearchIntegration) { + this._searchCheckbox.checked = SearchIntegration.prefEnabled; + // On Windows, do not offer the option on startup as it does not perform well. + if ( + Services.appinfo.OS == "WINNT" && + !calledFromPrefs && + !this._searchCheckbox.checked + ) { + this._searchCheckbox.hidden = true; + // Even if the user wasn't presented the choice, + // we do not want to ask again automatically. + SearchIntegration.firstRunDone = true; + } else if (!SearchIntegration.osVersionTooLow) { + // Hide/disable the options if the OS does not support them. + this._searchCheckbox.hidden = false; + if (SearchIntegration.osComponentsNotRunning) { + this._searchCheckbox.checked = false; + this._searchCheckbox.disabled = true; + } + } + } + }, + + /** + * Called when the dialog is closed by any button. + * + * @param aSetAsDefault If true, set TB as the default application for the + * checked actions (mail/news/rss). Otherwise do nothing. + */ + onDialogClose(aSetAsDefault) { + // In all cases, save the user's decision for "always check at startup". + this._shellSvc.shouldCheckDefaultClient = this._startupCheckbox.checked; + + // If the search checkbox is exposed, the user had the chance to make his choice. + // So do not ask next time. + let searchIntegPossible = !this._searchCheckbox.hidden; + if (searchIntegPossible) { + SearchIntegration.firstRunDone = true; + } + + // If the "skip integration" button was used do not set any defaults + // and close the dialog. + if (!aSetAsDefault) { + // Disable search integration in this case. + if (searchIntegPossible) { + SearchIntegration.prefEnabled = false; + } + + return true; + } + + // For each checked item, if we aren't already the default client, + // make us the default. + let appTypes = 0; + + if ( + this._mailCheckbox.checked && + !this._shellSvc.isDefaultClient(false, this._shellSvc.MAIL) + ) { + appTypes |= this._shellSvc.MAIL; + } + + if ( + this._newsCheckbox.checked && + !this._shellSvc.isDefaultClient(false, this._shellSvc.NEWS) + ) { + appTypes |= this._shellSvc.NEWS; + } + + if ( + this._rssCheckbox.checked && + !this._shellSvc.isDefaultClient(false, this._shellSvc.RSS) + ) { + appTypes |= this._shellSvc.RSS; + } + + if ( + this._calendarCheckbox.checked && + !this._shellSvc.isDefaultClient(false, this._shellSvc.CALENDAR) + ) { + appTypes |= this._shellSvc.CALENDAR; + } + + if (appTypes) { + this._shellSvc.setDefaultClient(false, appTypes); + } + + // Set the search integration pref if it is changed. + // The integration will handle the rest. + if (searchIntegPossible) { + SearchIntegration.prefEnabled = this._searchCheckbox.checked; + } + + return true; + }, +}; + +document.addEventListener("dialogaccept", () => + gSystemIntegrationDialog.onDialogClose(true) +); +document.addEventListener("dialogcancel", () => + gSystemIntegrationDialog.onDialogClose(false) +); diff --git a/comm/mail/base/content/systemIntegrationDialog.xhtml b/comm/mail/base/content/systemIntegrationDialog.xhtml new file mode 100644 index 0000000000..6ce932aed5 --- /dev/null +++ b/comm/mail/base/content/systemIntegrationDialog.xhtml @@ -0,0 +1,53 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css"?> + +<!DOCTYPE window> + +<window + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="gSystemIntegrationDialog.onLoad();" + data-l10n-id="system-integration-title" + style="min-width: 33em" +> + <dialog + id="systemIntegrationDialog" + buttons="accept,cancel" + data-l10n-id="system-integration-dialog" + data-l10n-attrs="buttonlabelaccept, buttonlabelcancel, buttonlabelcancel2" + > + <script src="chrome://messenger/content/systemIntegrationDialog.js" /> + + <linkset> + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link + rel="localization" + href="messenger/preferences/system-integration.ftl" + /> + </linkset> + + <label control="defaultClientList" data-l10n-id="default-client-intro" /> + <vbox id="defaultClientList" role="group"> + <checkbox id="checkMail" data-l10n-id="checkbox-email-label" /> + <checkbox id="checkNews" data-l10n-id="checkbox-newsgroups-label" /> + <checkbox id="checkRSS" data-l10n-id="checkbox-feeds-label" /> + <checkbox id="checkCalendar" data-l10n-id="checkbox-calendar-label" /> + </vbox> + + <separator class="groove" /> + + <checkbox + id="searchIntegration" + hidden="true" + data-l10n-id="system-search-integration-label" + /> + + <separator class="thin" /> + + <checkbox id="checkOnStartup" data-l10n-id="check-on-startup-label" /> + </dialog> +</window> diff --git a/comm/mail/base/content/tabDialogs.inc.xhtml b/comm/mail/base/content/tabDialogs.inc.xhtml new file mode 100644 index 0000000000..956db429a4 --- /dev/null +++ b/comm/mail/base/content/tabDialogs.inc.xhtml @@ -0,0 +1,23 @@ +# 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/. + +<html:template id="dialogStackTemplate"> + <stack class="dialogStack tab-dialog-box" hidden="true"> + <vbox class="dialogTemplate dialogOverlay" align="center" topmost="true" hidden="true"> + <hbox class="dialogBox"> + <browser class="dialogFrame" + autoscroll="false" + disablehistory="true"/> + </hbox> + </vbox> + </stack> +</html:template> + +<html:template id="printPreviewStackTemplate"> + <stack class="previewStack" rendering="true" flex="1" previewtype="primary"> + <vbox class="previewRendering" flex="1"> + <h1 class="print-pending-label" data-l10n-id="printui-loading"></h1> + </vbox> + </stack> +</html:template> diff --git a/comm/mail/base/content/tabmail.js b/comm/mail/base/content/tabmail.js new file mode 100644 index 0000000000..c2ab652ef9 --- /dev/null +++ b/comm/mail/base/content/tabmail.js @@ -0,0 +1,2048 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; // from mailWindow.js + +/* global MozElements, MozXULElement */ + +/* import-globals-from mailCore.js */ +/* globals contentProgress, statusFeedback */ + +var { UIFontSize } = ChromeUtils.import("resource:///modules/UIFontSize.jsm"); + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozTabmailAlltabsMenuPopup widget is used as a menupopup to list all the + * currently opened tabs. + * + * @augments {MozElements.MozMenuPopup} + * @implements {EventListener} + */ + class MozTabmailAlltabsMenuPopup extends MozElements.MozMenuPopup { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.tabmail = document.getElementById("tabmail"); + + this._mutationObserver = new MutationObserver((records, observer) => { + records.forEach(mutation => { + let menuItem = mutation.target.mCorrespondingMenuitem; + if (menuItem) { + this._setMenuitemAttributes(menuItem, mutation.target); + } + }); + }); + + this.addEventListener("popupshowing", event => { + // Set up the menu popup. + let tabcontainer = this.tabmail.tabContainer; + let tabs = tabcontainer.allTabs; + + // Listen for changes in the tab bar. + this._mutationObserver.observe(tabcontainer, { + attributes: true, + subtree: true, + attributeFilter: ["label", "crop", "busy", "image", "selected"], + }); + + this.tabmail.addEventListener("TabOpen", this); + tabcontainer.arrowScrollbox.addEventListener("scroll", this); + + // If an animation is in progress and the user + // clicks on the "all tabs" button, stop the animation. + tabcontainer._stopAnimation(); + + for (let i = 0; i < tabs.length; i++) { + this._createTabMenuItem(tabs[i]); + } + this._updateTabsVisibilityStatus(); + }); + + this.addEventListener("popuphiding", event => { + // Clear out the menu popup and remove the listeners. + while (this.hasChildNodes()) { + let menuItem = this.lastElementChild; + menuItem.removeEventListener("command", this); + menuItem.tab.removeEventListener("TabClose", this); + menuItem.tab.mCorrespondingMenuitem = null; + menuItem.remove(); + } + this._mutationObserver.disconnect(); + + this.tabmail.tabContainer.arrowScrollbox.removeEventListener( + "scroll", + this + ); + this.tabmail.removeEventListener("TabOpen", this); + }); + } + + _menuItemOnCommand(aEvent) { + this.tabmail.tabContainer.selectedItem = aEvent.target.tab; + } + + _tabOnTabClose(aEvent) { + let menuItem = aEvent.target.mCorrespondingMenuitem; + if (menuItem) { + menuItem.remove(); + } + } + + handleEvent(aEvent) { + if (!aEvent.isTrusted) { + return; + } + + switch (aEvent.type) { + case "command": + this._menuItemOnCommand(aEvent); + break; + case "TabClose": + this._tabOnTabClose(aEvent); + break; + case "TabOpen": + this._createTabMenuItem(aEvent.target); + break; + case "scroll": + this._updateTabsVisibilityStatus(); + break; + } + } + + _updateTabsVisibilityStatus() { + let tabStrip = this.tabmail.tabContainer.arrowScrollbox; + // We don't want menu item decoration unless there is overflow. + if (tabStrip.getAttribute("overflow") != "true") { + return; + } + + let tabStripBox = tabStrip.getBoundingClientRect(); + + for (let i = 0; i < this.children.length; i++) { + let currentTabBox = this.children[i].tab.getBoundingClientRect(); + + if ( + currentTabBox.left >= tabStripBox.left && + currentTabBox.right <= tabStripBox.right + ) { + this.children[i].setAttribute("tabIsVisible", "true"); + } else { + this.children[i].removeAttribute("tabIsVisible"); + } + } + } + + _createTabMenuItem(aTab) { + let menuItem = document.createXULElement("menuitem"); + + menuItem.setAttribute( + "class", + "menuitem-iconic alltabs-item menuitem-with-favicon" + ); + + this._setMenuitemAttributes(menuItem, aTab); + + // Keep some attributes of the menuitem in sync with its + // corresponding tab (e.g. the tab label). + aTab.mCorrespondingMenuitem = menuItem; + aTab.addEventListener("TabClose", this); + menuItem.tab = aTab; + menuItem.addEventListener("command", this); + + this.appendChild(menuItem); + return menuItem; + } + + _setMenuitemAttributes(aMenuitem, aTab) { + aMenuitem.setAttribute("label", aTab.label); + aMenuitem.setAttribute("crop", "end"); + + if (aTab.hasAttribute("busy")) { + aMenuitem.setAttribute("busy", aTab.getAttribute("busy")); + aMenuitem.removeAttribute("image"); + } else { + aMenuitem.setAttribute("image", aTab.getAttribute("image")); + aMenuitem.removeAttribute("busy"); + } + + // Change the tab icon accordingly. + let style = window.getComputedStyle(aTab); + aMenuitem.style.listStyleImage = style.listStyleImage; + aMenuitem.style.MozImageRegion = style.MozImageRegion; + + if (aTab.hasAttribute("pending")) { + aMenuitem.setAttribute("pending", aTab.getAttribute("pending")); + } else { + aMenuitem.removeAttribute("pending"); + } + + if (aTab.selected) { + aMenuitem.setAttribute("selected", "true"); + } else { + aMenuitem.removeAttribute("selected"); + } + } + } + + customElements.define( + "tabmail-alltabs-menupopup", + MozTabmailAlltabsMenuPopup, + { extends: "menupopup" } + ); + + /** + * Thunderbird's tab UI mechanism. + * + * We expect to be instantiated with the following children: + * One "tabpanels" child element whose id must be placed in the + * "panelcontainer" attribute on the element we are being bound to. We do + * this because it is important to allow overlays to contribute panels. + * When we attempted to have the immediate children of the bound element + * be propagated through use of the "children" tag, we found that children + * contributed by overlays did not propagate. + * Any children you want added to the right side of the tab bar. This is + * primarily intended to allow for "open a BLANK tab" buttons, namely + * calendar and tasks. For reasons similar to the tabpanels case, we + * expect the instantiating element to provide a child hbox for overlays + * to contribute buttons to. + * + * From a javascript perspective, there are three types of code that we + * expect to interact with: + * 1) Code that wants to open new tabs. + * 2) Code that wants to contribute one or more varieties of tabs. + * 3) Code that wants to monitor to know when the active tab changes. + * + * Consumer code should use the following methods: + * openTab(aTabModeName, aArgs) + * Open a tab of the given "mode", passing the provided arguments as an + * object. The tab type author should tell you the modes they implement + * and the required/optional arguments. + * + * Each tab type can define the set of arguments that it expects, but + * there are also a few common ones that all should obey, including: + * + * "background": if this is true, the tab will be loaded in the + * background. + * "disregardOpener": if this is true, then the tab opener will not + * be switched to automatically by tabmail if the new tab is immediately + * closed. + * + * closeTab(aOptionalTabIndexInfoOrTabNode, aNoUndo): + * If no argument is provided, the current tab is closed. The first + * argument specifies a specific tab to be closed. It can be a tab index, + * a tab info object, or a tab's DOM element. In case the second + * argument is true, the closed tab can't be restored by calling + * undoCloseTab(). + * Please note, some tabs cannot be closed. Trying to close such tab, + * will fail silently. + * undoCloseTab(): + * Restores the most recent tab closed by the user. + * switchToTab(aTabIndexInfoOrTabNode): + * Switch to the tab by providing a tab index, tab info object, or tab + * node (tabmail-tab bound element.) Instead of calling this method, + * you can also just poke at tabmail.tabContainer and its selectedIndex + * and selectedItem properties. + * replaceTabWithWindow(aTab): + * Detaches a tab from this tabbar to new window. The argument "aTab" is + * required and can be a tab index, a tab info object or a tabs's + * DOM element. Calling this method works only for tabs implementing + * session restore. + * moveTabTo(aTab, aIndex): + * moves the given tab to the given Index. The first argument can be + * a tab index, a tab info object or a tab's DOM element. The second + * argument specifies the tabs new absolute position within the tabbar. + * + * Less-friendly consumer methods: + * * persistTab(tab): + * serializes a tab into an object, by passing a tab info object as + * argument. It is used for session restore and moving tabs between + * windows. Returns null in case persist fails. + * * removeCurrentTab(): + * Close the current tab. + * * removeTabByNode(aTabElement): + * Close the tab whose tabmail-tab bound element is passed in. + * Changing the currently displayed tab is accomplished by changing + * tabmail.tabContainer's selectedIndex or selectedItem property. + * + * Code that lives in a tab should use the following methods: + * * setTabTitle([aOptionalTabInfo]): Tells us that the title of the current + * tab (if no argument is provided) or provided tab needs to be updated. + * This will result in a call to the tab mode's logic to update the title. + * In the event this is not for the current tab, the caller is responsible + * for ensuring that the underlying tab mode is capable of providing a tab + * title when it is in the background. (The is currently not the case for + * "folder" and "mail" modes because of their implementation.) + * * setTabBusy(aTabNode, aBusyState): Tells us that the tab in question + * is now busy or not busy. "Busy" means that it is occupied and + * will not be able to respond to you until it is no longer busy. + * This impacts the cursor display, as well as potentially + * providing tab display hints. + * * setTabThinking(aTabNode, aThinkingState): Tells us that the + * tab in question is now thinking or not thinking. "Thinking" means + * that the tab is involved in some ongoing process but you can still + * interact with the tab while it is thinking. A search would be an + * example of thinking. This impacts spinny-thing feedback as well as + * potential providing tab display hints. aThinkingState may be a + * boolean or a localized string explaining what you are thinking about. + * + * Tab contributing code should define a tab type object and register it + * with us by calling registerTabType. You can remove a registered tab + * type (eg when unloading a restartless addon) by calling unregisterTabType. + * Each tab type can provide multiple tab modes. The rationale behind this + * organization is that Thunderbird historically/currently uses a single + * 3-pane view to display both three-pane folder browsing and single message + * browsing across multiple tabs. Each tab type has the ability to use a + * single tab panel for all of its display needs. So Thunderbird's "mail" + * tab type covers both the "folder" (3-pane folder-based browsing) and + * "message" (just a single message) tab modes. Likewise, calendar/lightning + * currently displays both its calendar and tasks in the same panel. A tab + * type can also create a new tabpanel for each tab as it is created. In + * that case, the tab type should probably only have a single mode unless + * there are a number of similar modes that can gain from code sharing. + * + * If you're adding a new tab type, please update TabmailTab.type in + * mail/components/extensions/parent/ext-mail.js. + * + * The tab type definition should include the following attributes: + * * name: The name of the tab-type, mainly to aid in debugging. + * * panelId or perTabPanel: If using a single tab panel, the id of the + * panel must be provided in panelId. If using one tab panel per tab, + * perTabPanel should be either the XUL element name that should be + * created for each tab, or a helper function to create and return the + * element. + * * modes: An object whose attributes are mode names (which are + * automatically propagated to a 'name' attribute for debugging) and + * values are objects with the following attributes... + * * any of the openTab/closeTab/saveTabState/showTab/onTitleChanged + * functions as described on the mode definitions. These will only be + * called if the mode does not provide the functions. Note that because + * the 'this' variable passed to the functions will always reference the + * tab type definition (rather than the mode definition), the mode + * functions can defer to the tab type functions by calling + * this.functionName(). (This should prove convenient.) + * Mode definition attributes: + * * type: The "type" attribute to set on the displayed tab for CSS purposes. + * Generally, this would be the same as the mode name, but you can do as + * you please. + * * isDefault: This should only be present and should be true for the tab + * mode that is the tab displayed automatically on startup. + * * maxTabs: The maximum number of this mode that can be opened at a time. + * If this limit is reached, any additional calls to openTab for this + * mode will simply result in the first existing tab of this mode being + * displayed. + * * shouldSwitchTo(aArgs): Optional function. Called when openTab is called + * on the top-level tabmail binding. It is used to decide if the openTab + * function should switch to an existing tab or actually open a new tab. + * If the openTab function should switch to an existing tab, return the + * index of that tab; otherwise return -1. + * aArgs is a set of named parameters (the ones that are later passed to + * openTab). + * * openTab(aTab, aArgs): Called when a tab of the given mode is in the + * process of being opened. aTab will have its "mode" attribute + * set to the mode definition of the tab mode being opened. You should + * set the "title" attribute on it, and may set any other attributes + * you wish for your own use in subsequent functions. Note that 'this' + * points to the tab type definition, not the mode definition as you + * might expect. This allows you to place common logic code on the + * tab type for use by multiple modes and to defer to it. Any arguments + * provided to the caller of tabmail.openTab will be passed to your + * function as well, including background. + * * closeTab(aTab): Called when aTab is being closed. The tab need not be + * currently displayed. You are responsible for properly cleaning up + * any state you preserved in aTab. + * * saveTabState(aTab): Called when aTab is being switched away from so that + * you can preserve its state on aTab. This is primarily for single + * tab panel implementations; you may not have much state to save if your + * tab has its own tab panel. + * * showTab(aTab): Called when aTab is being displayed and you should + * restore its state (if required). + * * persistTab(aTab): Called when we want to persist the tab because we are + * saving the session state. You should return an object suitable for + * JSON serialization. The object will be provided to your restoreTab + * method when we attempt to restore the session. If your code is + * unable or unwilling to persist the tab (some of the time), you should + * return null in that case. If your code never wants to persist the tab + * you should not implement this method. You must implement restoreTab + * if you implement this method. + * * restoreTab(aTabmail, aPersistedState): Called when we are restoring a + * tab session and a tab with your mode was previously persisted via a + * call to your persistTab implementation. You are provided with a + * reference to this tabmail instance and the (deserialized) state object + * you returned from your persistTab implementation. It is your + * function's job to determine if you can restore the tab, and if so, + * you should invoke aTabmail.openTab to actually cause your tab to be + * opened. This may seem odd, but it should help keep your code simple + * while letting you do whatever you want. Since openTab is synchronous + * and returns the tabInfo structure built for the tab, you can perform + * any additional work you need after the call to openTab. + * * onTitleChanged(aTab): Called when someone calls tabmail.setTabTitle() to + * hint that the tab's title needs to be updated. This function should + * update aTab.title if it can. + * Mode definition functions to do with menu/toolbar commands: + * * supportsCommand(aCommand, aTab): Called when a menu or toolbar needs to + * be updated. Return true if you support that command in + * isCommandEnabled and doCommand, return false otherwise. + * * isCommandEnabled(aCommand, aTab): Called when a menu or toolbar needs + * to be updated. Return true if the command can be executed at the + * current time, false otherwise. + * * doCommand(aCommand, aTab): Called when a menu or toolbar command is to + * be executed. Perform the action appropriate to the command. + * * onEvent(aEvent, aTab): This can be used to handle different events on + * the window. + * * getBrowser(aTab): This function should return the browser element for + * your tab if there is one (return null or don't define this function + * otherwise). It is used for some toolkit functions that require a + * global "getBrowser" function, e.g. ZoomManager. + * + * Tab monitoring code is expected to be used for widgets on the screen + * outside of the tab box that need to update themselves as the active tab + * changes. + * Tab monitoring code (un)registers itself via (un)registerTabMonitor. + * The following attributes should be provided on the monitor object: + * * monitorName: A string value naming the tab monitor/extension. This is + * the canonical name for the tab monitor for all persistence purposes. + * If the tab monitor wants to store data in the tab info object and its + * name is FOO it should store it in 'tabInfo._ext.FOO'. This is the + * only place the tab monitor should store information on the tab info + * object. The FOO attribute will not be automatically created; it is + * up to the code. The _ext attribute will be there, reliably, however. + * The name is also used when persisting state, but the tab monitor + * does not need to do anything in that case; the name is automatically + * used in the course of wrapping the object. + * The following functions should be provided on the monitor object: + * * onTabTitleChanged(aTab): Called when the tab's title changes. + * * onTabSwitched(aTab, aOldTab): Called when a new tab is made active. + * Also called when the monitor is registered if one or more tabs exist. + * If this is the first call, aOldTab will be null, otherwise aOldTab + * will be the previously active tab. + * * onTabOpened(aTab, aIsFirstTab, aWasCurrentTab): Called when a new tab is + * opened. This method is invoked after the tab mode's openTab method + * is invoked. This method is invoked before the tab monitor + * onTabSwitched method in the case where it will be invoked. (It is + * not invoked if the tab is opened in the background.) + * * onTabClosing(aTab): Called when a tab is being closed. This method is + * is invoked before the call to the tab mode's closeTab function. + * * onTabPersist(aTab): Return a JSON-representable object to persist for + * the tab. Return null if you do not have anything to persist. + * * onTabRestored(aTab, aState, aIsFirstTab): Called when a tab is being + * restored and there is data previously persisted by the tab monitor. + * This method is called instead of invoking onTabOpened. This is done + * because the restoreTab method (potentially) uses the tabmail openTab + * API to effect restoration. (Note: the first opened tab is special; + * it will produce an onTabOpened notification potentially followed by + * an onTabRestored notification.) + * Tab monitor code is also allowed to hook into the command processing + * logic. We support the standard supportsCommand/isCommandEnabled/ + * doCommand functions but with a twist to indicate when other tab monitors + * and the actual tab itself should get a chance to process: supportsCommand + * and isCommandEnabled should return null when they are not handling the + * case. doCommand should return true if it handled the case, null + * otherwise. + */ + + /** + * The MozTabmail widget handles the Tab UI mechanism. + * + * @augments {MozXULElement} + */ + class MozTabmail extends MozXULElement { + /** + * Flag indicating that the UI is currently covered by an overlay. + * + * @type {boolean} + */ + globalOverlay = false; + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.tabbox = this.getElementsByTagName("tabbox").item(0); + this.currentTabInfo = null; + + /** + * Temporary field that only has a non-null value during a call to + * openTab, and whose value is the currentTabInfo of the tab that was + * open when we received the call to openTab. + */ + this._mostRecentTabInfo = null; + /** + * Tab id, incremented on each openTab() and set on the browser. + */ + this.tabId = 0; + this.tabTypes = {}; + this.tabModes = {}; + this.defaultTabMode = null; + this.tabInfo = []; + this.tabContainer = document.getElementById( + this.getAttribute("tabcontainer") + ); + this.panelContainer = document.getElementById( + this.getAttribute("panelcontainer") + ); + this.tabMonitors = []; + this.recentlyClosedTabs = []; + this.mLastTabOpener = null; + this.unrestoredTabs = []; + + // @implements {nsIController} + this.tabController = { + supportsCommand: aCommand => { + let tab = this.currentTabInfo; + // This can happen if we're starting up and haven't got a tab + // loaded yet. + if (!tab) { + return false; + } + + for (let tabMonitor of this.tabMonitors) { + try { + if ("supportsCommand" in tabMonitor) { + let result = tabMonitor.supportsCommand(aCommand, tab); + if (result !== null) { + return result; + } + } + } catch (ex) { + console.error(ex); + } + } + + let supportsCommandFunc = + tab.mode.supportsCommand || tab.mode.tabType.supportsCommand; + if (supportsCommandFunc) { + return supportsCommandFunc.call(tab.mode.tabType, aCommand, tab); + } + + return false; + }, + + isCommandEnabled: aCommand => { + let tab = this.currentTabInfo; + // This can happen if we're starting up and haven't got a tab + // loaded yet. + if (!tab || this.globalOverlay) { + return false; + } + + for (let tabMonitor of this.tabMonitors) { + try { + if ("isCommandEnabled" in tabMonitor) { + let result = tabMonitor.isCommandEnabled(aCommand, tab); + if (result !== null) { + return result; + } + } + } catch (ex) { + console.error(ex); + } + } + + let isCommandEnabledFunc = + tab.mode.isCommandEnabled || tab.mode.tabType.isCommandEnabled; + if (isCommandEnabledFunc) { + return isCommandEnabledFunc.call(tab.mode.tabType, aCommand, tab); + } + + return false; + }, + + doCommand: (aCommand, ...args) => { + let tab = this.currentTabInfo; + // This can happen if we're starting up and haven't got a tab + // loaded yet. + if (!tab) { + return; + } + + for (let tabMonitor of this.tabMonitors) { + try { + if ("doCommand" in tabMonitor) { + let result = tabMonitor.doCommand(aCommand, tab); + if (result === true) { + return; + } + } + } catch (ex) { + console.error(ex); + } + } + + let doCommandFunc = tab.mode.doCommand || tab.mode.tabType.doCommand; + if (doCommandFunc) { + doCommandFunc.call(tab.mode.tabType, aCommand, tab, ...args); + } + }, + + onEvent: aEvent => { + let tab = this.currentTabInfo; + // This can happen if we're starting up and haven't got a tab + // loaded yet. + if (!tab) { + return null; + } + + let onEventFunc = tab.mode.onEvent || tab.mode.tabType.onEvent; + if (onEventFunc) { + return onEventFunc.call(tab.mode.tabType, aEvent, tab); + } + + return false; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIController"]), + }; + + // This is the second-highest priority controller. It's preceded by + // DefaultController and followed by calendarController, then whatever + // Gecko adds. + window.controllers.insertControllerAt(1, this.tabController); + this._restoringTabState = null; + } + + set selectedTab(val) { + this.switchToTab(val); + } + + get selectedTab() { + if (!this.currentTabInfo) { + this.currentTabInfo = this.tabInfo[0]; + } + + return this.currentTabInfo; + } + + get tabs() { + return this.tabContainer.allTabs; + } + + get selectedBrowser() { + return this.getBrowserForSelectedTab(); + } + + registerTabType(aTabType) { + if (aTabType.name in this.tabTypes) { + return; + } + + this.tabTypes[aTabType.name] = aTabType; + for (let [modeName, modeDetails] of Object.entries(aTabType.modes)) { + modeDetails.name = modeName; + modeDetails.tabType = aTabType; + modeDetails.tabs = []; + this.tabModes[modeName] = modeDetails; + if (modeDetails.isDefault) { + this.defaultTabMode = modeDetails; + } + } + + if (aTabType.panelId) { + aTabType.panel = document.getElementById(aTabType.panelId); + } else if (!aTabType.perTabPanel) { + throw new Error( + "Trying to register a tab type with neither panelId " + + "nor perTabPanel attributes." + ); + } + + setTimeout(() => { + for (let modeName of Object.keys(aTabType.modes)) { + let i = 0; + while (i < this.unrestoredTabs.length) { + let state = this.unrestoredTabs[i]; + if (state.mode == modeName) { + this.restoreTab(state); + this.unrestoredTabs.splice(i, 1); + } else { + i++; + } + } + } + }, 0); + } + + unregisterTabType(aTabType) { + // we can skip if the tab type was never registered... + if (!(aTabType.name in this.tabTypes)) { + return; + } + + // ... if the tab type is still in use, we can not remove it without + // breaking the UI. So we throw an exception. + for (let modeName of Object.keys(aTabType.modes)) { + if (this.tabModes[modeName].tabs.length) { + throw new Error("Tab mode " + modeName + " still in use. Close tabs"); + } + } + // ... finally get rid of the tab type + for (let modeName of Object.keys(aTabType.modes)) { + delete this.tabModes[modeName]; + } + + delete this.tabTypes[aTabType.name]; + } + + registerTabMonitor(aTabMonitor) { + if (!this.tabMonitors.includes(aTabMonitor)) { + this.tabMonitors.push(aTabMonitor); + if (this.tabInfo.length) { + aTabMonitor.onTabSwitched(this.currentTabInfo, null); + } + } + } + + unregisterTabMonitor(aTabMonitor) { + if (this.tabMonitors.includes(aTabMonitor)) { + this.tabMonitors.splice(this.tabMonitors.indexOf(aTabMonitor), 1); + } + } + + /** + * Given an index, tab node or tab info object, return a tuple of + * [iTab, tab info dictionary, tab DOM node]. If + * aTabIndexNodeOrInfo is not specified and aDefaultToCurrent is + * true, the current tab will be returned. Otherwise, an + * exception will be thrown. + */ + _getTabContextForTabbyThing(aTabIndexNodeOrInfo, aDefaultToCurrent) { + let iTab; + let tab; + let tabNode; + if (aTabIndexNodeOrInfo == null) { + if (!aDefaultToCurrent) { + throw new Error("You need to specify a tab!"); + } + iTab = this.tabContainer.selectedIndex; + return [iTab, this.tabInfo[iTab], this.tabContainer.allTabs[iTab]]; + } + if (typeof aTabIndexNodeOrInfo == "number") { + iTab = aTabIndexNodeOrInfo; + tabNode = this.tabContainer.allTabs[iTab]; + tab = this.tabInfo[iTab]; + } else if ( + aTabIndexNodeOrInfo.tagName && + aTabIndexNodeOrInfo.tagName == "tab" + ) { + tabNode = aTabIndexNodeOrInfo; + iTab = this.tabContainer.getIndexOfItem(tabNode); + tab = this.tabInfo[iTab]; + } else { + tab = aTabIndexNodeOrInfo; + iTab = this.tabInfo.indexOf(tab); + tabNode = iTab >= 0 ? this.tabContainer.allTabs[iTab] : null; + } + return [iTab, tab, tabNode]; + } + + openFirstTab() { + // From the moment of creation, our customElement already has a visible + // tab. We need to create a tab information structure for this tab. + // In the process we also generate a synthetic tab title changed + // event to ensure we have an accurate title. We assume the tab + // contents will set themselves up correctly. + if (this.tabInfo.length == 0) { + let tab = this.openTab("mail3PaneTab", { first: true }); + this.tabs[0].linkedPanel = tab.panel.id; + } + } + + // eslint-disable-next-line complexity + openTab(aTabModeName, aArgs = {}) { + try { + if (!(aTabModeName in this.tabModes)) { + throw new Error("No such tab mode: " + aTabModeName); + } + + let tabMode = this.tabModes[aTabModeName]; + // if we are already at our limit for this mode, show an existing one + if (tabMode.tabs.length == tabMode.maxTabs) { + let desiredTab = tabMode.tabs[0]; + this.tabContainer.selectedIndex = this.tabInfo.indexOf(desiredTab); + return null; + } + + // Do this so that we don't generate strict warnings + let background = aArgs.background; + // If the mode wants us to, we should switch to an existing tab + // rather than open a new one. We shouldn't switch to the tab if + // we're opening it in the background, though. + let shouldSwitchToFunc = + tabMode.shouldSwitchTo || tabMode.tabType.shouldSwitchTo; + if (shouldSwitchToFunc) { + let tabIndex = shouldSwitchToFunc.apply(tabMode.tabType, [aArgs]); + if (tabIndex >= 0) { + if (!background) { + this.selectTabByIndex(null, tabIndex); + } + return this.tabInfo[tabIndex]; + } + } + + if (!aArgs.first && !background) { + // we need to save the state before it gets corrupted + this.saveCurrentTabState(); + } + + let tab = { + first: !!aArgs.first, + mode: tabMode, + busy: false, + canClose: true, + thinking: false, + beforeTabOpen: true, + favIconUrl: null, + _ext: {}, + }; + + tab.tabId = this.tabId++; + tabMode.tabs.push(tab); + + let t; + if (aArgs.first) { + t = this.tabContainer.querySelector(`tab[is="tabmail-tab"]`); + } else { + t = document.createXULElement("tab", { is: "tabmail-tab" }); + t.className = "tabmail-tab"; + t.setAttribute("validate", "never"); + this.tabContainer.appendChild(t); + } + tab.tabNode = t; + + if ( + this.tabContainer.mCollapseToolbar.collapsed && + (!this.tabContainer.mAutoHide || this.tabContainer.allTabs.length > 1) + ) { + this.tabContainer.mCollapseToolbar.collapsed = false; + this.tabContainer._updateCloseButtons(); + document.documentElement.removeAttribute("tabbarhidden"); + } + + let oldTab = (this._mostRecentTabInfo = this.currentTabInfo); + // If we're not disregarding the opening, hold a reference to opener + // so that if the new tab is closed without switching, we can switch + // back to the opener tab. + if (aArgs.disregardOpener) { + this.mLastTabOpener = null; + } else { + this.mLastTabOpener = oldTab; + } + + // the order of the following statements is important + this.tabInfo[this.tabContainer.allTabs.length - 1] = tab; + if (!background) { + this.currentTabInfo = tab; + // this has a side effect of calling updateCurrentTab, but our + // setting currentTabInfo above will cause it to take no action. + this.tabContainer.selectedIndex = + this.tabContainer.allTabs.length - 1; + } + + // make sure we are on the right panel + if (tab.mode.tabType.perTabPanel) { + // should we create the element for them, or will they do it? + if (typeof tab.mode.tabType.perTabPanel == "string") { + tab.panel = document.createXULElement(tab.mode.tabType.perTabPanel); + } else { + tab.panel = tab.mode.tabType.perTabPanel(tab); + } + + this.panelContainer.appendChild(tab.panel); + + if (!background) { + this.panelContainer.selectedPanel = tab.panel; + } + } else { + if (!background) { + this.panelContainer.selectedPanel = tab.mode.tabType.panel; + } + t.linkedPanel = tab.mode.tabType.panelId; + } + + // Make sure the new panel is marked selected. + let oldPanel = [...this.panelContainer.children].find(p => + p.hasAttribute("selected") + ); + // Blur the currently focused element only if we're actually switching + // to the newly opened tab. + if (oldPanel && !background) { + this.rememberLastActiveElement(oldTab); + oldPanel.removeAttribute("selected"); + if (oldTab.chromeBrowser) { + oldTab.chromeBrowser.docShellIsActive = false; + } + } + + this.panelContainer.selectedPanel.setAttribute("selected", "true"); + let tabOpenFunc = tab.mode.openTab || tab.mode.tabType.openTab; + tabOpenFunc.apply(tab.mode.tabType, [tab, aArgs]); + if (tab.chromeBrowser) { + tab.chromeBrowser.docShellIsActive = !background; + } + + if (!t.linkedPanel) { + if (!tab.panel.id) { + // No id set. Create our own. + tab.panel.id = "unnamedTab" + Math.random().toString().substring(2); + console.warn(`Tab mode ${aTabModeName} should set an id + on the first argument of openTab.`); + } + t.linkedPanel = tab.panel.id; + } + + // Set the tabId after defining a <browser> and before notifications. + let browser = this.getBrowserForTab(tab); + if (browser && !tab.browser) { + tab.browser = browser; + if (!tab.linkedBrowser) { + tab.linkedBrowser = browser; + } + } + + let restoreState = this._restoringTabState; + for (let tabMonitor of this.tabMonitors) { + try { + if ( + "onTabRestored" in tabMonitor && + restoreState && + tabMonitor.monitorName in restoreState.ext + ) { + tabMonitor.onTabRestored( + tab, + restoreState.ext[tabMonitor.monitorName], + false + ); + } else if ("onTabOpened" in tabMonitor) { + tabMonitor.onTabOpened(tab, false, oldTab); + } + if (!background) { + tabMonitor.onTabSwitched(tab, oldTab); + } + } catch (ex) { + console.error(ex); + } + } + + // clear _mostRecentTabInfo; we only needed it during the call to + // openTab. + this._mostRecentTabInfo = null; + t.setAttribute("label", tab.title); + // For styling purposes, apply the type to the tab. + t.setAttribute("type", tab.mode.type); + + if (!background) { + this.setDocumentTitle(tab); + // Move the focus on the newly selected tab. + this.panelContainer.selectedPanel.focus(); + } + + let moving = restoreState ? restoreState.moving : null; + // Dispatch tab opening event + let evt = new CustomEvent("TabOpen", { + bubbles: true, + detail: { tabInfo: tab, moving }, + }); + t.dispatchEvent(evt); + delete tab.beforeTabOpen; + + contentProgress.addProgressListenerToBrowser(browser); + + return tab; + } catch (e) { + console.error(e); + return null; + } + } + + selectTabByMode(aTabModeName) { + let tabMode = this.tabModes[aTabModeName]; + if (tabMode.tabs.length) { + let desiredTab = tabMode.tabs[0]; + this.tabContainer.selectedIndex = this.tabInfo.indexOf(desiredTab); + } + } + + selectTabByIndex(aEvent, aIndex) { + // count backwards for aIndex < 0 + if (aIndex < 0) { + aIndex += this.tabInfo.length; + } + + if ( + aIndex >= 0 && + aIndex < this.tabInfo.length && + aIndex != this.tabContainer.selectedIndex + ) { + this.tabContainer.selectedIndex = aIndex; + } + + if (aEvent) { + aEvent.preventDefault(); + aEvent.stopPropagation(); + } + } + + /** + * If the current/most recent tab is of mode aTabModeName, return its + * tab info, otherwise return the tab info for the first tab of the + * given mode. + * You would want to use this method when you would like to mimic the + * settings of an existing instance of your mode. In such a case, + * it is reasonable to assume that if the 'current' tab was of the + * same mode that its settings should be used. Otherwise, we must + * fall back to another tab. We currently choose the first tab of + * the instance, because for the "folder" tab, it is the canonical tab. + * In other cases, having an MRU order and choosing the MRU tab might + * be more appropriate. + * + * @returns the tab info object for the tab meeting the above criteria, + * or null if no such tab exists. + */ + getTabInfoForCurrentOrFirstModeInstance(aTabMode) { + // If we're in the middle of opening a new tab + // (this._mostRecentTabInfo is non-null), we shouldn't consider the + // current tab + let tabToConsider = this._mostRecentTabInfo || this.currentTabInfo; + if (tabToConsider && tabToConsider.mode == aTabMode) { + return tabToConsider; + } else if (aTabMode.tabs.length) { + return aTabMode.tabs[0]; + } + + return null; + } + + undoCloseTab(aIdx) { + if (!this.recentlyClosedTabs.length) { + return; + } + if (aIdx >= this.recentlyClosedTabs.length) { + aIdx = this.recentlyClosedTabs.length - 1; + } + // splice always returns an array + let history = this.recentlyClosedTabs.splice(aIdx, 1)[0]; + if (!history.tab) { + return; + } + + if (!this.restoreTab(JSON.parse(history.tab))) { + return; + } + + let idx = Math.min(history.idx, this.tabInfo.length); + let tab = this.tabContainer.allTabs[this.tabInfo.length - 1]; + this.moveTabTo(tab, idx); + this.switchToTab(tab); + } + + closeTab(aOptTabIndexNodeOrInfo, aNoUndo) { + let [iTab, tab, tabNode] = this._getTabContextForTabbyThing( + aOptTabIndexNodeOrInfo, + true + ); + if (!tab.canClose) { + return; + } + + // Give the tab type a chance to make its own decisions about + // whether its tabs can be closed or not. For instance, contentTabs + // and chromeTabs run onbeforeunload event handlers that may + // exercise their right to prompt the user for confirmation before + // closing. + let tryCloseFunc = tab.mode.tryCloseTab || tab.mode.tabType.tryCloseTab; + if (tryCloseFunc && !tryCloseFunc.call(tab.mode.tabType, tab)) { + return; + } + + let evt = new CustomEvent("TabClose", { + bubbles: true, + detail: { tabInfo: tab, moving: tab.moving }, + }); + + tabNode.dispatchEvent(evt); + for (let tabMonitor of this.tabMonitors) { + try { + if ("onTabClosing" in tabMonitor) { + tabMonitor.onTabClosing(tab); + } + } catch (ex) { + console.error(ex); + } + } + + if (!aNoUndo) { + // Allow user to undo accidentally closed tabs + let session = this.persistTab(tab); + if (session) { + this.recentlyClosedTabs.unshift({ + tab: JSON.stringify(session), + idx: iTab, + title: tab.title, + }); + if (this.recentlyClosedTabs.length > 10) { + this.recentlyClosedTabs.pop(); + } + } + } + + tab.closed = true; + let closeFunc = tab.mode.closeTab || tab.mode.tabType.closeTab; + closeFunc.call(tab.mode.tabType, tab); + this.tabInfo.splice(iTab, 1); + tab.mode.tabs.splice(tab.mode.tabs.indexOf(tab), 1); + tabNode.remove(); + + if (this.tabContainer.selectedIndex == -1) { + if (this.mLastTabOpener && this.tabInfo.includes(this.mLastTabOpener)) { + this.tabContainer.selectedIndex = this.tabInfo.indexOf( + this.mLastTabOpener + ); + } else { + this.tabContainer.selectedIndex = + iTab == this.tabContainer.allTabs.length ? iTab - 1 : iTab; + } + } + + // Clear the last tab opener - we don't need this anymore. + this.mLastTabOpener = null; + if (this.currentTabInfo == tab) { + this.updateCurrentTab(); + } + + if (tab.panel) { + tab.panel.remove(); + delete tab.panel; + // Ensure current tab is still selected and displayed in the + // panelContainer. + this.panelContainer.selectedPanel = + this.currentTabInfo.panel || this.currentTabInfo.mode.tabType.panel; + } + + if ( + this.tabContainer.allTabs.length == 1 && + this.tabContainer.mAutoHide + ) { + this.tabContainer.mCollapseToolbar.collapsed = true; + document.documentElement.setAttribute("tabbarhidden", "true"); + } + } + + removeTabByNode(aTabNode) { + this.closeTab(aTabNode); + } + + /** + * Given a tabNode (or tabby thing), close all of the other tabs + * that are closeable. + */ + closeOtherTabs(aTabNode, aNoUndo) { + let [, thisTab] = this._getTabContextForTabbyThing(aTabNode, false); + // closeTab mutates the tabInfo array, so start from the end. + for (let i = this.tabInfo.length - 1; i >= 0; i--) { + let tab = this.tabInfo[i]; + if (tab != thisTab && tab.canClose) { + this.closeTab(tab, aNoUndo); + } + } + } + + replaceTabWithWindow(aTab, aTargetWindow, aTargetPosition) { + if (this.tabInfo.length <= 1) { + return null; + } + + let tab = this._getTabContextForTabbyThing(aTab, false)[1]; + if (!tab.canClose) { + return null; + } + + // We use JSON and session restore transfer the tab to the new window. + tab = this.persistTab(tab); + if (!tab) { + return null; + } + + // Converting to JSON and back again creates clean javascript + // object with absolutely no references to our current window. + tab = JSON.parse(JSON.stringify(tab)); + // Set up an identifier for the move, consumers may want to correlate TabClose and + // TabOpen events. + let moveSession = Services.uuid.generateUUID().toString(); + tab.moving = moveSession; + aTab.moving = moveSession; + this.closeTab(aTab, true); + + if (aTargetWindow && aTargetWindow !== "popup") { + let targetTabmail = aTargetWindow.document.getElementById("tabmail"); + targetTabmail.restoreTab(tab); + if (aTargetPosition) { + let droppedTab = + targetTabmail.tabInfo[targetTabmail.tabInfo.length - 1]; + targetTabmail.moveTabTo(droppedTab, aTargetPosition); + } + return aTargetWindow; + } + + let features = ["chrome"]; + if (aTargetWindow === "popup") { + features.push( + "dialog", + "resizable", + "minimizable", + "centerscreen", + "titlebar", + "close" + ); + } else { + features.push("dialog=no", "all", "status", "toolbar"); + } + + return window + .openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + features.join(","), + null, + { + action: "restore", + tabs: [tab], + } + ) + .focus(); + } + + moveTabTo(aTabIndexNodeOrInfo, aIndex) { + let [oldIdx, tab, tabNode] = this._getTabContextForTabbyThing( + aTabIndexNodeOrInfo, + false + ); + if ( + !tab || + !tabNode || + tabNode.tagName != "tab" || + oldIdx < 0 || + oldIdx == aIndex + ) { + return -1; + } + + // remove the entries from tabInfo, tabMode and the tabContainer + this.tabInfo.splice(oldIdx, 1); + tab.mode.tabs.splice(tab.mode.tabs.indexOf(tab), 1); + tabNode.remove(); + // as we removed items, we might need to update indices + if (oldIdx < aIndex) { + aIndex--; + } + + // Read it into tabInfo and the tabContainer + this.tabInfo.splice(aIndex, 0, tab); + this.tabContainer.insertBefore( + tabNode, + this.tabContainer.allTabs[aIndex] + ); + // Now it's getting a bit ugly, as tabModes stores redundant + // information we need to get it in sync with tabInfo. + // + // As tabModes.tabs is a subset of tabInfo, every tab can be mapped + // to a tabInfo index. So we check for each tab in tabModes if it is + // directly in front of our moved tab. We do this by looking up the + // index in tabInfo and compare it with the moved tab's index. If we + // found our tab, we insert the moved tab directly behind into tabModes + // In case find no tab we simply append it + let modeIdx = tab.mode.tabs.length + 1; + for (let i = 0; i < tab.mode.tabs.length; i++) { + if (this.tabInfo.indexOf(tab.mode.tabs[i]) < aIndex) { + continue; + } + modeIdx = i; + break; + } + + tab.mode.tabs.splice(modeIdx, 0, tab); + let evt = new CustomEvent("TabMove", { + bubbles: true, + view: window, + detail: { idx: oldIdx, tabInfo: tab }, + }); + tabNode.dispatchEvent(evt); + + return aIndex; + } + + // Returns null in case persist fails. + persistTab(tab) { + let persistFunc = tab.mode.persistTab || tab.mode.tabType.persistTab; + // if we can't restore the tab we can't move it + if (!persistFunc) { + return null; + } + + // If there is a non-null tab-state, then persisting succeeded and + // we should store it. We store the tab's persisted state in its + // own distinct object rather than mixing things up in a dictionary + // to avoid bugs and because we may eventually let extensions store + // per-tab information in the persisted state. + let tabState; + // Wrap this in an exception handler so that if the persistence + // logic fails, things like tab closure still run to completion. + try { + tabState = persistFunc.call(tab.mode.tabType, tab); + } catch (ex) { + // Report this so that our unit testing framework sees this + // error and (extension) developers likewise can see when their + // extensions are ill-behaved. + console.error(ex); + } + + if (!tabState) { + return null; + } + + let ext = {}; + for (let tabMonitor of this.tabMonitors) { + try { + if ("onTabPersist" in tabMonitor) { + let monState = tabMonitor.onTabPersist(tab); + if (monState !== null) { + ext[tabMonitor.monitorName] = monState; + } + } + } catch (ex) { + console.error(ex); + } + } + + return { mode: tab.mode.name, state: tabState, ext }; + } + + /** + * Persist the state of all tab modes implementing persistTab methods + * to a JSON-serializable object representation and return it. Call + * restoreTabs with the result to restore the tab state. + * Calling this method should have no side effects; tabs will not be + * closed, displays will not change, etc. This means the method is + * safe to use in an auto-save style so that if we crash we can + * restore the (approximate) state at the time of the crash. + * + * @returns {object} The persisted tab states. + */ + persistTabs() { + let state = { + // Explicitly specify a revision so we don't wish we had later. + rev: 0, + // If our currently selected tab gets persisted, we will update this + selectedIndex: null, + }; + + let tabs = (state.tabs = []); + for (let [iTab, tab] of this.tabInfo.entries()) { + let persistTab = this.persistTab(tab); + if (!persistTab) { + continue; + } + tabs.push(persistTab); + // Mark this persisted tab as selected + if (iTab == this.tabContainer.selectedIndex) { + state.selectedIndex = tabs.length - 1; + } + } + + return state; + } + + restoreTab(aState) { + // Migrate old mail tabs to new mail tabs. This can be removed after ESR 115. + if (aState.mode == "folder") { + aState.mode = "mail3PaneTab"; + } else if (aState.mode == "message") { + aState.mode = "mailMessageTab"; + } + + // if we no longer know about the mode, we can't restore the tab + let mode = this.tabModes[aState.mode]; + if (!mode) { + this.unrestoredTabs.push(aState); + return false; + } + + let restoreFunc = mode.restoreTab || mode.tabType.restoreTab; + if (!restoreFunc) { + return false; + } + + // normalize the state to have an ext attribute if it does not. + if (!("ext" in aState)) { + aState.ext = {}; + } + + this._restoringTabState = aState; + restoreFunc.call(mode.tabType, this, aState.state); + this._restoringTabState = null; + + return true; + } + + /** + * Attempts to restore tabs persisted from a prior call to + * |persistTabs|. This is currently a synchronous operation, but in + * the future this may kick off an asynchronous mechanism to restore + * the tabs one-by-one. + */ + restoreTabs(aPersistedState, aDontRestoreFirstTab) { + let tabs = aPersistedState.tabs; + let indexToSelect = null; + + for (let [iTab, tabState] of tabs.entries()) { + if (tabState.state.firstTab && aDontRestoreFirstTab) { + tabState.state.dontRestoreFirstTab = aDontRestoreFirstTab; + } + + if (!this.restoreTab(tabState)) { + continue; + } + + // If this persisted tab was the selected one, then mark the newest + // tab as the guy to select. + if (iTab == aPersistedState.selectedIndex) { + indexToSelect = this.tabInfo.length - 1; + } + } + + if (indexToSelect != null && !aDontRestoreFirstTab) { + this.tabContainer.selectedIndex = indexToSelect; + } else { + this.tabContainer.selectedIndex = 0; + } + + if ( + this.tabContainer.allTabs.length == 1 && + this.tabContainer.mAutoHide + ) { + this.tabContainer.mCollapseToolbar.collapsed = true; + document.documentElement.setAttribute("tabbarhidden", "true"); + } + } + + clearRecentlyClosedTabs() { + this.recentlyClosedTabs.length = 0; + } + /** + * Called when the window is being unloaded, this calls the close + * function for every tab. + */ + _teardown() { + for (var i = 0; i < this.tabInfo.length; i++) { + let tab = this.tabInfo[i]; + let tabCloseFunc = tab.mode.closeTab || tab.mode.tabType.closeTab; + tabCloseFunc.call(tab.mode.tabType, tab); + } + } + + /** + * The content window of the current tab, if it is a 3-pane tab. + * + * @type {?Window} + */ + get currentAbout3Pane() { + if (this.currentTabInfo.mode.name == "mail3PaneTab") { + return this.currentTabInfo.chromeBrowser.contentWindow; + } + return null; + } + + /** + * The content window of the current tab, if it is a message tab, OR if it + * is a 3-pane tab, the content window of the message browser within. + * + * @type {?Window} + */ + get currentAboutMessage() { + switch (this.currentTabInfo.mode.name) { + case "mail3PaneTab": { + let messageBrowser = this.currentAbout3Pane.messageBrowser; + return messageBrowser && !messageBrowser.hidden + ? messageBrowser.contentWindow + : null; + } + case "mailMessageTab": + return this.currentTabInfo.chromeBrowser.contentWindow; + default: + return null; + } + } + + /** + * getBrowserForSelectedTab is required as some toolkit functions + * require a getBrowser() function. + */ + getBrowserForSelectedTab() { + if (!this.tabInfo) { + return null; + } + + if (!this.currentTabInfo) { + this.currentTabInfo = this.tabInfo[0]; + } + + if (this.currentTabInfo) { + return this.getBrowserForTab(this.currentTabInfo); + } + + return null; + } + + getBrowserForTab(aTab) { + let browserFunc = aTab + ? aTab.mode.getBrowser || aTab.mode.tabType.getBrowser + : null; + return browserFunc ? browserFunc.call(aTab.mode.tabType, aTab) : null; + } + + /** + * getBrowserForDocument is used to find the browser for a specific + * document that's been loaded + */ + getBrowserForDocument(aDocument) { + for (let i = 0; i < this.tabInfo.length; ++i) { + let browserFunc = + this.tabInfo[i].mode.getBrowser || + this.tabInfo[i].mode.tabType.getBrowser; + + if (browserFunc) { + let possBrowser = browserFunc.call( + this.tabInfo[i].mode.tabType, + this.tabInfo[i] + ); + if (possBrowser && possBrowser.contentWindow == aDocument) { + return this.tabInfo[i]; + } + } + } + + return null; + } + + /** + * getBrowserForDocumentId is used to find the browser for a specific + * document via its id attribute. + */ + getBrowserForDocumentId(aDocumentId) { + for (let i = 0; i < this.tabInfo.length; ++i) { + let browserFunc = + this.tabInfo[i].mode.getBrowser || + this.tabInfo[i].mode.tabType.getBrowser; + if (browserFunc) { + let possBrowser = browserFunc.call( + this.tabInfo[i].mode.tabType, + this.tabInfo[i] + ); + if ( + possBrowser && + possBrowser.contentDocument.documentElement.id == aDocumentId + ) { + return this.tabInfo[i]; + } + } + } + + return null; + } + + getTabForBrowser(aBrowser) { + // Check the selected browser first, since that's the most likely. + if (this.getBrowserForSelectedTab() == aBrowser) { + return this.currentTabInfo; + } + for (let tabInfo of this.tabInfo) { + if (this.getBrowserForTab(tabInfo) == aBrowser) { + return tabInfo; + } + } + return null; + } + + removeCurrentTab() { + this.removeTabByNode( + this.tabContainer.allTabs[this.tabContainer.selectedIndex] + ); + } + + switchToTab(aTabIndexNodeOrInfo) { + let [iTab] = this._getTabContextForTabbyThing(aTabIndexNodeOrInfo, false); + this.tabContainer.selectedIndex = iTab; + } + + /** + * Finds the active element and stores it on `tabInfo` for restoring focus + * when this tab next becomes active. + * + * @param {object} tabInfo + */ + rememberLastActiveElement(tabInfo) { + // Check for anything inside tabmail-container rather than the panel + // because focus could be in the Today Pane. + let activeElement = document.activeElement; + let container = document.getElementById("tabmail-container"); + if (container.contains(activeElement)) { + while (activeElement.localName == "browser") { + let next = activeElement.contentDocument?.activeElement; + if (!next || next.localName == "body") { + break; + } + activeElement = next; + } + // If the active element is inside a container, store the container + // instead of the element, so that `.focus()` returns focus to the + // right place. + tabInfo.lastActiveElement = + activeElement.closest("[aria-activedescendant]") ?? activeElement; + Services.focus.clearFocus(window); + } else { + delete tabInfo.lastActiveElement; + } + } + + /** + * UpdateCurrentTab - called in response to changing the current tab. + */ + updateCurrentTab() { + if ( + this.currentTabInfo != this.tabInfo[this.tabContainer.selectedIndex] + ) { + if (this.currentTabInfo) { + this.saveCurrentTabState(); + } + + let oldTab = this.currentTabInfo; + let oldPanel = [...this.panelContainer.children].find(p => + p.hasAttribute("selected") + ); + let tab = (this.currentTabInfo = + this.tabInfo[this.tabContainer.selectedIndex]); + // Update the selected attribute on the current and old tab panel. + if (oldPanel) { + this.rememberLastActiveElement(oldTab); + oldPanel.removeAttribute("selected"); + if (oldTab.chromeBrowser) { + oldTab.chromeBrowser.docShellIsActive = false; + } + } + + this.panelContainer.selectedPanel.setAttribute("selected", "true"); + let showTabFunc = tab.mode.showTab || tab.mode.tabType.showTab; + showTabFunc.call(tab.mode.tabType, tab); + if (tab.chromeBrowser) { + tab.chromeBrowser.docShellIsActive = true; + } + + let browser = this.getBrowserForTab(tab); + if (browser && !tab.browser) { + tab.browser = browser; + if (!tab.linkedBrowser) { + tab.linkedBrowser = browser; + } + } + + for (let tabMonitor of this.tabMonitors) { + try { + tabMonitor.onTabSwitched(tab, oldTab); + } catch (ex) { + console.error(ex); + } + } + + // always update the cursor status when we switch tabs + SetBusyCursor(window, tab.busy); + // active tabs should not have the wasBusy attribute + this.tabContainer.selectedItem.removeAttribute("wasBusy"); + // update the thinking status when we switch tabs + this._setActiveThinkingState(tab.thinking); + // active tabs should not have the wasThinking attribute + this.tabContainer.selectedItem.removeAttribute("wasThinking"); + this.setDocumentTitle(tab); + + // We switched tabs, so we don't need to know the last tab + // opener anymore. + this.mLastTabOpener = null; + + // Try to set focus where it was when the tab was last selected. + this.panelContainer.selectedPanel.focus(); + if (tab.lastActiveElement) { + tab.lastActiveElement.focus(); + delete tab.lastActiveElement; + } + + let evt = new CustomEvent("TabSelect", { + bubbles: true, + detail: { + tabInfo: tab, + previousTabInfo: oldTab, + }, + }); + this.tabContainer.selectedItem.dispatchEvent(evt); + } + } + + saveCurrentTabState() { + if (!this.currentTabInfo) { + this.currentTabInfo = this.tabInfo[0]; + } + + let tab = this.currentTabInfo; + // save the old tab state before we change the current tab + let saveTabFunc = tab.mode.saveTabState || tab.mode.tabType.saveTabState; + saveTabFunc.call(tab.mode.tabType, tab); + } + + setTabTitle(aTabNodeOrInfo) { + let [iTab, tab] = this._getTabContextForTabbyThing(aTabNodeOrInfo, true); + if (tab) { + let tabNode = this.tabContainer.allTabs[iTab]; + let titleChangeFunc = + tab.mode.onTitleChanged || tab.mode.tabType.onTitleChanged; + if (titleChangeFunc) { + titleChangeFunc.call(tab.mode.tabType, tab, tabNode); + } + + let defaultTabTitle = + document.documentElement.getAttribute("defaultTabTitle"); + let oldLabel = tabNode.getAttribute("label"); + let newLabel = aTabNodeOrInfo ? tab.title : defaultTabTitle; + if (oldLabel == newLabel) { + return; + } + + for (let tabMonitor of this.tabMonitors) { + try { + tabMonitor.onTabTitleChanged(tab); + } catch (ex) { + console.error(ex); + } + } + + // If the displayed tab is the one at the moment of creation + // (aTabNodeOrInfo is null), set the default title as its title. + tabNode.setAttribute("label", newLabel); + // Update the window title if we're the displayed tab. + if (iTab == this.tabContainer.selectedIndex) { + this.setDocumentTitle(tab); + } + + // Notify tab title change + if (!tab.beforeTabOpen) { + let evt = new CustomEvent("TabAttrModified", { + bubbles: true, + cancelable: false, + detail: { changed: ["label"], tabInfo: tab }, + }); + tabNode.dispatchEvent(evt); + } + } + } + + /** + * Set the favIconUrl for the given tab and display it as the tab's icon. + * If the given favicon is missing or loads with an error, a fallback icon + * will be displayed instead. + * + * Note that the new favIconUrl is reported to the extension API's + * tabs.onUpdated. + * + * @param {object} tabInfo - The tabInfo object for the tab. + * @param {string|null} favIconUrl - The favIconUrl to set for the given + * tab. + * @param {string} fallbackSrc - The fallback icon src to display in case + * of missing or broken favicons. + */ + setTabFavIcon(tabInfo, favIconUrl, fallbackSrc) { + let prevUrl = tabInfo.favIconUrl; + // The favIconUrl value is used by the TabmailTab _favIconUrl getter, + // which is used by the tab wrapper in the TabAttrModified callback. + tabInfo.favIconUrl = favIconUrl; + // NOTE: we always report the given favIconUrl, rather than the icon that + // is used in the tab. In particular, if the favIconUrl is null, we pass + // null rather than the fallbackIcon that is displayed. + if (favIconUrl != prevUrl && !tabInfo.beforeTabOpen) { + let evt = new CustomEvent("TabAttrModified", { + bubbles: true, + cancelable: false, + detail: { changed: ["favIconUrl"], tabInfo }, + }); + tabInfo.tabNode.dispatchEvent(evt); + } + + tabInfo.tabNode.setIcon(favIconUrl, fallbackSrc); + } + + /** + * Updates the global state to reflect the active tab's thinking + * state (which the caller provides). + */ + _setActiveThinkingState(aThinkingState) { + if (aThinkingState) { + statusFeedback.showProgress(0); + if (typeof aThinkingState == "string") { + statusFeedback.showStatusString(aThinkingState); + } + } else { + statusFeedback.showProgress(0); + } + } + + setTabThinking(aTabNodeOrInfo, aThinking) { + let [iTab, tab, tabNode] = this._getTabContextForTabbyThing( + aTabNodeOrInfo, + false + ); + let isSelected = iTab == this.tabContainer.selectedIndex; + // if we are the current tab, update the cursor + if (isSelected) { + this._setActiveThinkingState(aThinking); + } + + // if we are busy, hint our tab + if (aThinking) { + tabNode.setAttribute("thinking", "true"); + } else { + // if we were thinking and are not selected, set the + // "wasThinking" attribute. + if (tab.thinking && !isSelected) { + tabNode.setAttribute("wasThinking", "true"); + } + tabNode.removeAttribute("thinking"); + } + + // update the tab info to store the busy state. + tab.thinking = aThinking; + } + + setTabBusy(aTabNodeOrInfo, aBusy) { + let [iTab, tab, tabNode] = this._getTabContextForTabbyThing( + aTabNodeOrInfo, + false + ); + let isSelected = iTab == this.tabContainer.selectedIndex; + + // if we are the current tab, update the cursor + if (isSelected) { + SetBusyCursor(window, aBusy); + } + + // if we are busy, hint our tab + if (aBusy) { + tabNode.setAttribute("busy", "true"); + } else { + // if we were busy and are not selected, set the + // "wasBusy" attribute. + if (tab.busy && !isSelected) { + tabNode.setAttribute("wasBusy", "true"); + } + tabNode.removeAttribute("busy"); + } + + // update the tab info to store the busy state. + tab.busy = aBusy; + } + + /** + * Set the document title based on the tab title + */ + setDocumentTitle(aTab = this.selectedTab) { + let docTitle = aTab.title ? aTab.title.trim() : ""; + let docElement = document.documentElement; + // If the document title is blank, add the default title. + if (!docTitle) { + docTitle = docElement.getAttribute("defaultTabTitle"); + } + + if (docElement.hasAttribute("titlepreface")) { + docTitle = docElement.getAttribute("titlepreface") + docTitle; + } + + // If we're on Mac, don't display the separator and the modifier. + if (AppConstants.platform != "macosx") { + docTitle += + docElement.getAttribute("titlemenuseparator") + + docElement.getAttribute("titlemodifier"); + } + + document.title = docTitle; + } + + // Called by <browser>, unused by tabmail. + finishBrowserRemotenessChange(browser, loadSwitchId) {} + + /** + * Returns the find bar for a tab. + */ + getCachedFindBar(tab = this.selectedTab) { + return tab.findbar ?? null; + } + + /** + * Implementation of gBrowser's lazy-loaded find bar. We don't lazily load + * the find bar, and some of our tabs don't have a find bar. + */ + async getFindBar(tab = this.selectedTab) { + return tab.findbar ?? null; + } + + disconnectedCallback() { + window.controllers.removeController(this.tabController); + } + } + + customElements.define("tabmail", MozTabmail); +} + +/** + * Refresh the contents of the recently closed tags popup menu/panel. + * Used for example for appmenu/Go/Recently_Closed_Tabs panel. + * + * @param {Element} parent - Parent element that will contain the menu items. + * @param {string} [elementName] - Type of menu item, e.g. "menuitem", "toolbarbutton". + * @param {string} [classes] - Classes to set on the menu items. + * @param {string} [separatorName] - Type of separator, e.g. "menuseparator", "toolbarseparator". + */ +function InitRecentlyClosedTabsPopup( + parent, + elementName = "menuitem", + classes, + separatorName = "menuseparator" +) { + const tabs = document.getElementById("tabmail").recentlyClosedTabs; + + // Show Popup only when there are restorable tabs. + if (!tabs.length) { + return false; + } + + // Clear the list. + while (parent.hasChildNodes()) { + parent.lastChild.remove(); + } + + // Insert menu items to rebuild the recently closed tab list. + tabs.forEach((tab, index) => { + const item = document.createXULElement(elementName); + item.setAttribute("label", tab.title); + item.setAttribute( + "oncommand", + `document.getElementById("tabmail").undoCloseTab(${index});` + ); + if (classes) { + item.setAttribute("class", classes); + } + + if (index == 0) { + item.setAttribute("key", "key_undoCloseTab"); + } + parent.appendChild(item); + }); + + // Only show "Restore All Tabs" if there is more than one tab to restore. + if (tabs.length > 1) { + parent.appendChild(document.createXULElement(separatorName)); + + const item = document.createXULElement(elementName); + item.setAttribute( + "label", + document.getElementById("bundle_messenger").getString("restoreAllTabs") + ); + + item.addEventListener("command", () => { + let tabmail = document.getElementById("tabmail"); + let len = tabmail.recentlyClosedTabs.length; + while (len--) { + document.getElementById("tabmail").undoCloseTab(); + } + }); + + if (classes) { + item.setAttribute("class", classes); + } + parent.appendChild(item); + } + + return true; +} + +// Set up the tabContextMenu, which is used as the context menu for all tabmail +// tabs. +window.addEventListener( + "DOMContentLoaded", + () => { + let tabmail = document.getElementById("tabmail"); + let tabMenu = document.getElementById("tabContextMenu"); + + let openInWindowItem = document.getElementById( + "tabContextMenuOpenInWindow" + ); + let closeOtherTabsItem = document.getElementById( + "tabContextMenuCloseOtherTabs" + ); + let recentlyClosedMenu = document.getElementById( + "tabContextMenuRecentlyClosed" + ); + let closeItem = document.getElementById("tabContextMenuClose"); + + // Shared variable: the tabNode that was activated to open the context menu. + let currentTabInfo = null; + + tabMenu.addEventListener("popupshowing", () => { + let tabNode = tabMenu.triggerNode?.closest("tab"); + + // this happens when the user did not actually-click on a tab but + // instead on the strip behind it. + if (!tabNode) { + currentTabInfo = null; + return false; + } + + currentTabInfo = tabmail.tabInfo.find(info => info.tabNode == tabNode); + openInWindowItem.setAttribute( + "disabled", + currentTabInfo.canClose && tabmail.persistTab(currentTabInfo) + ); + closeOtherTabsItem.setAttribute( + "disabled", + tabmail.tabInfo.every(info => info == currentTabInfo || !info.canClose) + ); + recentlyClosedMenu.setAttribute( + "disabled", + !tabmail.recentlyClosedTabs.length + ); + closeItem.setAttribute("disabled", !currentTabInfo.canClose); + return true; + }); + + // Tidy up. + tabMenu.addEventListener("popuphidden", () => { + currentTabInfo = null; + }); + + openInWindowItem.addEventListener("command", () => { + tabmail.replaceTabWithWindow(currentTabInfo); + }); + closeOtherTabsItem.addEventListener("command", () => { + tabmail.closeOtherTabs(currentTabInfo); + }); + closeItem.addEventListener("command", () => { + tabmail.closeTab(currentTabInfo); + }); + + let recentlyClosedPopup = recentlyClosedMenu.querySelector("menupopup"); + recentlyClosedPopup.addEventListener("popupshowing", () => + InitRecentlyClosedTabsPopup(recentlyClosedPopup) + ); + + // Register the tabmail window font size only after everything else loaded. + UIFontSize.registerWindow(window); + }, + { once: true } +); diff --git a/comm/mail/base/content/tagDialog.inc.xhtml b/comm/mail/base/content/tagDialog.inc.xhtml new file mode 100644 index 0000000000..100efefe60 --- /dev/null +++ b/comm/mail/base/content/tagDialog.inc.xhtml @@ -0,0 +1,27 @@ +# 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/. + + <linkset> + <html:link rel="localization" href="messenger/preferences/new-tag.ftl"/> + </linkset> + + <box style="display: grid; grid-template-columns: auto 1fr; align-items: center;"> + <label id="nameLabel" + data-l10n-id="tag-name-label" + control="name"/> + <hbox class="input-container"> + <html:input id="name" + type="text" + oninput="doEnabling();" + class="input-inline" + aria-labelledby="nameLabel"/> + </hbox> + <hbox align="center"> + <label id="colorLabel" + data-l10n-id="tag-color-label" + control="tagColorPicker"/> + </hbox> + <html:input type="color" id="tagColorPicker"/> + </box> + <separator/> diff --git a/comm/mail/base/content/threadPane.js b/comm/mail/base/content/threadPane.js new file mode 100644 index 0000000000..88af9c28b0 --- /dev/null +++ b/comm/mail/base/content/threadPane.js @@ -0,0 +1,825 @@ +/* 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/. */ + +/* TODO: Now used exclusively in SearchDialog.xhtml. Needs dead code removal. */ + +/* import-globals-from folderDisplay.js */ +/* import-globals-from SearchDialog.js */ + +/* globals validateFileName */ // From utilityOverlay.js +/* globals messageFlavorDataProvider */ // From messenger.js + +ChromeUtils.defineESModuleGetters(this, { + TreeSelection: "chrome://messenger/content/tree-selection.mjs", +}); + +var gLastMessageUriToLoad = null; +var gThreadPaneCommandUpdater = null; +/** + * Tracks whether the right mouse button changed the selection or not. If the + * user right clicks on the selection, it stays the same. If they click outside + * of it, we alter the selection (but not the current index) to be the row they + * clicked on. + * + * The value of this variable is an object with "view" and "selection" keys + * and values. The view value is the view whose selection we saved off, and + * the selection value is the selection object we saved off. + */ +var gRightMouseButtonSavedSelection = null; + +/** + * When right-clicks happen, we do not want to corrupt the underlying + * selection. The right-click is a transient selection. So, unless the + * user is right-clicking on the current selection, we create a new + * selection object (thanks to TreeSelection) and set that as the + * current/transient selection. + * + * @param aSingleSelect Should the selection we create be a single selection? + * This is relevant if the row being clicked on is already part of the + * selection. If it is part of the selection and !aSingleSelect, then we + * leave the selection as is. If it is part of the selection and + * aSingleSelect then we create a transient single-row selection. + */ +function ChangeSelectionWithoutContentLoad(event, tree, aSingleSelect) { + var treeSelection = tree.view.selection; + + var row = tree.getRowAt(event.clientX, event.clientY); + // Only do something if: + // - the row is valid + // - it's not already selected (or we want a single selection) + if (row >= 0 && (aSingleSelect || !treeSelection.isSelected(row))) { + // Check if the row is exactly the existing selection. In that case + // there is no need to create a bogus selection. + if (treeSelection.count == 1) { + let minObj = {}; + treeSelection.getRangeAt(0, minObj, {}); + if (minObj.value == row) { + event.stopPropagation(); + return; + } + } + + let transientSelection = new TreeSelection(tree); + transientSelection.logAdjustSelectionForReplay(); + + gRightMouseButtonSavedSelection = { + // Need to clear out this reference later. + view: tree.view, + realSelection: treeSelection, + transientSelection, + }; + + var saveCurrentIndex = treeSelection.currentIndex; + + // tell it to log calls to adjustSelection + // attach it to the view + tree.view.selection = transientSelection; + // Don't generate any selection events! (we never set this to false, because + // that would generate an event, and we never need one of those from this + // selection object. + transientSelection.selectEventsSuppressed = true; + transientSelection.select(row); + transientSelection.currentIndex = saveCurrentIndex; + tree.ensureRowIsVisible(row); + } + event.stopPropagation(); +} + +function ThreadPaneOnDragStart(aEvent) { + if (aEvent.target.localName != "treechildren") { + return; + } + + let messageUris = gFolderDisplay.selectedMessageUris; + if (!messageUris) { + return; + } + + gFolderDisplay.hintAboutToDeleteMessages(); + let messengerBundle = document.getElementById("bundle_messenger"); + let noSubjectString = messengerBundle.getString( + "defaultSaveMessageAsFileName" + ); + if (noSubjectString.endsWith(".eml")) { + noSubjectString = noSubjectString.slice(0, -4); + } + let longSubjectTruncator = messengerBundle.getString( + "longMsgSubjectTruncator" + ); + // Clip the subject string to 124 chars to avoid problems on Windows, + // see NS_MAX_FILEDESCRIPTOR in m-c/widget/windows/nsDataObj.cpp . + const maxUncutNameLength = 124; + let maxCutNameLength = maxUncutNameLength - longSubjectTruncator.length; + let messages = new Map(); + for (let [index, msgUri] of messageUris.entries()) { + let msgService = MailServices.messageServiceFromURI(msgUri); + let msgHdr = msgService.messageURIToMsgHdr(msgUri); + let subject = msgHdr.mime2DecodedSubject || ""; + if (msgHdr.flags & Ci.nsMsgMessageFlags.HasRe) { + subject = "Re: " + subject; + } + + let uniqueFileName; + // If there is no subject, use a default name. + // If subject needs to be truncated, add a truncation character to indicate it. + if (!subject) { + uniqueFileName = noSubjectString; + } else { + uniqueFileName = + subject.length <= maxUncutNameLength + ? subject + : subject.substr(0, maxCutNameLength) + longSubjectTruncator; + } + let msgFileName = validateFileName(uniqueFileName); + let msgFileNameLowerCase = msgFileName.toLocaleLowerCase(); + + while (true) { + if (!messages[msgFileNameLowerCase]) { + messages[msgFileNameLowerCase] = 1; + break; + } else { + let postfix = "-" + messages[msgFileNameLowerCase]; + messages[msgFileNameLowerCase]++; + msgFileName = msgFileName + postfix; + msgFileNameLowerCase = msgFileNameLowerCase + postfix; + } + } + + msgFileName = msgFileName + ".eml"; + + let msgUrl = msgService.getUrlForUri(msgUri); + let separator = msgUrl.spec.includes("?") ? "&" : "?"; + + aEvent.dataTransfer.mozSetDataAt("text/x-moz-message", msgUri, index); + aEvent.dataTransfer.mozSetDataAt("text/x-moz-url", msgUrl.spec, index); + aEvent.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-url", + msgUrl.spec + separator + "fileName=" + encodeURIComponent(msgFileName), + index + ); + aEvent.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise", + new messageFlavorDataProvider(), + index + ); + aEvent.dataTransfer.mozSetDataAt( + "application/x-moz-file-promise-dest-filename", + msgFileName.replace(/(.{74}).*(.{10})$/u, "$1...$2"), + index + ); + } + aEvent.dataTransfer.effectAllowed = "copyMove"; + aEvent.dataTransfer.addElement(aEvent.target); +} + +function ThreadPaneOnDragOver(aEvent) { + let ds = Cc["@mozilla.org/widget/dragservice;1"] + .getService(Ci.nsIDragService) + .getCurrentSession(); + ds.canDrop = false; + if (!gFolderDisplay.displayedFolder.canFileMessages) { + return; + } + + let dt = aEvent.dataTransfer; + if (Array.from(dt.mozTypesAt(0)).includes("application/x-moz-file")) { + let extFile = dt.mozGetDataAt("application/x-moz-file", 0); + if (!extFile) { + return; + } + + extFile = extFile.QueryInterface(Ci.nsIFile); + if (extFile.isFile()) { + let len = extFile.leafName.length; + if (len > 4 && extFile.leafName.toLowerCase().endsWith(".eml")) { + ds.canDrop = true; + } + } + } +} + +function ThreadPaneOnDrop(aEvent) { + let dt = aEvent.dataTransfer; + for (let i = 0; i < dt.mozItemCount; i++) { + let extFile = dt.mozGetDataAt("application/x-moz-file", i); + if (!extFile) { + continue; + } + + extFile = extFile.QueryInterface(Ci.nsIFile); + if (extFile.isFile()) { + let len = extFile.leafName.length; + if (len > 4 && extFile.leafName.toLowerCase().endsWith(".eml")) { + MailServices.copy.copyFileMessage( + extFile, + gFolderDisplay.displayedFolder, + null, + false, + 1, + "", + null, + msgWindow + ); + } + } + } +} + +function TreeOnMouseDown(event) { + // Detect right mouse click and change the highlight to the row + // where the click happened without loading the message headers in + // the Folder or Thread Pane. + // Same for middle click, which will open the folder/message in a tab. + if (event.button == 2 || event.button == 1) { + // We want a single selection if this is a middle-click (button 1) + ChangeSelectionWithoutContentLoad( + event, + event.target.parentNode, + event.button == 1 + ); + } +} + +function ThreadPaneOnClick(event) { + // We only care about button 0 (left click) events. + if (event.button != 0) { + event.stopPropagation(); + return; + } + + // We already handle marking as read/flagged/junk cyclers in nsMsgDBView.cpp + // so all we need to worry about here is doubleclicks and column header. We + // get here for clicks on the "treecol" (headers) and the "scrollbarbutton" + // (scrollbar buttons) and don't want those events to cause a doubleclick. + + let t = event.target; + if (t.localName == "treecol") { + HandleColumnClick(t.id); + return; + } + + if (t.localName != "treechildren") { + return; + } + + let tree = GetThreadTree(); + // Figure out what cell the click was in. + let treeCellInfo = tree.getCellAt(event.clientX, event.clientY); + if (treeCellInfo.row == -1) { + return; + } + + if (treeCellInfo.col.id == "selectCol") { + HandleSelectColClick(event, treeCellInfo.row); + return; + } + + if (treeCellInfo.col.id == "deleteCol") { + handleDeleteColClick(event); + return; + } + + // Grouped By Sort dummy header row non cycler column doubleclick toggles the + // thread's open/closed state; tree.js handles it. Cyclers are not currently + // implemented in group header rows, a click/doubleclick there should + // select/toggle thread state. + if (gFolderDisplay.view.isGroupedByHeaderAtIndex(treeCellInfo.row)) { + if (!treeCellInfo.col.cycler) { + return; + } + if (event.detail == 1) { + gFolderDisplay.selectViewIndex(treeCellInfo.row); + } + if (event.detail == 2) { + gFolderDisplay.view.dbView.toggleOpenState(treeCellInfo.row); + } + event.stopPropagation(); + return; + } + + // Cyclers and twisties respond to single clicks, not double clicks. + if ( + event.detail == 2 && + !treeCellInfo.col.cycler && + treeCellInfo.childElt != "twisty" + ) { + ThreadPaneDoubleClick(); + } else if ( + treeCellInfo.col.id == "threadCol" && + !event.shiftKey && + (event.ctrlKey || event.metaKey) + ) { + gDBView.ExpandAndSelectThreadByIndex(treeCellInfo.row, true); + event.stopPropagation(); + } +} + +function HandleColumnClick(columnID) { + if (columnID == "selectCol") { + let treeView = gFolderDisplay.tree.view; + let selection = treeView.selection; + if (!selection) { + return; + } + if (selection.count > 0) { + selection.clearSelection(); + } else { + selection.selectAll(); + } + return; + } + + if (gFolderDisplay.COLUMNS_MAP_NOSORT.has(columnID)) { + return; + } + + let sortType = gFolderDisplay.COLUMNS_MAP.get(columnID); + let curCustomColumn = gDBView.curCustomColumn; + if (!sortType) { + // If the column isn't in the map, check if it's a custom column. + try { + // Test for the columnHandler (an error is thrown if it does not exist). + gDBView.getColumnHandler(columnID); + + // Handler is registered - set column to be the current custom column. + gDBView.curCustomColumn = columnID; + sortType = "byCustom"; + } catch (ex) { + dump( + "HandleColumnClick: No custom column handler registered for " + + "columnID: " + + columnID + + " - " + + ex + + "\n" + ); + return; + } + } + + let viewWrapper = gFolderDisplay.view; + let simpleColumns = false; + try { + simpleColumns = !Services.prefs.getBoolPref( + "mailnews.thread_pane_column_unthreads" + ); + } catch (ex) {} + + if (sortType == "byThread") { + if (simpleColumns) { + MsgToggleThreaded(); + } else if (viewWrapper.showThreaded) { + MsgReverseSortThreadPane(); + } else { + MsgSortByThread(); + } + + return; + } + + if (!simpleColumns && viewWrapper.showThreaded) { + viewWrapper.showUnthreaded = true; + MsgSortThreadPane(sortType); + return; + } + + if ( + viewWrapper.primarySortType == Ci.nsMsgViewSortType[sortType] && + (viewWrapper.primarySortType != Ci.nsMsgViewSortType.byCustom || + curCustomColumn == columnID) + ) { + MsgReverseSortThreadPane(); + } else { + MsgSortThreadPane(sortType); + } +} + +function HandleSelectColClick(event, row) { + // User wants to multiselect using the old way. + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + let tree = gFolderDisplay.tree; + let selection = tree.view.selection; + if (event.detail == 1) { + selection.toggleSelect(row); + } + + // In the selectCol, we want a double click on a thread parent to select + // and deselect all children, in threaded and grouped views. + if ( + event.detail == 2 && + tree.view.isContainerOpen(row) && + !tree.view.isContainerEmpty(row) + ) { + // On doubleclick of an open thread, select/deselect all the children. + let startRow = row + 1; + let endRow = startRow; + while (endRow < tree.view.rowCount && tree.view.getLevel(endRow) > 0) { + endRow++; + } + endRow--; + if (selection.isSelected(row)) { + selection.rangedSelect(startRow, endRow, true); + } else { + selection.clearRange(startRow, endRow); + ThreadPaneSelectionChanged(); + } + } + + // There is no longer any selection, clean up for correct state of things. + if (selection.count == 0) { + if (gFolderDisplay.displayedFolder) { + gFolderDisplay.displayedFolder.lastMessageLoaded = nsMsgKey_None; + } + gFolderDisplay._mostRecentSelectionCounts[1] = 0; + } +} + +/** + * Delete a message without selecting it or loading its content. + * + * @param {DOMEvent} event - The DOM Event. + */ +function handleDeleteColClick(event) { + // Prevent deletion if any of the modifier keys was pressed. + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + + // Simulate a right click on the message row to inherit all the validations + // and alerts coming from the "cmd_delete" command. + ChangeSelectionWithoutContentLoad( + event, + event.target.parentNode, + event.button == 1 + ); + + // Trigger the message deletion. + goDoCommand("cmd_delete"); +} + +function ThreadPaneDoubleClick() { + MsgOpenSelectedMessages(); +} + +function ThreadPaneKeyDown(event) { + if (event.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + + // Grouped By Sort dummy header row <enter> toggles the thread's open/closed + // state. Let tree.js handle it. + if ( + gFolderDisplay.view.showGroupedBySort && + gFolderDisplay.treeSelection && + gFolderDisplay.treeSelection.count == 1 && + gFolderDisplay.view.isGroupedByHeaderAtIndex( + gFolderDisplay.treeSelection.currentIndex + ) + ) { + return; + } + + // Prevent any thread that happens to be last selected (currentIndex) in a + // single or multi selection from toggling in tree.js. + event.stopImmediatePropagation(); + + ThreadPaneDoubleClick(); +} + +function MsgSortByThread() { + gFolderDisplay.view.showThreaded = true; + MsgSortThreadPane("byDate"); +} + +function MsgSortThreadPane(sortName) { + let sortType = Ci.nsMsgViewSortType[sortName]; + let grouped = gFolderDisplay.view.showGroupedBySort; + gFolderDisplay.view._threadExpandAll = Boolean( + gFolderDisplay.view._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ); + + if (!grouped) { + gFolderDisplay.view.sort(sortType, Ci.nsMsgViewSortOrder.ascending); + // Respect user's last expandAll/collapseAll choice, post sort direction change. + gFolderDisplay.restoreThreadState(); + return; + } + + // legacy behavior dictates we un-group-by-sort if we were. this probably + // deserves a UX call... + + // For non virtual folders, do not ungroup (which sorts by the going away + // sort) and then sort, as it's a double sort. + // For virtual folders, which are rebuilt in the backend in a grouped + // change, create a new view upfront rather than applying viewFlags. There + // are oddities just applying viewFlags, for example changing out of a + // custom column grouped xfvf view with the threads collapsed works (doesn't) + // differently than other variations. + // So, first set the desired sortType and sortOrder, then set viewFlags in + // batch mode, then apply it all (open a new view) with endViewUpdate(). + gFolderDisplay.view.beginViewUpdate(); + gFolderDisplay.view._sort = [[sortType, Ci.nsMsgViewSortOrder.ascending]]; + gFolderDisplay.view.showGroupedBySort = false; + gFolderDisplay.view.endViewUpdate(); + + // Virtual folders don't persist viewFlags well in the back end, + // due to a virtual folder being either 'real' or synthetic, so make + // sure it's done here. + if (gFolderDisplay.view.isVirtual) { + gFolderDisplay.view.dbView.viewFlags = gFolderDisplay.view.viewFlags; + } +} + +function MsgReverseSortThreadPane() { + let grouped = gFolderDisplay.view.showGroupedBySort; + gFolderDisplay.view._threadExpandAll = Boolean( + gFolderDisplay.view._viewFlags & Ci.nsMsgViewFlagsType.kExpandAll + ); + + // Grouped By view is special for column click sort direction changes. + if (grouped) { + if (gDBView.selection.count) { + gFolderDisplay._saveSelection(); + } + + if (gFolderDisplay.view.isSingleFolder) { + if (gFolderDisplay.view.isVirtual) { + gFolderDisplay.view.showGroupedBySort = false; + } else { + // Must ensure rows are collapsed and kExpandAll is unset. + gFolderDisplay.doCommand(Ci.nsMsgViewCommandType.collapseAll); + } + } + } + + if (gFolderDisplay.view.isSortedAscending) { + gFolderDisplay.view.sortDescending(); + } else { + gFolderDisplay.view.sortAscending(); + } + + // Restore Grouped By state post sort direction change. + if (grouped) { + if (gFolderDisplay.view.isVirtual && gFolderDisplay.view.isSingleFolder) { + MsgGroupBySort(); + } + + // Restore Grouped By selection post sort direction change. + gFolderDisplay._restoreSelection(); + } + + // Respect user's last expandAll/collapseAll choice, for both threaded and grouped + // views, post sort direction change. + gFolderDisplay.restoreThreadState(); +} + +function MsgToggleThreaded() { + if (gFolderDisplay.view.showThreaded) { + gFolderDisplay.view.showUnthreaded = true; + } else { + gFolderDisplay.view.showThreaded = true; + } +} + +function MsgSortThreaded() { + gFolderDisplay.view.showThreaded = true; +} + +function MsgGroupBySort() { + gFolderDisplay.view.showGroupedBySort = true; +} + +function MsgSortUnthreaded() { + gFolderDisplay.view.showUnthreaded = true; +} + +function MsgSortAscending() { + if ( + gFolderDisplay.view.showGroupedBySort && + gFolderDisplay.view.isSingleFolder + ) { + if (gFolderDisplay.view.isSortedDescending) { + MsgReverseSortThreadPane(); + } + + return; + } + + gFolderDisplay.view.sortAscending(); +} + +function MsgSortDescending() { + if ( + gFolderDisplay.view.showGroupedBySort && + gFolderDisplay.view.isSingleFolder + ) { + if (gFolderDisplay.view.isSortedAscending) { + MsgReverseSortThreadPane(); + } + + return; + } + + gFolderDisplay.view.sortDescending(); +} + +// XXX this should probably migrate into FolderDisplayWidget, or whatever +// FolderDisplayWidget ends up using if it refactors column management out. +function UpdateSortIndicators(sortType, sortOrder) { + // Remove the sort indicator from all the columns + let treeColumns = document.getElementById("threadCols").children; + for (let i = 0; i < treeColumns.length; i++) { + treeColumns[i].removeAttribute("sortDirection"); + } + + // show the twisties if the view is threaded + let threadCol = document.getElementById("threadCol"); + let subjectCol = document.getElementById("subjectCol"); + let sortedColumn; + // set the sort indicator on the column we are sorted by + let colID = ConvertSortTypeToColumnID(sortType); + if (colID) { + sortedColumn = document.getElementById(colID); + } + + let viewWrapper = gFolderDisplay.view; + + // the thread column is not visible when we are grouped by sort + threadCol.collapsed = viewWrapper.showGroupedBySort; + + // show twisties only when grouping or threading + if (viewWrapper.showGroupedBySort || viewWrapper.showThreaded) { + subjectCol.setAttribute("primary", "true"); + } else { + subjectCol.removeAttribute("primary"); + } + + if (sortedColumn) { + sortedColumn.setAttribute( + "sortDirection", + sortOrder == Ci.nsMsgViewSortOrder.ascending ? "ascending" : "descending" + ); + } + + // Prevent threadCol from showing the sort direction chevron. + if (viewWrapper.showThreaded) { + threadCol.removeAttribute("sortDirection"); + } +} + +function GetThreadTree() { + return document.getElementById("threadTree"); +} + +function ThreadPaneOnLoad() { + var tree = GetThreadTree(); + // We won't have the tree if we're in a message window, so exit silently + if (!tree) { + return; + } + tree.addEventListener("click", ThreadPaneOnClick, true); + tree.addEventListener( + "dblclick", + event => { + // The tree.js dblclick event handler is handling editing and toggling + // open state of the cell. We don't use editing, and we want to handle + // the toggling through the click handler (also for double click), so + // capture the dblclick event before it bubbles up and causes the + // tree.js dblclick handler to toggle open state. + event.stopPropagation(); + }, + true + ); + + // The mousedown event listener below should only be added in the thread + // pane of the mailnews 3pane window, not in the advanced search window. + if (tree.parentNode.id == "searchResultListBox") { + return; + } + + tree.addEventListener("mousedown", TreeOnMouseDown, true); + let delay = Services.prefs.getIntPref("mailnews.threadpane_select_delay"); + document.getElementById("threadTree")._selectDelay = delay; +} + +function ThreadPaneSelectionChanged() { + GetThreadTree().view.selectionChanged(); + UpdateSelectCol(); + UpdateMailSearch(); +} + +function UpdateSelectCol() { + let selectCol = document.getElementById("selectCol"); + if (!selectCol) { + return; + } + let treeView = gFolderDisplay.tree.view; + let selection = treeView.selection; + if (selection && selection.count > 0) { + if (treeView.rowCount == selection.count) { + selectCol.classList.remove("someselected"); + selectCol.classList.add("allselected"); + } else { + selectCol.classList.remove("allselected"); + selectCol.classList.add("someselected"); + } + } else { + selectCol.classList.remove("allselected"); + selectCol.classList.remove("someselected"); + } +} + +function ConvertSortTypeToColumnID(sortKey) { + var columnID; + + // Hack to turn this into an integer, if it was a string. + // It would be a string if it came from XULStore.json. + sortKey = sortKey - 0; + + switch (sortKey) { + // In the case of None, we default to the date column + // This appears to be the case in such instances as + // Global search, so don't complain about it. + case Ci.nsMsgViewSortType.byNone: + case Ci.nsMsgViewSortType.byDate: + columnID = "dateCol"; + break; + case Ci.nsMsgViewSortType.byReceived: + columnID = "receivedCol"; + break; + case Ci.nsMsgViewSortType.byAuthor: + columnID = "senderCol"; + break; + case Ci.nsMsgViewSortType.byRecipient: + columnID = "recipientCol"; + break; + case Ci.nsMsgViewSortType.bySubject: + columnID = "subjectCol"; + break; + case Ci.nsMsgViewSortType.byLocation: + columnID = "locationCol"; + break; + case Ci.nsMsgViewSortType.byAccount: + columnID = "accountCol"; + break; + case Ci.nsMsgViewSortType.byUnread: + columnID = "unreadButtonColHeader"; + break; + case Ci.nsMsgViewSortType.byStatus: + columnID = "statusCol"; + break; + case Ci.nsMsgViewSortType.byTags: + columnID = "tagsCol"; + break; + case Ci.nsMsgViewSortType.bySize: + columnID = "sizeCol"; + break; + case Ci.nsMsgViewSortType.byPriority: + columnID = "priorityCol"; + break; + case Ci.nsMsgViewSortType.byFlagged: + columnID = "flaggedCol"; + break; + case Ci.nsMsgViewSortType.byThread: + columnID = "threadCol"; + break; + case Ci.nsMsgViewSortType.byId: + columnID = "idCol"; + break; + case Ci.nsMsgViewSortType.byJunkStatus: + columnID = "junkStatusCol"; + break; + case Ci.nsMsgViewSortType.byAttachments: + columnID = "attachmentCol"; + break; + case Ci.nsMsgViewSortType.byCustom: + // TODO: either change try() catch to if (property exists) or restore the getColumnHandler() check + try { + // getColumnHandler throws an error when the ID is not handled + columnID = window.gDBView.curCustomColumn; + } catch (err) { + // error - means no handler + dump( + "ConvertSortTypeToColumnID: custom sort key but no handler for column '" + + columnID + + "'\n" + ); + columnID = "dateCol"; + } + + break; + case Ci.nsMsgViewSortType.byCorrespondent: + columnID = "correspondentCol"; + break; + default: + dump("unsupported sort key: " + sortKey + "\n"); + columnID = "dateCol"; + break; + } + return columnID; +} + +addEventListener("load", ThreadPaneOnLoad, true); diff --git a/comm/mail/base/content/threadTree.inc.xhtml b/comm/mail/base/content/threadTree.inc.xhtml new file mode 100644 index 0000000000..6151847887 --- /dev/null +++ b/comm/mail/base/content/threadTree.inc.xhtml @@ -0,0 +1,230 @@ +# 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/. + + <!-- The threadTree is shared with messenger.xhtml (MAIN_WINDOW) + and SearchDialog.xhtml (SEARCH_WINDOW). --> + <tree id="threadTree" + class="plain" + persist="lastfoldersent width" + treelines="true" + enableColumnDrag="true" + _selectDelay="250" + lastfoldersent="false" + keepcurrentinview="true" + disableKeyNavigation="true" + onkeydown="ThreadPaneKeyDown(event);" + onselect="ThreadPaneSelectionChanged();"> +#ifdef MAIN_WINDOW + <treecols is="thread-pane-treecols" id="threadCols" +#else + <treecols id="threadCols" +#endif + pickertooltiptext="&columnChooser2.tooltip;"> + + <!-- + The below code may suggest that 'ordinal' is still a supported XUL + XUL attribute. It is not. This is a crutch so that we can + continue persisting the CSS -moz-box-ordinal-group attribute, + which is the appropriate replacement for the ordinal attribute + but cannot yet be easily persisted. The code that synchronizes + the attribute with the CSS lives in + toolkit/content/widget/tree.js and is specific to tree elements. + --> + <treecol is="treecol-image" id="selectCol" + class="thread-tree-icon-header selectColumnHeader" + persist="hidden ordinal" + fixed="true" + cycler="true" + currentView="unthreaded" + hidden="true" + closemenu="none" + src="chrome://messenger/skin/icons/new/compact/checkbox.svg" + label="&selectColumn.label;" + tooltiptext="&selectColumn.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol is="treecol-image" id="threadCol" + class="thread-tree-icon-header threadColumnHeader" + persist="hidden ordinal" + fixed="true" + cycler="true" + currentView="unthreaded" +#ifdef SEARCH_WINDOW + ignoreincolumnpicker="true" + hidden="true" +#endif + closemenu="none" + src="chrome://messenger/skin/icons/new/thread-sm.svg" + label="&threadColumn.label;" + tooltiptext="&threadColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol is="treecol-image" id="flaggedCol" + class="thread-tree-icon-header flagColumnHeader" + persist="hidden ordinal sortDirection" + fixed="true" + cycler="true" + closemenu="none" + src="chrome://messenger/skin/icons/new/star-sm.svg" + label="&starredColumn.label;" + tooltiptext="&starredColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol is="treecol-image" id="attachmentCol" + class="thread-tree-icon-header attachmentColumnHeader" + persist="hidden ordinal sortDirection" + fixed="true" + closemenu="none" + src="chrome://messenger/skin/icons/new/attachment-sm.svg" + label="&attachmentColumn.label;" + tooltiptext="&attachmentColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="subjectCol" + persist="ordinal width sortDirection" + ignoreincolumnpicker="true" + closemenu="none" + label="&subjectColumn.label;" + tooltiptext="&subjectColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol is="treecol-image" id="unreadButtonColHeader" + class="thread-tree-icon-header readColumnHeader" + persist="hidden ordinal sortDirection" + fixed="true" + cycler="true" + closemenu="none" + src="chrome://messenger/skin/icons/new/unread-sm.svg" + label="&readColumn.label;" + tooltiptext="&readColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="senderCol" + persist="hidden ordinal sortDirection width" + hidden="true" + closemenu="none" + label="&fromColumn.label;" + tooltiptext="&fromColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="recipientCol" + persist="hidden ordinal sortDirection width" + hidden="true" + closemenu="none" + label="&recipientColumn.label;" + tooltiptext="&recipientColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="correspondentCol" + persist="hidden ordinal sortDirection width" + closemenu="none" + label="&correspondentColumn.label;" + tooltiptext="&correspondentColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol is="treecol-image" id="junkStatusCol" + class="thread-tree-icon-header junkStatusHeader" + persist="hidden ordinal sortDirection" + fixed="true" + cycler="true" + closemenu="none" + src="chrome://messenger/skin/icons/new/spam-sm.svg" + label="&junkStatusColumn.label;" + tooltiptext="&junkStatusColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="receivedCol" + persist="hidden ordinal sortDirection width" + hidden="true" + closemenu="none" + label="&receivedColumn.label;" + tooltiptext="&receivedColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="dateCol" + persist="hidden ordinal sortDirection width" + closemenu="none" + label="&dateColumn.label;" + tooltiptext="&dateColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="statusCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + hidden="true" + closemenu="none" + label="&statusColumn.label;" + tooltiptext="&statusColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="sizeCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + hidden="true" + closemenu="none" + label="&sizeColumn.label;" + tooltiptext="&sizeColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="tagsCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + hidden="true" + closemenu="none" + label="&tagsColumn.label;" + tooltiptext="&tagsColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="accountCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + hidden="true" + closemenu="none" + label="&accountColumn.label;" + tooltiptext="&accountColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="priorityCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + hidden="true" + closemenu="none" + label="&priorityColumn.label;" + tooltiptext="&priorityColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="unreadCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + hidden="true" + closemenu="none" + label="&unreadColumn.label;" + tooltiptext="&unreadColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="totalCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + hidden="true" + closemenu="none" + label="&totalColumn.label;" + tooltiptext="&totalColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="locationCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + closemenu="none" + label="&locationColumn.label;" + tooltiptext="&locationColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol id="idCol" + persist="hidden ordinal sortDirection width" + style="flex: 1 auto" + hidden="true" + closemenu="none" + label="&idColumn.label;" + tooltiptext="&idColumn2.tooltip;"/> + <splitter class="tree-splitter"/> + <treecol is="treecol-image" id="deleteCol" + class="thread-tree-icon-header deleteColumnHeader" + persist="hidden ordinal" + fixed="true" + cycler="true" + currentView="unthreaded" + hidden="true" + closemenu="none" + src="chrome://messenger/skin/icons/new/trash-sm.svg" + label="&deleteColumn.label;" + tooltiptext="&deleteColumn.tooltip;"/> + </treecols> +#ifdef MAIN_WINDOW + <treechildren ondragstart="ThreadPaneOnDragStart(event);" + ondragover="ThreadPaneOnDragOver(event);" + ondrop="ThreadPaneOnDrop(event);"/> +#else + <treechildren ondragstart="ThreadPaneOnDragStart(event);"/> +#endif + </tree> diff --git a/comm/mail/base/content/toolbarIconColor.js b/comm/mail/base/content/toolbarIconColor.js new file mode 100644 index 0000000000..591c86096d --- /dev/null +++ b/comm/mail/base/content/toolbarIconColor.js @@ -0,0 +1,166 @@ +/** + * 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 { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +var ToolbarIconColor = { + _windowState: { + active: false, + fullscreen: false, + tabsintitlebar: false, + }, + + init() { + this._initialized = true; + + window.addEventListener("activate", this); + window.addEventListener("deactivate", this); + window.addEventListener("toolbarvisibilitychange", this); + window.addEventListener("windowlwthemeupdate", this); + + // If the window isn't active now, we assume that it has never been active + // before and will soon become active such that inferFromText will be + // called from the initial activate event. + if (Services.focus.activeWindow == window) { + this.inferFromText("activate"); + } + }, + + uninit() { + this._initialized = false; + + window.removeEventListener("activate", this); + window.removeEventListener("deactivate", this); + window.removeEventListener("toolbarvisibilitychange", this); + window.removeEventListener("windowlwthemeupdate", this); + }, + + handleEvent(event) { + switch (event.type) { + case "activate": + case "deactivate": + case "windowlwthemeupdate": + this.inferFromText(event.type); + break; + case "toolbarvisibilitychange": + this.inferFromText(event.type, event.visible); + break; + } + }, + + // A cache of luminance values for each toolbar to avoid unnecessary calls to + // getComputedStyle(). + _toolbarLuminanceCache: new Map(), + + // A cache of the current sidebar color to avoid unnecessary conditions and + // luminance calculations. + _sidebarColorCache: null, + + inferFromText(reason, reasonValue) { + if (!this._initialized) { + return; + } + + function parseRGB(aColorString) { + let rgb = aColorString.match(/^rgba?\((\d+), (\d+), (\d+)/); + rgb.shift(); + return rgb.map(x => parseInt(x)); + } + + switch (reason) { + case "activate": // falls through. + case "deactivate": + this._windowState.active = reason === "activate"; + break; + case "fullscreen": + this._windowState.fullscreen = reasonValue; + break; + case "windowlwthemeupdate": + // Theme change, we'll need to recalculate all color values. + this._toolbarLuminanceCache.clear(); + this._sidebarColorCache = null; + break; + case "toolbarvisibilitychange": + // Toolbar changes dont require reset of the cached color values. + break; + case "tabsintitlebar": + this._windowState.tabsintitlebar = reasonValue; + break; + } + + let toolbarSelector = "toolbox > toolbar:not([collapsed=true])"; + if (AppConstants.platform == "macosx") { + toolbarSelector += ":not([type=menubar])"; + } + toolbarSelector += ", .toolbar"; + + // The getComputedStyle calls and setting the brighttext are separated in + // two loops to avoid flushing layout and making it dirty repeatedly. + let cachedLuminances = this._toolbarLuminanceCache; + let luminances = new Map(); + for (let toolbar of document.querySelectorAll(toolbarSelector)) { + // Toolbars *should* all have ids, but guard anyway to avoid blowing up. + let cacheKey = + toolbar.id && toolbar.id + JSON.stringify(this._windowState); + // Lookup cached luminance value for this toolbar in this window state. + let luminance = cacheKey && cachedLuminances.get(cacheKey); + if (isNaN(luminance)) { + let [r, g, b] = parseRGB(getComputedStyle(toolbar).color); + luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b; + if (cacheKey) { + cachedLuminances.set(cacheKey, luminance); + } + } + luminances.set(toolbar, luminance); + } + + const luminanceThreshold = 127; // In between 0 and 255 + for (let [toolbar, luminance] of luminances) { + if (luminance <= luminanceThreshold) { + toolbar.removeAttribute("brighttext"); + } else { + toolbar.setAttribute("brighttext", "true"); + } + } + + // On Linux, we need to detect if the OS theme caused a text color change in + // the sidebar icons and properly update the brighttext attribute. + if ( + reason == "activate" && + AppConstants.platform == "linux" && + Services.prefs.getCharPref("extensions.activeThemeID", "") == + "default-theme@mozilla.org" + ) { + let folderTree = document.getElementById("folderTree"); + if (!folderTree) { + return; + } + + let sidebarColor = getComputedStyle(folderTree).color; + // Interrupt if the sidebar color didn't change. + if (sidebarColor == this._sidebarColorCache) { + return; + } + + this._sidebarColorCache = sidebarColor; + + let mainWindow = document.getElementById("messengerWindow"); + if (!mainWindow) { + return; + } + + let [r, g, b] = parseRGB(sidebarColor); + let luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b; + + if (luminance <= 110) { + mainWindow.removeAttribute("lwt-tree-brighttext"); + } else { + mainWindow.setAttribute("lwt-tree-brighttext", "true"); + } + } + }, +}; diff --git a/comm/mail/base/content/troubleshootMode.js b/comm/mail/base/content/troubleshootMode.js new file mode 100644 index 0000000000..79343f3004 --- /dev/null +++ b/comm/mail/base/content/troubleshootMode.js @@ -0,0 +1,74 @@ +/* -*- Mode: JavaScript; 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 { XPIDatabase } = ChromeUtils.import( + "resource://gre/modules/addons/XPIDatabase.jsm" +); + +function restartApp() { + Services.startup.quit( + Services.startup.eForceQuit | Services.startup.eRestart + ); +} + +function deleteLocalstore() { + // Delete the xulstore file. + let xulstoreFile = Services.dirsvc.get("ProfD", Ci.nsIFile); + xulstoreFile.append("xulstore.json"); + if (xulstoreFile.exists()) { + xulstoreFile.remove(false); + } +} + +async function disableAddons() { + XPIDatabase.syncLoadDB(false); + let addons = XPIDatabase.getAddons(); + for (let addon of addons) { + if (addon.type == "theme") { + // Setting userDisabled to false on the default theme activates it, + // disables all other themes and deactivates the applied persona, if + // any. + const DEFAULT_THEME_ID = "default-theme@mozilla.org"; + if (addon.id == DEFAULT_THEME_ID) { + await XPIDatabase.updateAddonDisabledState(addon, { + userDisabled: false, + }); + } + } else { + await XPIDatabase.updateAddonDisabledState(addon, { userDisabled: true }); + } + } +} + +async function onOK(event) { + event.preventDefault(); + if (document.getElementById("resetToolbars").checked) { + deleteLocalstore(); + } + if (document.getElementById("disableAddons").checked) { + await disableAddons(); + } + restartApp(); +} + +function onCancel() { + Services.startup.quit(Services.startup.eForceQuit); +} + +function onLoad() { + document + .getElementById("tasks") + .addEventListener("CheckboxStateChange", updateOKButtonState); + + document.addEventListener("dialogaccept", onOK); + document.addEventListener("dialogcancel", onCancel); + document.addEventListener("dialogextra1", () => window.close()); +} + +function updateOKButtonState() { + document.querySelector("dialog").getButton("accept").disabled = + !document.getElementById("resetToolbars").checked && + !document.getElementById("disableAddons").checked; +} diff --git a/comm/mail/base/content/troubleshootMode.xhtml b/comm/mail/base/content/troubleshootMode.xhtml new file mode 100644 index 0000000000..767adced2c --- /dev/null +++ b/comm/mail/base/content/troubleshootMode.xhtml @@ -0,0 +1,54 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE window [ <!ENTITY % utilityDTD SYSTEM "chrome://communicator/locale/utilityOverlay.dtd"> +%utilityDTD; ]> + +<?xml-stylesheet href="chrome://global/skin/global.css"?> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="troubleshoot-mode-window" + data-l10n-attrs="title,style" + onload="onLoad();" +> + <dialog + id="safeModeDialog" + style="width: inherit" + buttons="accept,cancel,extra1" + buttonidaccept="troubleshoot-mode-change-and-restart" + buttonidcancel="troubleshoot-mode-quit" + buttonidextra1="troubleshoot-mode-continue" + buttondisabledaccept="true" + > + <script src="chrome://messenger/content/troubleshootMode.js" /> + + <linkset> + <html:link rel="localization" href="branding/brand.ftl" /> + <html:link rel="localization" href="messenger/troubleshootMode.ftl" /> + </linkset> + + <vbox> + <description data-l10n-id="troubleshoot-mode-description" /> + + <separator class="thin" /> + + <label data-l10n-id="troubleshoot-mode-description2" /> + <vbox id="tasks"> + <checkbox + id="disableAddons" + data-l10n-id="troubleshoot-mode-disable-addons" + /> + <checkbox + id="resetToolbars" + data-l10n-id="troubleshoot-mode-reset-toolbars" + /> + </vbox> + </vbox> + + <separator class="thin" /> + </dialog> +</window> diff --git a/comm/mail/base/content/utilityOverlay.js b/comm/mail/base/content/utilityOverlay.js new file mode 100644 index 0000000000..b0593647e1 --- /dev/null +++ b/comm/mail/base/content/utilityOverlay.js @@ -0,0 +1,514 @@ +/* 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/. */ + +/* globals goUpdateCommand */ // From globalOverlay.js + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); + +var gShowBiDi = false; + +function getBrowserURL() { + return AppConstants.BROWSER_CHROME_URL; +} + +// update menu items that rely on focus +function goUpdateGlobalEditMenuItems() { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_copy"); + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_selectAll"); + goUpdateCommand("cmd_delete"); + if (gShowBiDi) { + goUpdateCommand("cmd_switchTextDirection"); + } +} + +// update menu items that rely on the current selection +function goUpdateSelectEditMenuItems() { + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_copy"); + goUpdateCommand("cmd_delete"); + goUpdateCommand("cmd_selectAll"); +} + +// update menu items that relate to undo/redo +function goUpdateUndoEditMenuItems() { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); +} + +// update menu items that depend on clipboard contents +function goUpdatePasteMenuItems() { + goUpdateCommand("cmd_paste"); +} + +// update Find As You Type menu items, they rely on focus +function goUpdateFindTypeMenuItems() { + goUpdateCommand("cmd_findTypeText"); + goUpdateCommand("cmd_findTypeLinks"); +} + +/** + * Gather all descendent text under given node. + * + * @param {Node} root - The root node to gather text from. + * @returns {string} The text data under the node. + */ +function gatherTextUnder(root) { + var text = ""; + var node = root.firstChild; + var depth = 1; + while (node && depth > 0) { + // See if this node is text. + if (node.nodeType == Node.TEXT_NODE) { + // Add this text to our collection. + text += " " + node.data; + } else if (HTMLImageElement.isInstance(node)) { + // If it has an alt= attribute, add that. + var altText = node.getAttribute("alt"); + if (altText && altText != "") { + text += " " + altText; + } + } + // Find next node to test. + if (node.firstChild) { + // If it has children, go to first child. + node = node.firstChild; + depth++; + } else if (node.nextSibling) { + // No children, try next sibling. + node = node.nextSibling; + } else { + // Last resort is a sibling of an ancestor. + while (node && depth > 0) { + node = node.parentNode; + depth--; + if (node.nextSibling) { + node = node.nextSibling; + break; + } + } + } + } + // Strip leading and trailing whitespace. + text = text.trim(); + // Compress remaining whitespace. + text = text.replace(/\s+/g, " "); + return text; +} + +function GenerateValidFilename(filename, extension) { + if (filename) { + // we have a title; let's see if it's usable + // clean up the filename to make it usable and + // then trim whitespace from beginning and end + filename = validateFileName(filename).trim(); + if (filename.length > 0) { + return filename + extension; + } + } + return null; +} + +function validateFileName(aFileName) { + var re = /[\/]+/g; + if (navigator.appVersion.includes("Windows")) { + re = /[\\\/\|]+/g; + aFileName = aFileName.replace(/[\"]+/g, "'"); + aFileName = aFileName.replace(/[\*\:\?]+/g, " "); + aFileName = aFileName.replace(/[\<]+/g, "("); + aFileName = aFileName.replace(/[\>]+/g, ")"); + } else if (navigator.appVersion.includes("Macintosh")) { + re = /[\:\/]+/g; + } + + if ( + Services.prefs.getBoolPref("mail.save_msg_filename_underscores_for_space") + ) { + aFileName = aFileName.replace(/ /g, "_"); + } + + return aFileName.replace(re, "_"); +} + +function goToggleToolbar(id, elementID) { + var toolbar = document.getElementById(id); + var element = document.getElementById(elementID); + if (toolbar) { + const isHidden = toolbar.getAttribute("hidden") === "true"; + toolbar.setAttribute("hidden", !isHidden); + Services.xulStore.persist(toolbar, "hidden"); + if (element) { + element.setAttribute("checked", isHidden); + Services.xulStore.persist(element, "checked"); + } + } +} + +/** + * Toggle a splitter to show or hide some piece of UI (e.g. the message preview + * pane). + * + * @param splitterId the splliter that should be toggled + */ +function togglePaneSplitter(splitterId) { + var splitter = document.getElementById(splitterId); + var state = splitter.getAttribute("state"); + if (state == "collapsed") { + splitter.setAttribute("state", "open"); + } else { + splitter.setAttribute("state", "collapsed"); + } +} + +// openUILink handles clicks on UI elements that cause URLs to load. +// We currently only react to left click in Thunderbird. +function openUILink(url, event) { + if (!event.button) { + PlacesUtils.history + .insert({ + url, + visits: [ + { + date: new Date(), + }, + ], + }) + .catch(console.error); + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(url)); + } +} + +function openLinkText(event, what) { + switch (what) { + case "getInvolvedURL": + openUILink("https://www.thunderbird.net/get-involved/", event); + break; + case "keyboardShortcutsURL": + openUILink("https://support.mozilla.org/kb/keyboard-shortcuts/", event); + break; + case "donateURL": + openUILink( + "https://give.thunderbird.net/?utm_source=thunderbird-client&utm_medium=referral&utm_content=help-menu", + event + ); + break; + case "tourURL": + openUILink("https://www.thunderbird.net/features/", event); + break; + case "feedbackURL": + openUILink("https://connect.mozilla.org/", event); + break; + } +} + +/** + * Open a web search in the default browser for a given query. + * + * @param query the string to search for + * @param engine (optional) the search engine to use + */ +function openWebSearch(query, engine) { + return Services.search.init().then(async () => { + if (!engine) { + engine = await Services.search.getDefault(); + openLinkExternally(engine.getSubmission(query).uri.spec); + + Services.telemetry.keyedScalarAdd( + "tb.websearch.usage", + engine.name.toLowerCase(), + 1 + ); + } + }); +} + +/** + * Open the specified tab type (possibly in a new window) + * + * @param tabType the tab type to open (e.g. "contentTab") + * @param tabParams the parameters to pass to the tab + * @param where 'tab' to open in a new tab (default) or 'window' to open in a + * new window + */ +function openTab(tabType, tabParams, where) { + if (where != "window") { + let tabmail = document.getElementById("tabmail"); + if (!tabmail) { + // Try opening new tabs in an existing 3pane window + let mail3PaneWindow = Services.wm.getMostRecentWindow("mail:3pane"); + if (mail3PaneWindow) { + tabmail = mail3PaneWindow.document.getElementById("tabmail"); + mail3PaneWindow.focus(); + } + } + + if (tabmail) { + return tabmail.openTab(tabType, tabParams); + } + } + + // Either we explicitly wanted to open in a new window, or we fell through to + // here because there's no 3pane. + return window.openDialog( + "chrome://messenger/content/messenger.xhtml", + "_blank", + "chrome,dialog=no,all", + null, + { + tabType, + tabParams, + } + ); +} + +/** + * Open the specified URL as a content tab (or window) + * + * @param {string} url - The location to open. + * @param {string} [where="tab"] - 'tab' to open in a new tab or 'window' to + * open in a new window + * @param {string} [linkHandler] - See specialTabs.contentTabType.openTab. + */ +function openContentTab(url, where, linkHandler) { + return openTab("contentTab", { url, linkHandler }, where); +} + +/** + * Open the preferences page for the specified query in a new tab. + * + * @param paneID ID of prefpane to select automatically. + * @param scrollPaneTo ID of the element to scroll into view. + * @param otherArgs other prefpane specific arguments. + */ +function openPreferencesTab(paneID, scrollPaneTo, otherArgs) { + openTab("preferencesTab", { + paneID, + scrollPaneTo, + otherArgs, + onLoad(aEvent, aBrowser) { + aBrowser.contentWindow.selectPrefPane(paneID, scrollPaneTo, otherArgs); + }, + }); +} + +/** + * Open the dictionary list in a new content tab, if possible in an available + * mail:3pane window, otherwise by opening a new mail:3pane. + * + * @param where the context to open the dictionary list in (e.g. 'tab', + * 'window'). See openContentTab for more details. + */ +function openDictionaryList(where) { + let dictUrl = Services.urlFormatter.formatURLPref( + "spellchecker.dictionaries.download.url" + ); + + openContentTab(dictUrl, where); +} + +/** + * Open the privacy policy in a new content tab, if possible in an available + * mail:3pane window, otherwise by opening a new mail:3pane. + * + * @param where the context to open the privacy policy in (e.g. 'tab', + * 'window'). See openContentTab for more details. + */ +function openPrivacyPolicy(where) { + const kTelemetryInfoUrl = "toolkit.telemetry.infoURL"; + let url = Services.prefs.getCharPref(kTelemetryInfoUrl); + openContentTab(url, where); +} + +/** + * Used by the developer tools (in the toolbox process) and a few toolkit pages + * for opening URLs. + * + * Thunderbird code should avoid using this function. + * + * This is similar, but not identical, to the same function in Firefox. + * + * @param {string} url - The URL to load. + * @param {string} [where] - Ignored, only here for compatibility. + * @param {object} [openParams] - Optional parameters for changing behaviour. + */ +function openTrustedLinkIn(url, where, params = {}) { + if (!params.triggeringPrincipal) { + params.triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + } + + openLinkIn(url, where, params); +} + +/** + * Used by the developer tools (in the toolbox process) for opening URLs. + * MDN URLs get send to a browser, all others are displayed in a new window. + * + * Thunderbird code should avoid using this function. + * + * This is similar, but not identical, to the same function in Firefox. + * + * @param {string} url - The URL to load. + * @param {string} [where] - Ignored, only here for compatibility. + * @param {object} [openParams] - Optional parameters for changing behaviour. + */ +function openWebLinkIn(url, where, params = {}) { + if (url.startsWith("https://developer.mozilla.org/")) { + openLinkExternally(url); + return; + } + + if (!params.triggeringPrincipal) { + params.triggeringPrincipal = + Services.scriptSecurityManager.createNullPrincipal({}); + } + if (params.triggeringPrincipal.isSystemPrincipal) { + throw new Error( + "System principal should never be passed into openWebLinkIn()" + ); + } + + openLinkIn(url, where, params); +} + +// Thunderbird itself is not using this function. It is however called for the +// "contribute" button for add-ons in the add-on manager. We ignore all additional +// parameters including "where" and always open the link externally. We don't +// want to open donation pages in a tab due to their complexity, and we don't +// want to handle them inside Thunderbird. +function openUILinkIn( + url, + where, + aAllowThirdPartyFixup, + aPostData, + aReferrerInfo +) { + openLinkExternally(url); +} + +/** + * Loads a URL in Thunderbird. If this is a mail:3pane window, the URL opens + * in a content tab, otherwise a new window is opened. + * + * This is similar, but not identical, to the same function in Firefox. + * + * @param {string} url - The URL to load. + * @param {string} [where] - Ignored, only here for compatibility. + * @param {object} [openParams] - Optional parameters for changing behaviour. + */ +function openLinkIn(url, where, openParams) { + if (!url) { + return; + } + + if ("switchToTabHavingURI" in window) { + window.switchToTabHavingURI(url, true); + return; + } + + // If we get here, this isn't a mail:3pane window, which means it's probably + // the developer tools window and therefore a completely separate program + // from the rest of Thunderbird. Be careful what you do here. + + let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + let uri = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + uri.data = url; + args.appendElement(uri); + + let win = Services.ww.openWindow( + window, + AppConstants.BROWSER_CHROME_URL, + null, + "chrome,dialog=no,all", + args + ); + + if (openParams.resolveOnContentBrowserCreated) { + win.addEventListener("load", () => + openParams.resolveOnContentBrowserCreated(win.gBrowser.selectedBrowser) + ); + } +} + +/** + * Forces a url to open in an external application according to the protocol + * service settings. + * + * @param url A url string or an nsIURI containing the url to open. + */ +function openLinkExternally(url) { + let uri = url; + if (!(uri instanceof Ci.nsIURI)) { + uri = Services.io.newURI(url); + } + + // This can fail if there is a problem with the places database. + PlacesUtils.history + .insert({ + url, // accepts both string and nsIURI + visits: [ + { + date: new Date(), + }, + ], + }) + .catch(console.error); + + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(uri); +} + +/** + * Moved from toolkit/content/globalOverlay.js. + * For details see bug 1422720 and bug 1422721. + */ +function goSetMenuValue(aCommand, aLabelAttribute) { + var commandNode = top.document.getElementById(aCommand); + if (commandNode) { + var label = commandNode.getAttribute(aLabelAttribute); + if (label) { + commandNode.setAttribute("label", label); + } + } +} + +function goSetAccessKey(aCommand, aAccessKeyAttribute) { + var commandNode = top.document.getElementById(aCommand); + if (commandNode) { + var value = commandNode.getAttribute(aAccessKeyAttribute); + if (value) { + commandNode.setAttribute("accesskey", value); + } + } +} + +function buildHelpMenu() { + let helpTroubleshootModeItem = document.getElementById( + "helpTroubleshootMode" + ); + if (helpTroubleshootModeItem) { + helpTroubleshootModeItem.disabled = + !Services.policies.isAllowed("safeMode"); + } + let appmenu_troubleshootModeItem = document.getElementById( + "appmenu_troubleshootMode" + ); + if (appmenu_troubleshootModeItem) { + appmenu_troubleshootModeItem.disabled = + !Services.policies.isAllowed("safeMode"); + } +} diff --git a/comm/mail/base/content/viewSource.js b/comm/mail/base/content/viewSource.js new file mode 100644 index 0000000000..8fd3a9ccde --- /dev/null +++ b/comm/mail/base/content/viewSource.js @@ -0,0 +1,168 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 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/. */ + +/* globals gViewSourceUtils, internalSave, ZoomManager */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PrintUtils", + "chrome://messenger/content/printUtils.js" +); + +// Needed for printing. +window.browserDOMWindow = window.opener.browserDOMWindow; + +var gBrowser; +addEventListener("load", () => { + gBrowser = document.getElementById("content"); + gBrowser.getTabForBrowser = () => { + return null; + }; + gBrowser.addEventListener("pagetitlechanged", () => { + document.title = + document.documentElement.getAttribute("titlepreface") + + gBrowser.contentTitle + + document.documentElement.getAttribute("titlemenuseparator") + + document.documentElement.getAttribute("titlemodifier"); + }); + + if (Services.prefs.getBoolPref("view_source.wrap_long_lines", false)) { + document + .getElementById("cmd_wrapLongLines") + .setAttribute("checked", "true"); + } + + gViewSourceUtils.viewSourceInBrowser({ + ...window.arguments[0], + viewSourceBrowser: gBrowser, + }); + gBrowser.contentWindow.focus(); + + document + .getElementById("repair-text-encoding") + .setAttribute("disabled", !gBrowser.mayEnableCharacterEncodingMenu); + gBrowser.addEventListener( + "load", + () => { + document + .getElementById("repair-text-encoding") + .setAttribute("disabled", !gBrowser.mayEnableCharacterEncodingMenu); + }, + true + ); + + gBrowser.addEventListener( + "DoZoomEnlargeBy10", + () => { + ZoomManager.scrollZoomEnlarge(gBrowser); + }, + true + ); + gBrowser.addEventListener( + "DoZoomReduceBy10", + () => { + ZoomManager.scrollReduceEnlarge(gBrowser); + }, + true + ); +}); + +var viewSourceChrome = { + promptAndGoToLine() { + let actor = gViewSourceUtils.getViewSourceActor(gBrowser.browsingContext); + actor.manager.getActor("ViewSourcePage").promptAndGoToLine(); + }, + + toggleWrapping() { + let state = gBrowser.contentDocument.body.classList.toggle("wrap"); + if (state) { + document + .getElementById("cmd_wrapLongLines") + .setAttribute("checked", "true"); + } else { + document.getElementById("cmd_wrapLongLines").removeAttribute("checked"); + } + Services.prefs.setBoolPref("view_source.wrap_long_lines", state); + }, + + /** + * Called by clicks on a menuitem to force the character set detection. + */ + onForceCharacterSet() { + gBrowser.forceEncodingDetection(); + gBrowser.reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE); + }, + + /** + * Reloads the browser, bypassing the network cache. + */ + reload() { + gBrowser.reloadWithFlags( + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY | + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE + ); + }, +}; + +// viewZoomOverlay.js uses this +function getBrowser() { + return gBrowser; +} + +// Strips the |view-source:| for internalSave() +function ViewSourceSavePage() { + internalSave( + gBrowser.currentURI.spec.replace(/^view-source:/i, ""), + null, + null, + null, + null, + null, + null, + "SaveLinkTitle", + null, + null, + gBrowser.cookieJarSettings, + gBrowser.contentDocument, + null, + gBrowser.webNavigation.QueryInterface(Ci.nsIWebPageDescriptor), + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); +} + +/** Called by ContextMenuParent.sys.mjs */ +function openContextMenu({ data }, browser, actor) { + let popup = browser.ownerDocument.getElementById("viewSourceContextMenu"); + + let newEvent = document.createEvent("MouseEvent"); + let screenX = data.context.screenXDevPx / window.devicePixelRatio; + let screenY = data.context.screenYDevPx / window.devicePixelRatio; + newEvent.initNSMouseEvent( + "contextmenu", + true, + true, + null, + 0, + screenX, + screenY, + 0, + 0, + false, + false, + false, + false, + 2, + null, + 0, + data.context.mozInputSource + ); + popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent); +} diff --git a/comm/mail/base/content/viewSource.xhtml b/comm/mail/base/content/viewSource.xhtml new file mode 100644 index 0000000000..046ad41937 --- /dev/null +++ b/comm/mail/base/content/viewSource.xhtml @@ -0,0 +1,245 @@ +<?xml version="1.0"?> +# -*- Mode: HTML -*- +# 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/. + +<?xml-stylesheet href="chrome://messenger/skin/messenger.css" type="text/css"?> +<?xml-stylesheet href="chrome://messenger/skin/contextMenu.css" type="text/css"?> + +<!DOCTYPE window [ +<!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" > +%brandDTD; +<!ENTITY % baseMenuOverlayDTD SYSTEM "chrome://messenger/locale/baseMenuOverlay.dtd"> +%baseMenuOverlayDTD; +<!ENTITY % sourceDTD SYSTEM "chrome://messenger/locale/viewSource.dtd" > +%sourceDTD; +]> + +<window id="viewSource" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + contenttitlesetting="true" + title="&mainWindow.title;" + titlemodifier="&mainWindow.titlemodifier;" + titlepreface="&mainWindow.preface;" + titlemenuseparator ="&mainWindow.titlemodifierseparator;" + windowtype="navigator:view-source" + width="640" height="480" + screenX="10" screenY="10" + persist="screenX screenY width height sizemode"> + +<linkset> + <html:link rel="localization" href="branding/brand.ftl"/> + <html:link rel="localization" href="messenger/messenger.ftl"/> + <html:link rel="localization" href="messenger/menubar.ftl"/> + <html:link rel="localization" href="messenger/appmenu.ftl"/> + <html:link rel="localization" href="messenger/viewSource.ftl"/> + <html:link rel="localization" href="toolkit/global/textActions.ftl"/> + <html:link rel="localization" href="toolkit/printing/printUI.ftl" /> +</linkset> + + <script src="chrome://messenger/content/globalOverlay.js"/> + <script src="chrome://global/content/contentAreaUtils.js"/> + <script src="chrome://messenger/content/mailCore.js"/> + <script src="chrome://messenger/content/viewSource.js"/> + <script src="chrome://messenger/content/viewZoomOverlay.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + + <stringbundle id="viewSourceBundle" src="chrome://messenger/locale/viewSource.properties"/> + + <command id="cmd_savePage" oncommand="ViewSourceSavePage();"/> + <command id="cmd_print" oncommand="PrintUtils.startPrintWindow(gBrowser.browsingContext, {});"/> + <command id="cmd_close" oncommand="window.close();"/> + <command id="cmd_find" + oncommand="document.getElementById('FindToolbar').onFindCommand();"/> + <command id="cmd_findAgain" + oncommand="document.getElementById('FindToolbar').onFindAgainCommand(false);"/> + <command id="cmd_findPrevious" + oncommand="document.getElementById('FindToolbar').onFindAgainCommand(true);"/> +#ifdef XP_MACOSX + <command id="cmd_findSelection" + oncommand="document.getElementById('FindToolbar').onFindSelectionCommand();"/> +#endif + <command id="cmd_reload" oncommand="viewSourceChrome.reload();"/> + <command id="cmd_goToLine" oncommand="viewSourceChrome.promptAndGoToLine();"/> + <command id="cmd_wrapLongLines" oncommand="viewSourceChrome.toggleWrapping();"/> + <command id="cmd_textZoomReduce" oncommand="ZoomManager.reduce();"/> + <command id="cmd_textZoomEnlarge" oncommand="ZoomManager.enlarge();"/> + <command id="cmd_textZoomReset" oncommand="ZoomManager.reset();"/> + + <keyset id="viewSourceKeys"> + <key id="key_savePage" key="&savePageCmd.commandkey;" modifiers="accel" command="cmd_savePage"/> + <key id="key_print" key="&printCmd.commandkey;" modifiers="accel" command="cmd_print"/> + <key id="key_close" key="&closeCmd.commandkey;" modifiers="accel" command="cmd_close"/> + <key id="key_goToLine" key="&goToLineCmd.commandkey;" command="cmd_goToLine" modifiers="accel"/> + + <key id="key_textZoomEnlarge" key="&textEnlarge.commandkey;" command="cmd_textZoomEnlarge" modifiers="accel"/> + <key id="key_textZoomEnlarge2" key="&textEnlarge.commandkey2;" command="cmd_textZoomEnlarge" modifiers="accel"/> + <key id="key_textZoomEnlarge3" key="&textEnlarge.commandkey3;" command="cmd_textZoomEnlarge" modifiers="accel"/> + <key id="key_textZoomReduce" key="&textReduce.commandkey;" command="cmd_textZoomReduce" modifiers="accel"/> + <key id="key_textZoomReduce2" key="&textReduce.commandkey2;" command="cmd_textZoomReduce" modifiers="accel"/> + <key id="key_textZoomReset" key="&textReset.commandkey;" command="cmd_textZoomReset" modifiers="accel"/> + <key id="key_textZoomReset2" key="&textReset.commandkey2;" command="cmd_textZoomReset" modifiers="accel"/> + + <key id="key_reload" key="&reloadCmd.commandkey;" command="cmd_reload" modifiers="accel"/> + <key key="&reloadCmd.commandkey;" command="cmd_reload" modifiers="accel,shift"/> + <key keycode="VK_F5" command="cmd_reload"/> + <key keycode="VK_F5" command="cmd_reload" modifiers="accel"/> + + <key id="key_copy" data-l10n-id="text-action-copy-shortcut" modifiers="accel" command="cmd_copy"/> + <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" command="cmd_selectAll"/> + <key id="key_find" key="&findOnCmd.commandkey;" command="cmd_find" modifiers="accel"/> + <key id="key_findAgain" key="&findAgainCmd.commandkey;" command="cmd_findAgain" modifiers="accel"/> + <key id="key_findPrevious" key="&findAgainCmd.commandkey;" command="cmd_findPrevious" modifiers="accel,shift"/> +#ifdef XP_MACOSX + <key id="key_findSelection" key="&findSelectionCmd.commandkey;" command="cmd_findSelection" modifiers="accel"/> +#endif + <key keycode="&findAgainCmd.commandkey2;" command="cmd_findAgain"/> + <key keycode="&findAgainCmd.commandkey2;" command="cmd_findPrevious" modifiers="shift"/> + + <key keycode="VK_BACK" command="Browser:Back"/> + <key keycode="VK_BACK" command="Browser:Forward" modifiers="shift"/> +#ifndef XP_MACOSX + <key id="goBackKb" keycode="VK_LEFT" command="Browser:Back" modifiers="alt"/> + <key id="goForwardKb" keycode="VK_RIGHT" command="Browser:Forward" modifiers="alt"/> +#else + <key id="goBackKb" keycode="VK_LEFT" command="Browser:Back" modifiers="accel" /> + <key id="goForwardKb" keycode="VK_RIGHT" command="Browser:Forward" modifiers="accel" /> +#endif +#ifdef XP_UNIX + <key id="goBackKb2" key="&goBackCmd.commandKey;" command="Browser:Back" modifiers="accel"/> + <key id="goForwardKb2" key="&goForwardCmd.commandKey;" command="Browser:Forward" modifiers="accel"/> +#endif + <key id="key_openHelp" + oncommand="openSupportURL();" +#ifdef XP_MACOSX + key="&productHelpMac.commandkey;" + modifiers="&productHelpMac.modifiers;"/> +#else + keycode="&productHelp.commandkey;"/> +#endif + </keyset> + + <tooltip id="aHTMLTooltip" page="true"/> + + <menupopup id="viewSourceContextMenu"> + <menuitem id="cMenu_copy" + data-l10n-id="text-action-copy" + command="cmd_copy"/> + <menuseparator/> + <menuitem id="cMenu_selectAll" + data-l10n-id="text-action-select-all" + command="cmd_selectAll"/> + <menuseparator/> + <menuitem id="cMenu_find" + data-l10n-id="context-text-action-find" + command="cmd_find"/> + <menuitem id="cMenu_findAgain" + data-l10n-id="context-text-action-find-again" + command="cmd_findAgain"/> + </menupopup> + + <!-- Menu --> + <toolbox id="viewSource-toolbox"> + <toolbar type="menubar"> + <menubar id="viewSource-main-menubar"> + + <menu id="menu_file" label="&fileMenu.label;" accesskey="&fileMenu.accesskey;"> + <menupopup id="menu_FilePopup"> + <menuitem key="key_savePage" command="cmd_savePage" id="menu_savePage" + label="&savePageCmd.label;" accesskey="&savePageCmd.accesskey;"/> + <menuseparator/> + <menuitem key="key_print" command="cmd_print" id="menu_print" + label="&printCmd.label;" accesskey="&printCmd.accesskey;"/> + <menuseparator/> + <menuitem key="key_close" command="cmd_close" id="menu_close" + label="&closeCmd.label;" accesskey="&closeCmd.accesskey;"/> + </menupopup> + </menu> + + <menu id="menu_edit" label="&editMenu.label;" + accesskey="&editMenu.accesskey;"> + <menupopup id="editmenu-popup"> + <menuitem id="menu_copy" + data-l10n-id="text-action-copy" + key="key_copy" + command="cmd_copy"/> + <menuseparator/> + <menuitem id="menu_selectAll" + data-l10n-id="text-action-select-all" + key="key_selectAll" + command="cmd_selectAll"/> + <menuseparator/> + <menuitem id="menu_find" + data-l10n-id="text-action-find" + key="key_find" + command="cmd_find"/> + <menuitem id="menu_findAgain" + data-l10n-id="text-action-find-again" + key="key_findAgain" + command="cmd_findAgain"/> + <menuseparator/> + <menuitem id="menu_goToLine" key="key_goToLine" command="cmd_goToLine" + label="&goToLineCmd.label;" accesskey="&goToLineCmd.accesskey;"/> + </menupopup> + </menu> + + <menu id="menu_view" label="&viewMenu.label;" accesskey="&viewMenu.accesskey;"> + <menupopup id="viewmenu-popup"> + <menuitem id="menu_reload" command="cmd_reload" accesskey="&reloadCmd.accesskey;" + label="&reloadCmd.label;" key="key_reload"/> + <menuseparator /> + <menu id="viewTextZoomMenu" label="&menu_textSize.label;" accesskey="&menu_textSize.accesskey;"> + <menupopup> + <menuitem id="menu_textEnlarge" command="cmd_textZoomEnlarge" + label="&menu_textEnlarge.label;" accesskey="&menu_textEnlarge.accesskey;" + key="key_textZoomEnlarge"/> + <menuitem id="menu_textReduce" command="cmd_textZoomReduce" + label="&menu_textReduce.label;" accesskey="&menu_textReduce.accesskey;" + key="key_textZoomReduce"/> + <menuseparator/> + <menuitem id="menu_textReset" command="cmd_textZoomReset" + label="&menu_textReset.label;" accesskey="&menu_textReset.accesskey;" + key="key_textZoomReset"/> + </menupopup> + </menu> + + <!-- Charset Menu --> + <menuitem id="repair-text-encoding" + data-l10n-id="menu-view-repair-text-encoding" + oncommand="viewSourceChrome.onForceCharacterSet();"/> + <menuseparator/> + <menuitem id="menu_wrapLongLines" type="checkbox" command="cmd_wrapLongLines" + label="&menu_wrapLongLines.title;" accesskey="&menu_wrapLongLines.accesskey;"/> + </menupopup> + </menu> + <menu id="helpMenu" + data-l10n-id="menu-help-help-title"> + <menupopup id="menu_HelpPopup"> + <menuitem id="menu_openHelp" + data-l10n-id="appmenu-help-get-help" + key="key_openHelp" + oncommand="openSupportURL();"/> + </menupopup> + </menu> + </menubar> + </toolbar> + </toolbox> + <vbox class="printPreviewStack" flex="1"> + <browser id="content" + type="content" + name="content" + src="about:blank" + flex="1" + primary="true" + disableglobalhistory="true" + showcaret="true" + tooltip="aHTMLTooltip" + maychangeremoteness="true" + messagemanagergroup="browsers"/> + <findbar id="FindToolbar" browserid="content"/> + </vbox> + +#include tabDialogs.inc.xhtml +</window> diff --git a/comm/mail/base/content/viewZoomOverlay.js b/comm/mail/base/content/viewZoomOverlay.js new file mode 100644 index 0000000000..523386f82e --- /dev/null +++ b/comm/mail/base/content/viewZoomOverlay.js @@ -0,0 +1,153 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 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/. */ + +/* globals getBrowser */ + +/** Document Zoom Management Code + * + * Forked from M-C since we don't provide a global gBrowser variable. + * + * TODO: Move to dedicated js module - see bug 1841768. + */ + +var ZoomManager = { + get MIN() { + delete this.MIN; + return (this.MIN = Services.prefs.getIntPref("zoom.minPercent") / 100); + }, + + get MAX() { + delete this.MAX; + return (this.MAX = Services.prefs.getIntPref("zoom.maxPercent") / 100); + }, + + get useFullZoom() { + return Services.prefs.getBoolPref("browser.zoom.full"); + }, + + set useFullZoom(aVal) { + Services.prefs.setBoolPref("browser.zoom.full", aVal); + }, + + get zoom() { + return this.getZoomForBrowser(getBrowser()); + }, + + useFullZoomForBrowser(aBrowser) { + return this.useFullZoom || aBrowser.isSyntheticDocument; + }, + + getFullZoomForBrowser(aBrowser) { + if (!this.useFullZoomForBrowser(aBrowser)) { + return 1.0; + } + return this.getZoomForBrowser(aBrowser); + }, + + getZoomForBrowser(aBrowser) { + let zoom = this.useFullZoomForBrowser(aBrowser) + ? aBrowser.fullZoom + : aBrowser.textZoom; + // Round to remove any floating-point error. + return Number(zoom ? zoom.toFixed(2) : 1); + }, + + set zoom(aVal) { + this.setZoomForBrowser(getBrowser(), aVal); + }, + + setZoomForBrowser(browser, val) { + if (val < this.MIN || val > this.MAX) { + throw Components.Exception( + `invalid zoom value: ${val}`, + Cr.NS_ERROR_INVALID_ARG + ); + } + + let fullZoom = this.useFullZoomForBrowser(browser); + browser.textZoom = fullZoom ? 1 : val; + browser.fullZoom = fullZoom ? val : 1; + }, + + get zoomValues() { + var zoomValues = Services.prefs + .getCharPref("toolkit.zoomManager.zoomValues") + .split(",") + .map(parseFloat); + zoomValues.sort((a, b) => a - b); + + while (zoomValues[0] < this.MIN) { + zoomValues.shift(); + } + + while (zoomValues[zoomValues.length - 1] > this.MAX) { + zoomValues.pop(); + } + + delete this.zoomValues; + return (this.zoomValues = zoomValues); + }, + + enlarge(browser = getBrowser()) { + const i = + this.zoomValues.indexOf(this.snap(this.getZoomForBrowser(browser))) + 1; + if (i < this.zoomValues.length) { + this.setZoomForBrowser(browser, this.zoomValues[i]); + } + }, + + reduce(browser = getBrowser()) { + const i = + this.zoomValues.indexOf(this.snap(this.getZoomForBrowser(browser))) - 1; + if (i >= 0) { + this.setZoomForBrowser(browser, this.zoomValues[i]); + } + }, + + reset(browser = getBrowser()) { + this.setZoomForBrowser(browser, 1); + }, + + toggleZoom(browser = getBrowser()) { + const zoomLevel = this.getZoomForBrowser(); + + this.useFullZoom = !this.useFullZoom; + this.setZoomForBrowser(browser, zoomLevel); + }, + + snap(aVal) { + var values = this.zoomValues; + for (var i = 0; i < values.length; i++) { + if (values[i] >= aVal) { + if (i > 0 && aVal - values[i - 1] < values[i] - aVal) { + i--; + } + return values[i]; + } + } + return values[i - 1]; + }, + + scrollZoomEnlarge(messagePaneBrowser) { + let zoom = messagePaneBrowser.fullZoom; + zoom += 0.1; + let zoomMax = Services.prefs.getIntPref("zoom.maxPercent") / 100; + if (zoom > zoomMax) { + zoom = zoomMax; + } + messagePaneBrowser.fullZoom = zoom; + }, + + scrollReduceEnlarge(messagePaneBrowser) { + let zoom = messagePaneBrowser.fullZoom; + zoom -= 0.1; + let zoomMin = Services.prefs.getIntPref("zoom.minPercent") / 100; + if (zoom < zoomMin) { + zoom = zoomMin; + } + messagePaneBrowser.fullZoom = zoom; + }, +}; diff --git a/comm/mail/base/content/webextensions.css b/comm/mail/base/content/webextensions.css new file mode 100644 index 0000000000..8245fb5183 --- /dev/null +++ b/comm/mail/base/content/webextensions.css @@ -0,0 +1,106 @@ +/* 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/. */ + +@namespace xul url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +@namespace html url("http://www.w3.org/1999/xhtml"); + +/* Rules to help integrate WebExtension buttons */ + +.webextension-action > .toolbarbutton-badge-stack > .toolbarbutton-icon { + height: 18px; + width: 18px; +} + +@media not all and (min-resolution: 1.1dppx) { + /* for browserAction, composeAction and messageAction */ + :root .spaces-addon-menuitem, + .webextension-action { + list-style-image: var(--webextension-toolbar-image, inherit); + } + + /* for buttons in sidebar or sidebar menu */ + :root .spaces-addon-button img { + content: var(--webextension-toolbar-image, inherit); + } + + :root .spaces-addon-menuitem:-moz-lwtheme, + .webextension-action:-moz-lwtheme { + list-style-image: var(--webextension-toolbar-image-dark, inherit); + } + + :root .spaces-addon-button:-moz-lwtheme img { + content: var(--webextension-toolbar-image-dark, inherit); + } + + @media (prefers-color-scheme: dark) { + :root .spaces-addon-menuitem, + .webextension-action { + list-style-image: var(--webextension-toolbar-image-light, inherit) !important; + } + + :root .spaces-addon-button img { + content: var(--webextension-toolbar-image-light, inherit) !important; + } + } + + .webextension-action[cui-areatype="menu-panel"] { + list-style-image: var(--webextension-menupanel-image, inherit); + } + :root[lwt-popup-brighttext] .webextension-action[cui-areatype="menu-panel"] { + list-style-image: var(--webextension-menupanel-image-light, inherit); + } + :root:not([lwt-popup-brighttext]) .webextension-action[cui-areatype="menu-panel"]:-moz-lwtheme { + list-style-image: var(--webextension-menupanel-image-dark, inherit); + } + + .webextension-menuitem { + list-style-image: var(--webextension-menuitem-image, inherit) !important; + } +} + +/* for displays, like Retina > 1.1dppx */ +@media (min-resolution: 1.1dppx) { + :root .spaces-addon-menuitem, + .webextension-action { + list-style-image: var(--webextension-toolbar-image-2x, inherit); + } + + :root .spaces-addon-button img { + content: var(--webextension-toolbar-image-2x, inherit); + } + + :root .spaces-addon-menuitem:-moz-lwtheme, + .webextension-action:-moz-lwtheme { + list-style-image: var(--webextension-toolbar-image-2x-dark, inherit); + } + + :root .spaces-addon-button:-moz-lwtheme img { + content: var(--webextension-toolbar-image-2x-dark, inherit); + } + + @media (prefers-color-scheme: dark) { + :root .spaces-addon-menuitem, + .webextension-action { + list-style-image: var(--webextension-toolbar-image-2x-light, inherit) !important; + } + + :root .spaces-addon-button img { + content: var(--webextension-toolbar-image-2x-light, inherit) !important; + } + } + + .webextension-action[cui-areatype="menu-panel"] { + list-style-image: var(--webextension-menupanel-image-2x, inherit); + } + :root[lwt-popup-brighttext] .webextension-action[cui-areatype="menu-panel"] { + list-style-image: var(--webextension-menupanel-image-2x-light, inherit); + } + :root:not([lwt-popup-brighttext]) .webextension-action[cui-areatype="menu-panel"]:-moz-lwtheme { + list-style-image: var(--webextension-menupanel-image-2x-dark, inherit); + } + + .webextension-menuitem { + list-style-image: var(--webextension-menuitem-image-2x, inherit) !important; + } +} diff --git a/comm/mail/base/content/widgets/browserPopups.inc.xhtml b/comm/mail/base/content/widgets/browserPopups.inc.xhtml new file mode 100644 index 0000000000..468c2eb3eb --- /dev/null +++ b/comm/mail/base/content/widgets/browserPopups.inc.xhtml @@ -0,0 +1,192 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +#ifndef NO_BROWSERCONTEXT + <menupopup id="browserContext" + onpopupshowing="return browserContextOnShowing(event);" + onpopuphiding="browserContextOnHiding(event);"> + <!-- Browser navigation --> +#ifdef XP_MACOSX + <menuitem id="browserContext-back" + data-l10n-id="content-tab-menu-back-mac" + command="Browser:Back"/> + <menuitem id="browserContext-forward" + data-l10n-id="content-tab-menu-forward-mac" + command="Browser:Forward"/> + <menuitem id="browserContext-reload" + tooltip="dynamic-shortcut-tooltip" + data-l10n-id="content-tab-menu-reload-mac" + command="cmd_reload"/> + <menuitem id="browserContext-stop" + tooltip="dynamic-shortcut-tooltip" + data-l10n-id="content-tab-menu-stop-mac" + command="cmd_stop"/> +#else + <menugroup id="context-navigation"> + <menuitem id="browserContext-back" + data-l10n-id="content-tab-menu-back" + data-l10n-args='{"shortcut":""}' + class="menuitem-iconic" + command="Browser:Back"/> + <menuitem id="browserContext-forward" + data-l10n-id="content-tab-menu-forward" + data-l10n-args='{"shortcut":""}' + class="menuitem-iconic" + command="Browser:Forward"/> + <menuitem id="browserContext-reload" + class="menuitem-iconic" + tooltip="dynamic-shortcut-tooltip" + data-l10n-id="content-tab-menu-reload" + command="cmd_reload"/> + <menuitem id="browserContext-stop" + class="menuitem-iconic" + tooltip="dynamic-shortcut-tooltip" + data-l10n-id="content-tab-menu-stop" + command="cmd_stop"/> + </menugroup> +#endif + <menuseparator id="browserContext-sep-navigation"/> + <!-- Spellchecking suggestions --> + <menuitem id="browserContext-spell-no-suggestions" + disabled="true" + data-l10n-id="text-action-spell-no-suggestions"/> + <menuitem id="browserContext-spell-add-to-dictionary" + data-l10n-id="text-action-spell-add-to-dictionary" + oncommand="gSpellChecker.addToDictionary();"/> + <menuitem id="browserContext-spell-undo-add-to-dictionary" + data-l10n-id="text-action-spell-undo-add-to-dictionary" + oncommand="gSpellChecker.undoAddToDictionary();" /> + <menuseparator id="browserContext-spell-suggestions-separator"/> + + <menuitem id="browserContext-openInBrowser" + label="&openInBrowser.label;" + accesskey="&openInBrowser.accesskey;" + oncommand="gContextMenu.openInBrowser();"/> + <menuitem id="browserContext-openLinkInBrowser" + label="&openLinkInBrowser.label;" + accesskey="&openLinkInBrowser.accesskey;" + oncommand="gContextMenu.openLinkInBrowser();"/> + <menuseparator id="browserContext-sep-open-browser"/> + <menuitem id="browserContext-undo" + label="&undoDefaultCmd.label;" + accesskey="&undoDefaultCmd.accesskey;" + command="cmd_undo"/> + <menuseparator id="browserContext-sep-undo"/> + <menuitem id="browserContext-cut" + data-l10n-id="text-action-cut" + command="cmd_copy"/> + <menuitem id="browserContext-copy" + data-l10n-id="text-action-copy" + command="cmd_copy"/> + <menuitem id="browserContext-paste" + data-l10n-id="text-action-paste" + command="cmd_paste"/> + <menuitem id="browserContext-selectall" + data-l10n-id="text-action-select-all" + command="cmd_selectAll"/> + <menuseparator id="browserContext-sep-clipboard"/> + + <menuitem id="browserContext-searchTheWeb" + label="[glodaComplete.webSearch1.label]" + oncommand="openWebSearch(event.target.value)"/> + + <!-- Spellchecking general menu items (enable, add dictionaries...) --> + <menuseparator id="browserContext-spell-separator"/> + <menuitem id="browserContext-spell-check-enabled" + data-l10n-id="text-action-spell-check-toggle" + type="checkbox" + oncommand="gSpellChecker.toggleEnabled();"/> + <menuitem id="browserContext-spell-add-dictionaries-main" + label="&spellAddDictionaries.label;" + accesskey="&spellAddDictionaries.accesskey;" + oncommand="gContextMenu.addDictionaries();"/> + <menu id="browserContext-spell-dictionaries" + data-l10n-id="text-action-spell-dictionaries"> + <menupopup id="browserContext-spell-dictionaries-menu"> + <menuseparator id="browserContext-spell-language-separator"/> + <menuitem id="browserContext-spell-add-dictionaries" + label="&spellAddDictionaries.label;" + accesskey="&spellAddDictionaries.accesskey;" + oncommand="gContextMenu.addDictionaries();"/> + </menupopup> + </menu> + + <menuitem id="browserContext-media-play" + label="&contextPlay.label;" + accesskey="&contextPlay.accesskey;" + oncommand="gContextMenu.mediaCommand('play');"/> + <menuitem id="browserContext-media-pause" + label="&contextPause.label;" + accesskey="&contextPause.accesskey;" + oncommand="gContextMenu.mediaCommand('pause');"/> + <menuitem id="browserContext-media-mute" + label="&contextMute.label;" + accesskey="&contextMute.accesskey;" + oncommand="gContextMenu.mediaCommand('mute');"/> + <menuitem id="browserContext-media-unmute" + label="&contextUnmute.label;" + accesskey="&contextUnmute.accesskey;" + oncommand="gContextMenu.mediaCommand('unmute');"/> + <menuseparator id="browserContext-sep-edit"/> + <menuitem id="browserContext-copylink" + label="©LinkCmd.label;" + accesskey="©LinkCmd.accesskey;" + command="cmd_copyLink"/> + <menuitem id="browserContext-copyimage" + label="©ImageAllCmd.label;" + accesskey="©ImageAllCmd.accesskey;" + command="cmd_copyImage"/> + <menuitem id="browserContext-addemail" + label="&AddToAddressBook.label;" + accesskey="&AddToAddressBook.accesskey;" + oncommand="addEmail(gContextMenu.linkURL);"/> + <menuitem id="browserContext-composeemailto" + label="&SendMessageTo.label;" + accesskey="&SendMessageTo.accesskey;" + oncommand="composeEmailTo(gContextMenu.linkURL);"/> + <menuitem id="browserContext-copyemail" + label="©EmailCmd.label;" + accesskey="©EmailCmd.accesskey;" + oncommand="gContextMenu.copyEmail();"/> + <menuseparator id="browserContext-sep-copy"/> + <menuitem id="browserContext-savelink" + label="&saveLinkAsCmd.label;" + accesskey="&saveLinkAsCmd.accesskey;" + oncommand="gContextMenu.saveLink();"/> + <menuitem id="browserContext-saveimage" + label="&saveImageAsCmd.label;" + accesskey="&saveImageAsCmd.accesskey;" + oncommand="gContextMenu.saveImage();"/> + </menupopup> +#endif + <panel id="DateTimePickerPanel" + type="arrow" + orient="vertical" + noautofocus="true" + norolluponanchor="true" + consumeoutsideclicks="never" + level="top" + tabspecific="true"> + </panel> + + <!-- For select dropdowns. The menupopup is what shows the list of options, + and the popuponly menulist makes things like the menuactive attributes + work correctly on the menupopup. ContentSelectDropdown expects the + popuponly menulist to be its immediate parent. --> + <menulist popuponly="true" id="ContentSelectDropdown" hidden="true"> + <menupopup rolluponmousewheel="true" + activateontab="true" position="after_start" + level="parent" +#ifdef XP_WIN + consumeoutsideclicks="false" ignorekeys="shortcuts" +#endif + /> + </menulist> + + <panel is="autocomplete-richlistbox-popup" id="PopupAutoComplete" + type="autocomplete" + role="group" + noautofocus="true"/> + + <tooltip id="remoteBrowserTooltip"/> diff --git a/comm/mail/base/content/widgets/browserPopups.js b/comm/mail/base/content/widgets/browserPopups.js new file mode 100644 index 0000000000..f6d2a2139f --- /dev/null +++ b/comm/mail/base/content/widgets/browserPopups.js @@ -0,0 +1,991 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../utilityOverlay.js */ + +/* globals saveURL */ // From contentAreaUtils.js +/* globals goUpdateCommand */ // From globalOverlay.js + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { InlineSpellChecker, SpellCheckHelper } = ChromeUtils.importESModule( + "resource://gre/modules/InlineSpellChecker.sys.mjs" +); +var { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +var { ShortcutUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ShortcutUtils.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +ChromeUtils.defineModuleGetter( + this, + "MailUtils", + "resource:///modules/MailUtils.jsm" +); +var { E10SUtils } = ChromeUtils.importESModule( + "resource://gre/modules/E10SUtils.sys.mjs" +); + +var gContextMenu; +var gSpellChecker = new InlineSpellChecker(); + +/** Called by ContextMenuParent.sys.mjs */ +function openContextMenu({ data }, browser, actor) { + if (!browser.hasAttribute("context")) { + return; + } + + let wgp = actor.manager; + + if (!wgp.isCurrentGlobal) { + // Don't display context menus for unloaded documents + return; + } + + // NOTE: We don't use `wgp.documentURI` here as we want to use the failed + // channel URI in the case we have loaded an error page. + let documentURIObject = wgp.browsingContext.currentURI; + + let frameReferrerInfo = data.frameReferrerInfo; + if (frameReferrerInfo) { + frameReferrerInfo = E10SUtils.deserializeReferrerInfo(frameReferrerInfo); + } + + let linkReferrerInfo = data.linkReferrerInfo; + if (linkReferrerInfo) { + linkReferrerInfo = E10SUtils.deserializeReferrerInfo(linkReferrerInfo); + } + + let frameID = nsContextMenu.WebNavigationFrames.getFrameId( + wgp.browsingContext + ); + + nsContextMenu.contentData = { + context: data.context, + browser, + actor, + editFlags: data.editFlags, + spellInfo: data.spellInfo, + principal: wgp.documentPrincipal, + storagePrincipal: wgp.documentStoragePrincipal, + documentURIObject, + docLocation: data.docLocation, + charSet: data.charSet, + referrerInfo: E10SUtils.deserializeReferrerInfo(data.referrerInfo), + frameReferrerInfo, + linkReferrerInfo, + contentType: data.contentType, + contentDisposition: data.contentDisposition, + frameID, + frameOuterWindowID: frameID, + frameBrowsingContext: wgp.browsingContext, + selectionInfo: data.selectionInfo, + disableSetDesktopBackground: data.disableSetDesktopBackground, + loginFillInfo: data.loginFillInfo, + parentAllowsMixedContent: data.parentAllowsMixedContent, + userContextId: wgp.browsingContext.originAttributes.userContextId, + webExtContextData: data.webExtContextData, + cookieJarSettings: wgp.cookieJarSettings, + }; + + // Note: `popup` must be in `document`, but `browser` might be in a + // different document, such as about:3pane. + let popup = document.getElementById(browser.getAttribute("context")); + let context = nsContextMenu.contentData.context; + + // Fill in some values in the context from the WindowGlobalParent actor. + context.principal = wgp.documentPrincipal; + context.storagePrincipal = wgp.documentStoragePrincipal; + context.frameID = frameID; + context.frameOuterWindowID = wgp.outerWindowId; + context.frameBrowsingContextID = wgp.browsingContext.id; + + // We don't have access to the original event here, as that happened in + // another process. Therefore we synthesize a new MouseEvent to propagate the + // inputSource to the subsequently triggered popupshowing event. + let newEvent = document.createEvent("MouseEvent"); + let screenX = context.screenXDevPx / window.devicePixelRatio; + let screenY = context.screenYDevPx / window.devicePixelRatio; + newEvent.initNSMouseEvent( + "contextmenu", + true, + true, + null, + 0, + screenX, + screenY, + 0, + 0, + false, + false, + false, + false, + 2, + null, + 0, + context.mozInputSource + ); + popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent); +} + +/** + * Function to set the global nsContextMenu. Called by popupshowing on browserContext. + * + * @param {Event} event - The onpopupshowing event. + * @returns {boolean} + */ +function browserContextOnShowing(event) { + if (event.target.id != "browserContext") { + return true; + } + + gContextMenu = new nsContextMenu(event.target, event.shiftKey); + return gContextMenu.shouldDisplay; +} + +/** + * Function to clear out the global nsContextMenu. + * + * @param {Event} event - The onpopuphiding event. + */ +function browserContextOnHiding(event) { + if (event.target.id != "browserContext") { + return; + } + + gContextMenu.hiding(); + gContextMenu = null; +} + +class nsContextMenu { + constructor(aXulMenu, aIsShift) { + this.xulMenu = aXulMenu; + + // Get contextual info. + this.setContext(); + + if (!this.shouldDisplay) { + return; + } + + this.isContentSelected = + !this.selectionInfo || !this.selectionInfo.docSelectionIsCollapsed; + + if (!aIsShift) { + // The rest of this block sends menu information to WebExtensions. + let subject = { + menu: aXulMenu, + tab: document.getElementById("tabmail") + ? document.getElementById("tabmail").currentTabInfo + : window, + timeStamp: this.timeStamp, + isContentSelected: this.isContentSelected, + inFrame: this.inFrame, + isTextSelected: this.isTextSelected, + onTextInput: this.onTextInput, + onLink: this.onLink, + onImage: this.onImage, + onVideo: this.onVideo, + onAudio: this.onAudio, + onCanvas: this.onCanvas, + onEditable: this.onEditable, + onSpellcheckable: this.onSpellcheckable, + onPassword: this.onPassword, + srcUrl: this.mediaURL, + frameUrl: this.contentData ? this.contentData.docLocation : undefined, + pageUrl: this.browser ? this.browser.currentURI.spec : undefined, + linkText: this.linkTextStr, + linkUrl: this.linkURL, + selectionText: this.isTextSelected + ? this.selectionInfo.fullText + : undefined, + frameId: this.frameID, + webExtBrowserType: this.webExtBrowserType, + webExtContextData: this.contentData + ? this.contentData.webExtContextData + : undefined, + }; + + subject.wrappedJSObject = subject; + Services.obs.notifyObservers(subject, "on-build-contextmenu"); + } + + // Reset after "on-build-contextmenu" notification in case selection was + // changed during the notification. + this.isContentSelected = + !this.selectionInfo || !this.selectionInfo.docSelectionIsCollapsed; + this.initItems(); + + // If all items in the menu are hidden, set this.shouldDisplay to false + // so that the callers know to not even display the empty menu. + let contextPopup = document.getElementById("browserContext"); + for (let item of contextPopup.children) { + if (!item.hidden) { + return; + } + } + + // All items must have been hidden. + this.shouldDisplay = false; + } + + setContext() { + let context = Object.create(null); + + if (nsContextMenu.contentData) { + this.contentData = nsContextMenu.contentData; + context = this.contentData.context; + nsContextMenu.contentData = null; + } + + this.shouldDisplay = !this.contentData || context.shouldDisplay; + this.timeStamp = context.timeStamp; + + // Assign what's _possibly_ needed from `context` sent by ContextMenuChild.sys.mjs + // Keep this consistent with the similar code in ContextMenu's _setContext + this.bgImageURL = context.bgImageURL; + this.imageDescURL = context.imageDescURL; + this.imageInfo = context.imageInfo; + this.mediaURL = context.mediaURL; + + this.canSpellCheck = context.canSpellCheck; + this.hasBGImage = context.hasBGImage; + this.hasMultipleBGImages = context.hasMultipleBGImages; + this.isDesignMode = context.isDesignMode; + this.inFrame = context.inFrame; + this.inPDFViewer = context.inPDFViewer; + this.inSrcdocFrame = context.inSrcdocFrame; + this.inSyntheticDoc = context.inSyntheticDoc; + + this.link = context.link; + this.linkDownload = context.linkDownload; + this.linkProtocol = context.linkProtocol; + this.linkTextStr = context.linkTextStr; + this.linkURL = context.linkURL; + this.linkURI = this.getLinkURI(); // can't send; regenerate + + this.onAudio = context.onAudio; + this.onCanvas = context.onCanvas; + this.onCompletedImage = context.onCompletedImage; + this.onDRMMedia = context.onDRMMedia; + this.onPiPVideo = context.onPiPVideo; + this.onEditable = context.onEditable; + this.onImage = context.onImage; + this.onKeywordField = context.onKeywordField; + this.onLink = context.onLink; + this.onLoadedImage = context.onLoadedImage; + this.onMailtoLink = context.onMailtoLink; + this.onMozExtLink = context.onMozExtLink; + this.onNumeric = context.onNumeric; + this.onPassword = context.onPassword; + this.onSaveableLink = context.onSaveableLink; + this.onSpellcheckable = context.onSpellcheckable; + this.onTextInput = context.onTextInput; + this.onVideo = context.onVideo; + + this.target = context.target; + this.targetIdentifier = context.targetIdentifier; + + this.principal = context.principal; + this.storagePrincipal = context.storagePrincipal; + this.frameID = context.frameID; + this.frameOuterWindowID = context.frameOuterWindowID; + this.frameBrowsingContext = BrowsingContext.get( + context.frameBrowsingContextID + ); + + this.inSyntheticDoc = context.inSyntheticDoc; + this.inAboutDevtoolsToolbox = context.inAboutDevtoolsToolbox; + + // Everything after this isn't sent directly from ContextMenu + if (this.target) { + this.ownerDoc = this.target.ownerDocument; + } + + this.csp = E10SUtils.deserializeCSP(context.csp); + + if (!this.contentData) { + return; + } + + this.browser = this.contentData.browser; + if (this.browser && this.browser.currentURI.spec == "about:blank") { + this.shouldDisplay = false; + return; + } + this.selectionInfo = this.contentData.selectionInfo; + this.actor = this.contentData.actor; + + this.textSelected = this.selectionInfo?.text; + this.isTextSelected = !!this.textSelected?.length; + + this.webExtBrowserType = this.browser.getAttribute( + "webextension-view-type" + ); + + if (context.shouldInitInlineSpellCheckerUINoChildren) { + gSpellChecker.initFromRemote( + this.contentData.spellInfo, + this.actor.manager + ); + } + + if (this.contentData.spellInfo) { + this.spellSuggestions = this.contentData.spellInfo.spellSuggestions; + } + + if (context.shouldInitInlineSpellCheckerUIWithChildren) { + gSpellChecker.initFromRemote( + this.contentData.spellInfo, + this.actor.manager + ); + let canSpell = gSpellChecker.canSpellCheck && this.canSpellCheck; + this.showItem("browserContext-spell-check-enabled", canSpell); + this.showItem("browserContext-spell-separator", canSpell); + } + } + + hiding() { + if (this.actor) { + this.actor.hiding(); + } + + this.contentData = null; + gSpellChecker.clearSuggestionsFromMenu(); + gSpellChecker.clearDictionaryListFromMenu(); + gSpellChecker.uninit(); + } + + initItems() { + this.initSaveItems(); + this.initClipboardItems(); + this.initMediaPlayerItems(); + this.initBrowserItems(); + this.initSpellingItems(); + this.initSeparators(); + } + addDictionaries() { + openDictionaryList(); + } + initSpellingItems() { + let canSpell = + gSpellChecker.canSpellCheck && + !gSpellChecker.initialSpellCheckPending && + this.canSpellCheck; + let showDictionaries = canSpell && gSpellChecker.enabled; + let onMisspelling = gSpellChecker.overMisspelling; + let showUndo = canSpell && gSpellChecker.canUndo(); + this.showItem("browserContext-spell-check-enabled", canSpell); + this.showItem("browserContext-spell-separator", canSpell); + document + .getElementById("browserContext-spell-check-enabled") + .setAttribute("checked", canSpell && gSpellChecker.enabled); + + this.showItem("browserContext-spell-add-to-dictionary", onMisspelling); + this.showItem("browserContext-spell-undo-add-to-dictionary", showUndo); + + // suggestion list + this.showItem( + "browserContext-spell-suggestions-separator", + onMisspelling || showUndo + ); + if (onMisspelling) { + let addMenuItem = document.getElementById( + "browserContext-spell-add-to-dictionary" + ); + let suggestionCount = gSpellChecker.addSuggestionsToMenu( + addMenuItem.parentNode, + addMenuItem, + this.spellSuggestions + ); + this.showItem( + "browserContext-spell-no-suggestions", + suggestionCount == 0 + ); + } else { + this.showItem("browserContext-spell-no-suggestions", false); + } + + // dictionary list + this.showItem("browserContext-spell-dictionaries", showDictionaries); + if (canSpell) { + let dictMenu = document.getElementById( + "browserContext-spell-dictionaries-menu" + ); + let dictSep = document.getElementById( + "browserContext-spell-language-separator" + ); + let count = gSpellChecker.addDictionaryListToMenu(dictMenu, dictSep); + this.showItem(dictSep, count > 0); + this.showItem("browserContext-spell-add-dictionaries-main", false); + } else if (this.onSpellcheckable) { + // when there is no spellchecker but we might be able to spellcheck + // add the add to dictionaries item. This will ensure that people + // with no dictionaries will be able to download them + this.showItem( + "browserContext-spell-language-separator", + showDictionaries + ); + this.showItem( + "browserContext-spell-add-dictionaries-main", + showDictionaries + ); + } else { + this.showItem("browserContext-spell-add-dictionaries-main", false); + } + } + initSaveItems() { + this.showItem("browserContext-savelink", this.onSaveableLink); + this.showItem("browserContext-saveimage", this.onLoadedImage); + } + initClipboardItems() { + // Copy depends on whether there is selected text. + // Enabling this context menu item is now done through the global + // command updating system. + + goUpdateGlobalEditMenuItems(); + + this.showItem("browserContext-cut", this.onTextInput); + this.showItem( + "browserContext-copy", + !this.onPlayableMedia && (this.isContentSelected || this.onTextInput) + ); + this.showItem("browserContext-paste", this.onTextInput); + + this.showItem("browserContext-undo", this.onTextInput); + // Select all not available in the thread pane or on playable media. + this.showItem("browserContext-selectall", !this.onPlayableMedia); + this.showItem("browserContext-copyemail", this.onMailtoLink); + this.showItem("browserContext-copylink", this.onLink && !this.onMailtoLink); + this.showItem("browserContext-copyimage", this.onImage); + + this.showItem("browserContext-composeemailto", this.onMailtoLink); + this.showItem("browserContext-addemail", this.onMailtoLink); + + let searchTheWeb = document.getElementById("browserContext-searchTheWeb"); + this.showItem( + searchTheWeb, + !this.onPlayableMedia && this.isContentSelected + ); + + if (!searchTheWeb.hidden) { + let selection = this.textSelected; + + let bundle = document.getElementById("bundle_messenger"); + let key = "openSearch.label"; + let abbrSelection; + if (selection.length > 15) { + key += ".truncated"; + abbrSelection = selection.slice(0, 15); + } else { + abbrSelection = selection; + } + + searchTheWeb.label = bundle.getFormattedString(key, [ + Services.search.defaultEngine.name, + abbrSelection, + ]); + searchTheWeb.value = selection; + } + } + initMediaPlayerItems() { + let onMedia = this.onVideo || this.onAudio; + // Several mutually exclusive items.... play/pause, mute/unmute, show/hide + this.showItem("browserContext-media-play", onMedia && this.target.paused); + this.showItem("browserContext-media-pause", onMedia && !this.target.paused); + this.showItem("browserContext-media-mute", onMedia && !this.target.muted); + this.showItem("browserContext-media-unmute", onMedia && this.target.muted); + if (onMedia) { + let hasError = + this.target.error != null || + this.target.networkState == this.target.NETWORK_NO_SOURCE; + this.setItemAttr("browserContext-media-play", "disabled", hasError); + this.setItemAttr("browserContext-media-pause", "disabled", hasError); + this.setItemAttr("browserContext-media-mute", "disabled", hasError); + this.setItemAttr("browserContext-media-unmute", "disabled", hasError); + } + } + initBackForwardMenuItemTooltip(menuItemId, l10nId, shortcutId) { + // On macOS regular menuitems are used and the shortcut isn't added. + if (AppConstants.platform == "macosx") { + return; + } + + let shortcut = document.getElementById(shortcutId); + if (shortcut) { + shortcut = ShortcutUtils.prettifyShortcut(shortcut); + } else { + // Sidebar doesn't have navigation buttons or shortcuts, but we still + // want to format the menu item tooltip to remove "$shortcut" string. + shortcut = ""; + } + let menuItem = document.getElementById(menuItemId); + document.l10n.setAttributes(menuItem, l10nId, { shortcut }); + } + initBrowserItems() { + // Work out if we are a context menu on a special item e.g. an image, link + // etc. + let onSpecialItem = + this.isContentSelected || + this.onCanvas || + this.onLink || + this.onImage || + this.onAudio || + this.onVideo || + this.onTextInput; + + // Internal about:* pages should not show nav items. + let shouldShowNavItems = + !onSpecialItem && this.browser.currentURI.scheme != "about"; + + // Ensure these commands are updated with their current status. + if (shouldShowNavItems) { + goUpdateCommand("Browser:Back"); + goUpdateCommand("Browser:Forward"); + goUpdateCommand("cmd_stop"); + goUpdateCommand("cmd_reload"); + } + + let stopped = document.getElementById("cmd_stop").hasAttribute("disabled"); + this.showItem("browserContext-reload", shouldShowNavItems && stopped); + this.showItem("browserContext-stop", shouldShowNavItems && !stopped); + this.showItem("browserContext-sep-navigation", shouldShowNavItems); + + if (AppConstants.platform == "macosx") { + this.showItem("browserContext-back", shouldShowNavItems); + this.showItem("browserContext-forward", shouldShowNavItems); + } else { + this.showItem("context-navigation", shouldShowNavItems); + + this.initBackForwardMenuItemTooltip( + "browserContext-back", + "content-tab-menu-back", + "key_goBackKb" + ); + this.initBackForwardMenuItemTooltip( + "browserContext-forward", + "content-tab-menu-forward", + "key_goForwardKb" + ); + } + + // Only show open in browser if we're not on a special item and we're not + // on an about: or chrome: protocol - for these protocols the browser is + // unlikely to show the same thing as we do (if at all), so therefore don't + // offer the option. + this.showItem( + "browserContext-openInBrowser", + !onSpecialItem && + ["http", "https"].includes(this.contentData?.documentURIObject?.scheme) + ); + + // Only show browserContext-openLinkInBrowser if we're on a link and it isn't + // a mailto link. + this.showItem( + "browserContext-openLinkInBrowser", + this.onLink && ["http", "https"].includes(this.linkProtocol) + ); + } + initSeparators() { + let separators = Array.from( + this.xulMenu.querySelectorAll(":scope > menuseparator") + ); + let lastShownSeparator = null; + for (let separator of separators) { + let shouldShow = this.shouldShowSeparator(separator); + if ( + !shouldShow && + lastShownSeparator && + separator.classList.contains("webextension-group-separator") + ) { + // The separator for the WebExtension elements group must be shown, hide + // the last shown menu separator instead. + lastShownSeparator.hidden = true; + shouldShow = true; + } + if (shouldShow) { + lastShownSeparator = separator; + } + separator.hidden = !shouldShow; + } + this.checkLastSeparator(this.xulMenu); + } + + /** + * Get a computed style property for an element. + * + * @param aElem + * A DOM node + * @param aProp + * The desired CSS property + * @returns the value of the property + */ + getComputedStyle(aElem, aProp) { + return aElem.ownerGlobal.getComputedStyle(aElem).getPropertyValue(aProp); + } + + /** + * Determine whether the clicked-on link can be saved, and whether it + * may be saved according to the ScriptSecurityManager. + * + * @returns true if the protocol can be persisted and if the target has + * permission to link to the URL, false if not + */ + isLinkSaveable() { + try { + Services.scriptSecurityManager.checkLoadURIWithPrincipal( + this.target.nodePrincipal, + this.linkURI, + Ci.nsIScriptSecurityManager.STANDARD + ); + } catch (e) { + // Don't save things we can't link to. + return false; + } + + // We don't do the Right Thing for news/snews yet, so turn them off + // until we do. + return ( + this.linkProtocol && + !( + this.linkProtocol == "mailto" || + this.linkProtocol == "javascript" || + this.linkProtocol == "news" || + this.linkProtocol == "snews" + ) + ); + } + + /** + * Save URL of clicked-on link. + */ + saveLink() { + saveURL( + this.linkURL, + null, + this.linkTextStr, + null, + true, + null, + null, + null, + document + ); + } + + /** + * Save a clicked-on image. + */ + saveImage() { + saveURL( + this.imageInfo.currentSrc, + null, + null, + "SaveImageTitle", + false, + null, + null, + null, + document + ); + } + + /** + * Extract email addresses from a mailto: link and put them on the + * clipboard. + */ + copyEmail() { + // Copy the comma-separated list of email addresses only. + // There are other ways of embedding email addresses in a mailto: + // link, but such complex parsing is beyond us. + + const kMailToLength = 7; // length of "mailto:" + + var url = this.linkURL; + var qmark = url.indexOf("?"); + var addresses; + + if (qmark > kMailToLength) { + addresses = url.substring(kMailToLength, qmark); + } else { + addresses = url.substr(kMailToLength); + } + + // Let's try to unescape it using a character set. + try { + addresses = Services.textToSubURI.unEscapeURIForUI(addresses); + } catch (ex) { + // Do nothing. + } + + var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + clipboard.copyString(addresses); + } + + // --------- + // Utilities + + /** + * Set a DOM node's hidden property by passing in the node's id or the + * element itself. + * + * @param aItemOrId + * a DOM node or the id of a DOM node + * @param aShow + * true to show, false to hide + */ + showItem(aItemOrId, aShow) { + var item = + aItemOrId.constructor == String + ? document.getElementById(aItemOrId) + : aItemOrId; + if (item) { + item.hidden = !aShow; + } + } + + /** + * Set a DOM node's disabled property by passing in the node's id or the + * element itself. + * + * @param aItemOrId A DOM node or the id of a DOM node + * @param aEnabled True to enable the element, false to disable. + */ + enableItem(aItemOrId, aEnabled) { + var item = + aItemOrId.constructor == String + ? document.getElementById(aItemOrId) + : aItemOrId; + item.disabled = !aEnabled; + } + + /** + * Set given attribute of specified context-menu item. If the + * value is null, then it removes the attribute (which works + * nicely for the disabled attribute). + * + * @param aId + * The id of an element + * @param aAttr + * The attribute name + * @param aVal + * The value to set the attribute to, or null to remove the attribute + */ + setItemAttr(aId, aAttr, aVal) { + var elem = document.getElementById(aId); + if (elem) { + if (aVal == null) { + // null indicates attr should be removed. + elem.removeAttribute(aAttr); + } else { + // Set attr=val. + elem.setAttribute(aAttr, aVal); + } + } + } + + /** + * Get an absolute URL for clicked-on link, from the href property or by + * resolving an XLink URL by hand. + * + * @returns the string absolute URL for the clicked-on link + */ + getLinkURL() { + if (this.link.href) { + return this.link.href; + } + var href = this.link.getAttributeNS("http://www.w3.org/1999/xlink", "href"); + if (!href || href.trim() == "") { + // Without this we try to save as the current doc, + // for example, HTML case also throws if empty. + throw new Error("Empty href"); + } + href = this.makeURLAbsolute(this.link.baseURI, href); + return href; + } + + /** + * Generate a URI object from the linkURL spec + * + * @returns an nsIURI if possible, or null if not + */ + getLinkURI() { + try { + return Services.io.newURI(this.linkURL); + } catch (ex) { + // e.g. empty URL string + } + return null; + } + + /** + * Get the scheme for the clicked-on linkURI, if present. + * + * @returns a scheme, possibly undefined, or null if there's no linkURI + */ + getLinkProtocol() { + if (this.linkURI) { + return this.linkURI.scheme; // Can be |undefined|. + } + + return null; + } + + /** + * Get the text of the clicked-on link. + * + * @returns {string} + */ + linkText() { + return this.linkTextStr; + } + + /** + * Determines whether the focused window has something selected. + * + * @returns true if there is a selection, false if not + */ + isContentSelection() { + return !document.commandDispatcher.focusedWindow.getSelection().isCollapsed; + } + + /** + * Convert relative URL to absolute, using a provided <base>. + * + * @param aBase + * The URL string to use as the base + * @param aUrl + * The possibly-relative URL string + * @returns The string absolute URL + */ + makeURLAbsolute(aBase, aUrl) { + // Construct nsIURL. + var baseURI = Services.io.newURI(aBase); + + return Services.io.newURI(baseURI.resolve(aUrl)).spec; + } + + /** + * Determine whether a DOM node is a text or password input, or a textarea. + * + * @param aNode + * The DOM node to check + * @returns true for textboxes, false for other elements + */ + isTargetATextBox(aNode) { + if (HTMLInputElement.isInstance(aNode)) { + return aNode.type == "text" || aNode.type == "password"; + } + + return HTMLTextAreaElement.isInstance(aNode); + } + + /** + * Determine whether a separator should be shown based on whether + * there are any non-hidden items between it and the previous separator. + * + * @param {DomElement} element - The separator element. + * @returns {boolean} True if the separator should be shown, false if not. + */ + shouldShowSeparator(element) { + if (element) { + let sibling = element.previousElementSibling; + while (sibling && sibling.localName != "menuseparator") { + if (!sibling.hidden) { + return true; + } + sibling = sibling.previousElementSibling; + } + } + return false; + } + + /** + * Ensures that there isn't a separator shown at the bottom of the menu. + * + * @param aPopup The menu to check. + */ + checkLastSeparator(aPopup) { + let sibling = aPopup.lastElementChild; + while (sibling) { + if (!sibling.hidden) { + if (sibling.localName == "menuseparator") { + // If we got here then the item is a menuseparator and everything + // below it hidden. + sibling.setAttribute("hidden", true); + return; + } + return; + } + sibling = sibling.previousElementSibling; + } + } + + openInBrowser() { + let url = this.contentData?.documentURIObject?.spec; + if (!url) { + return; + } + PlacesUtils.history + .insert({ + url, + visits: [ + { + date: new Date(), + }, + ], + }) + .catch(console.error); + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(Services.io.newURI(url)); + } + + openLinkInBrowser() { + PlacesUtils.history + .insert({ + url: this.linkURL, + visits: [ + { + date: new Date(), + }, + ], + }) + .catch(console.error); + Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .loadURI(this.linkURI); + } + + mediaCommand(command) { + var media = this.target; + + switch (command) { + case "play": + media.play(); + break; + case "pause": + media.pause(); + break; + case "mute": + media.muted = true; + break; + case "unmute": + media.muted = false; + break; + // XXX hide controls & show controls don't work in emails as Javascript is + // disabled. May want to consider later for RSS feeds. + } + } +} + +ChromeUtils.defineESModuleGetters(nsContextMenu, { + WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs", +}); diff --git a/comm/mail/base/content/widgets/customizable-toolbar.js b/comm/mail/base/content/widgets/customizable-toolbar.js new file mode 100644 index 0000000000..350e814716 --- /dev/null +++ b/comm/mail/base/content/widgets/customizable-toolbar.js @@ -0,0 +1,319 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * Extends the built-in `toolbar` element to allow it to be customized. + * + * @augments {MozXULElement} + */ + class CustomizableToolbar extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback() || this._hasConnected) { + return; + } + this._hasConnected = true; + + this._toolbox = null; + this._newElementCount = 0; + + // Search for the toolbox palette in the toolbar binding because + // toolbars are constructed first. + let toolbox = this.toolbox; + if (!toolbox) { + return; + } + + if (!toolbox.palette) { + // Look to see if there is a toolbarpalette. + let node = toolbox.firstElementChild; + while (node) { + if (node.localName == "toolbarpalette") { + break; + } + node = node.nextElementSibling; + } + + if (!node) { + return; + } + + // Hold on to the palette but remove it from the document. + toolbox.palette = node; + toolbox.removeChild(node); + } + + // Build up our contents from the palette. + let currentSet = + this.getAttribute("currentset") || this.getAttribute("defaultset"); + + if (currentSet) { + this.currentSet = currentSet; + } + } + + /** + * Get the toolbox element connected to this toolbar. + * + * @returns {Element?} The toolbox element or null. + */ + get toolbox() { + if (this._toolbox) { + return this._toolbox; + } + + let toolboxId = this.getAttribute("toolboxid"); + if (toolboxId) { + let toolbox = document.getElementById(toolboxId); + if (!toolbox) { + let tbName = this.hasAttribute("toolbarname") + ? ` (${this.getAttribute("toolbarname")})` + : ""; + + throw new Error( + `toolbar ID ${this.id}${tbName}: toolboxid attribute '${toolboxId}' points to a toolbox that doesn't exist` + ); + } + this._toolbox = toolbox; + return this._toolbox; + } + + this._toolbox = + this.parentNode && this.parentNode.localName == "toolbox" + ? this.parentNode + : null; + + return this._toolbox; + } + + /** + * Sets the current set of items in the toolbar. + * + * @param {string} val - Comma-separated list of IDs or "__empty". + * @returns {string} Comma-separated list of IDs or "__empty". + */ + set currentSet(val) { + if (val == this.currentSet) { + return; + } + + // Build a cache of items in the toolbarpalette. + let palette = this.toolbox ? this.toolbox.palette : null; + let paletteChildren = palette ? palette.children : []; + + let paletteItems = {}; + + for (let item of paletteChildren) { + paletteItems[item.id] = item; + } + + let ids = val == "__empty" ? [] : val.split(","); + let children = this.children; + let nodeidx = 0; + let added = {}; + + // Iterate over the ids to use on the toolbar. + for (let id of ids) { + // Iterate over the existing nodes on the toolbar. nodeidx is the + // spot where we want to insert items. + let found = false; + for (let i = nodeidx; i < children.length; i++) { + let curNode = children[i]; + if (this._idFromNode(curNode) == id) { + // The node already exists. If i equals nodeidx, we haven't + // iterated yet, so the item is already in the right position. + // Otherwise, insert it here. + if (i != nodeidx) { + this.insertBefore(curNode, children[nodeidx]); + } + + added[curNode.id] = true; + nodeidx++; + found = true; + break; + } + } + if (found) { + // Move on to the next id. + continue; + } + + // The node isn't already on the toolbar, so add a new one. + let nodeToAdd = paletteItems[id] || this._getToolbarItem(id); + if (nodeToAdd && !(nodeToAdd.id in added)) { + added[nodeToAdd.id] = true; + this.insertBefore(nodeToAdd, children[nodeidx] || null); + nodeToAdd.setAttribute("removable", "true"); + nodeidx++; + } + } + + // Remove any leftover removable nodes. + for (let i = children.length - 1; i >= nodeidx; i--) { + let curNode = children[i]; + + let curNodeId = this._idFromNode(curNode); + // Skip over fixed items. + if (curNodeId && curNode.getAttribute("removable") == "true") { + if (palette) { + palette.appendChild(curNode); + } else { + this.removeChild(curNode); + } + } + } + } + + /** + * Gets the current set of items in the toolbar. + * + * @returns {string} Comma-separated list of IDs or "__empty". + */ + get currentSet() { + let node = this.firstElementChild; + let currentSet = []; + while (node) { + let id = this._idFromNode(node); + if (id) { + currentSet.push(id); + } + node = node.nextElementSibling; + } + + return currentSet.join(",") || "__empty"; + } + + /** + * Return the ID for a given toolbar item node, with special handling for + * some cases. + * + * @param {Element} node - Return the ID of this node. + * @returns {string} The ID of the node. + */ + _idFromNode(node) { + if (node.getAttribute("skipintoolbarset") == "true") { + return ""; + } + const specialItems = { + toolbarseparator: "separator", + toolbarspring: "spring", + toolbarspacer: "spacer", + }; + return specialItems[node.localName] || node.id; + } + + /** + * Returns a toolbar item based on the given ID. + * + * @param {string} id - The ID for the new toolbar item. + * @returns {Element?} The toolbar item corresponding to the ID, or null. + */ + _getToolbarItem(id) { + // Handle special cases. + if (["separator", "spring", "spacer"].includes(id)) { + let newItem = document.createXULElement("toolbar" + id); + // Due to timers resolution Date.now() can be the same for + // elements created in small timeframes. So ids are + // differentiated through a unique count suffix. + newItem.id = id + Date.now() + ++this._newElementCount; + if (id == "spring") { + newItem.flex = 1; + } + return newItem; + } + + let toolbox = this.toolbox; + if (!toolbox) { + return null; + } + + // Look for an item with the same id, as the item may be + // in a different toolbar. + let item = document.getElementById(id); + if ( + item && + item.parentNode && + item.parentNode.localName == "toolbar" && + item.parentNode.toolbox == toolbox + ) { + return item; + } + + if (toolbox.palette) { + // Attempt to locate an item with a matching ID within the palette. + let paletteItem = toolbox.palette.firstElementChild; + while (paletteItem) { + if (paletteItem.id == id) { + return paletteItem; + } + paletteItem = paletteItem.nextElementSibling; + } + } + return null; + } + + /** + * Insert an item into the toolbar. + * + * @param {string} id - The ID of the item to insert. + * @param {Element?} beforeElt - Optional element to insert the item before. + * @param {Element?} wrapper - Optional wrapper element. + * @returns {Element} The inserted item. + */ + insertItem(id, beforeElt, wrapper) { + let newItem = this._getToolbarItem(id); + if (!newItem) { + return null; + } + + let insertItem = newItem; + // Make sure added items are removable. + newItem.setAttribute("removable", "true"); + + // Wrap the item in another node if so inclined. + if (wrapper) { + wrapper.appendChild(newItem); + insertItem = wrapper; + } + + // Insert the palette item into the toolbar. + if (beforeElt) { + this.insertBefore(insertItem, beforeElt); + } else { + this.appendChild(insertItem); + } + return newItem; + } + + /** + * Determine whether the current set of toolbar items has custom + * interactive items or not. + * + * @param {string} currentSet - Comma-separated list of IDs or "__empty". + * @returns {boolean} Whether the current set has custom interactive items. + */ + hasCustomInteractiveItems(currentSet) { + if (currentSet == "__empty") { + return false; + } + + let defaultOrNoninteractive = (this.getAttribute("defaultset") || "") + .split(",") + .concat(["separator", "spacer", "spring"]); + + return currentSet + .split(",") + .some(item => !defaultOrNoninteractive.includes(item)); + } + } + + customElements.define("customizable-toolbar", CustomizableToolbar, { + extends: "toolbar", + }); +} diff --git a/comm/mail/base/content/widgets/foldersummary.js b/comm/mail/base/content/widgets/foldersummary.js new file mode 100644 index 0000000000..48bcb34d26 --- /dev/null +++ b/comm/mail/base/content/widgets/foldersummary.js @@ -0,0 +1,295 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozElements */ +/* global MozXULElement */ +/* import-globals-from ../../../../mailnews/base/content/newmailalert.js */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + + /** + * MozFolderSummary displays a listing of NEW mails for the folder in question. + * For each mail the subject, sender and a message preview can be included. + * + * @augments {MozXULElement} + */ + class MozFolderSummary extends MozXULElement { + constructor() { + super(); + this.maxMsgHdrsInPopup = 8; + + this.showSubject = Services.prefs.getBoolPref( + "mail.biff.alert.show_subject" + ); + this.showSender = Services.prefs.getBoolPref( + "mail.biff.alert.show_sender" + ); + this.showPreview = Services.prefs.getBoolPref( + "mail.biff.alert.show_preview" + ); + this.messengerBundle = Services.strings.createBundle( + "chrome://messenger/locale/messenger.properties" + ); + + ChromeUtils.defineModuleGetter( + this, + "MailUtils", + "resource:///modules/MailUtils.jsm" + ); + } + + hasMessages() { + return this.lastElementChild; + } + + static createFolderSummaryMessage() { + let vbox = document.createXULElement("vbox"); + vbox.setAttribute("class", "folderSummaryMessage"); + + let hbox = document.createXULElement("hbox"); + hbox.setAttribute("class", "folderSummary-message-row"); + + let subject = document.createXULElement("label"); + subject.setAttribute("class", "folderSummary-subject"); + + let sender = document.createXULElement("label"); + sender.setAttribute("class", "folderSummary-sender"); + sender.setAttribute("crop", "end"); + + hbox.appendChild(subject); + hbox.appendChild(sender); + + let preview = document.createXULElement("description"); + preview.setAttribute( + "class", + "folderSummary-message-row folderSummary-previewText" + ); + preview.setAttribute("crop", "end"); + + vbox.appendChild(hbox); + vbox.appendChild(preview); + return vbox; + } + + /** + * Check the given folder for NEW messages. + * + * @param {nsIMsgFolder} folder - The folder to examine. + * @param {nsIUrlListener} urlListener - Listener to notify if we run urls + * to fetch msgs. + * @param Object outAsync - Object with value property set to true if there + * are async fetches pending (a message preview will be available later). + * @returns true if the folder knows about messages that should be shown. + */ + parseFolder(folder, urlListener, outAsync) { + // Skip servers, Trash, Junk folders and newsgroups. + if ( + !folder || + folder.isServer || + !folder.hasNewMessages || + folder.getFlag(Ci.nsMsgFolderFlags.Junk) || + folder.getFlag(Ci.nsMsgFolderFlags.Trash) || + folder.server instanceof Ci.nsINntpIncomingServer + ) { + return false; + } + + let folderArray = []; + let msgDatabase; + try { + msgDatabase = folder.msgDatabase; + } catch (e) { + // The database for this folder may be missing (e.g. outdated/missing .msf), + // so just skip this folder. + return false; + } + + if (folder.flags & Ci.nsMsgFolderFlags.Virtual) { + let srchFolderUri = + msgDatabase.dBFolderInfo.getCharProperty("searchFolderUri"); + let folderUris = srchFolderUri.split("|"); + for (let uri of folderUris) { + let realFolder = this.MailUtils.getOrCreateFolder(uri); + if (!realFolder.isServer) { + folderArray.push(realFolder); + } + } + } else { + folderArray.push(folder); + } + + let haveMsgsToShow = false; + for (let folder of folderArray) { + // now get the database + try { + msgDatabase = folder.msgDatabase; + } catch (e) { + // The database for this folder may be missing (e.g. outdated/missing .msf), + // then just skip this folder. + continue; + } + + folder.msgDatabase = null; + let msgKeys = msgDatabase.getNewList(); + + let numNewMessages = folder.getNumNewMessages(false); + if (!numNewMessages) { + continue; + } + // NOTE: getNewlist returns all nsMsgMessageFlagType::New messages, + // while getNumNewMessages returns count of new messages since the last + // biff. Only show newly received messages since last biff in + // notification. + msgKeys = msgKeys.slice(-numNewMessages); + if (!msgKeys.length) { + continue; + } + + if (this.showPreview) { + // fetchMsgPreviewText forces the previewText property to get generated + // for each of the message keys. + try { + outAsync.value = folder.fetchMsgPreviewText(msgKeys, urlListener); + folder.msgDatabase = null; + } catch (ex) { + // fetchMsgPreviewText throws an error when we call it on a news + // folder + folder.msgDatabase = null; + continue; + } + } + + // If fetching the preview text is going to be an asynch operation and the + // caller is set up to handle that fact, then don't bother filling in any + // of the fields since we'll have to do this all over again when the fetch + // for the preview text completes. + // We don't expect to get called with a urlListener if we're doing a + // virtual folder. + if (outAsync.value && urlListener) { + return false; + } + + // In the case of async fetching for more than one folder, we may + // already have got enough to show (added by another urllistener). + let curHdrsInPopup = this.children.length; + if (curHdrsInPopup >= this.maxMsgHdrsInPopup) { + return false; + } + + for ( + let i = 0; + i + curHdrsInPopup < this.maxMsgHdrsInPopup && i < msgKeys.length; + i++ + ) { + let msgBox = MozFolderSummary.createFolderSummaryMessage(); + let msgHdr = msgDatabase.getMsgHdrForKey(msgKeys[i]); + msgBox.addEventListener("click", event => { + if (event.button !== 0) { + return; + } + this.MailUtils.displayMessageInFolderTab(msgHdr, true); + }); + + if (this.showSubject) { + let msgSubject = msgHdr.mime2DecodedSubject; + const kMsgFlagHasRe = 0x0010; // MSG_FLAG_HAS_RE + if (msgHdr.flags & kMsgFlagHasRe) { + msgSubject = msgSubject ? "Re: " + msgSubject : "Re: "; + } + msgBox.querySelector(".folderSummary-subject").textContent = + msgSubject; + } + + if (this.showSender) { + let addrs = MailServices.headerParser.parseEncodedHeader( + msgHdr.author, + msgHdr.effectiveCharset, + false + ); + let folderSummarySender = msgBox.querySelector( + ".folderSummary-sender" + ); + // Set the label value instead of textContent to avoid wrapping. + folderSummarySender.value = + addrs.length > 0 ? addrs[0].name || addrs[0].email : ""; + if (addrs.length > 1) { + let andOthersStr = + this.messengerBundle.GetStringFromName("andOthers"); + folderSummarySender.value += " " + andOthersStr; + } + } + + if (this.showPreview) { + msgBox.querySelector(".folderSummary-previewText").textContent = + msgHdr.getStringProperty("preview") || ""; + } + this.appendChild(msgBox); + haveMsgsToShow = true; + } + } + return haveMsgsToShow; + } + + /** + * Render NEW messages in a folder. + * + * @param {nsIMsgFolder} folder - A real folder containing new messages. + * @param {number[]} msgKeys - The keys of new messages. + */ + render(folder, msgKeys) { + let msgDatabase = folder.msgDatabase; + for (let msgKey of msgKeys.slice(0, this.maxMsgHdrsInPopup)) { + let msgBox = MozFolderSummary.createFolderSummaryMessage(); + let msgHdr = msgDatabase.getMsgHdrForKey(msgKey); + msgBox.addEventListener("click", event => { + if (event.button !== 0) { + return; + } + this.MailUtils.displayMessageInFolderTab(msgHdr, true); + }); + + if (this.showSubject) { + let msgSubject = msgHdr.mime2DecodedSubject; + const kMsgFlagHasRe = 0x0010; // MSG_FLAG_HAS_RE + if (msgHdr.flags & kMsgFlagHasRe) { + msgSubject = msgSubject ? "Re: " + msgSubject : "Re: "; + } + msgBox.querySelector(".folderSummary-subject").textContent = + msgSubject; + } + + if (this.showSender) { + let addrs = MailServices.headerParser.parseEncodedHeader( + msgHdr.author, + msgHdr.effectiveCharset, + false + ); + let folderSummarySender = msgBox.querySelector( + ".folderSummary-sender" + ); + // Set the label value instead of textContent to avoid wrapping. + folderSummarySender.value = + addrs.length > 0 ? addrs[0].name || addrs[0].email : ""; + if (addrs.length > 1) { + let andOthersStr = + this.messengerBundle.GetStringFromName("andOthers"); + folderSummarySender.value += " " + andOthersStr; + } + } + + if (this.showPreview) { + msgBox.querySelector(".folderSummary-previewText").textContent = + msgHdr.getStringProperty("preview") || ""; + } + this.appendChild(msgBox); + } + } + } + customElements.define("folder-summary", MozFolderSummary); +} diff --git a/comm/mail/base/content/widgets/gloda-autocomplete-input.js b/comm/mail/base/content/widgets/gloda-autocomplete-input.js new file mode 100644 index 0000000000..59f71ba6ae --- /dev/null +++ b/comm/mail/base/content/widgets/gloda-autocomplete-input.js @@ -0,0 +1,243 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozXULElement */ + +"use strict"; + +// The autocomplete CE is defined lazily. Create one now to get +// autocomplete-input defined, allowing us to inherit from it. +if (!customElements.get("autocomplete-input")) { + delete document.createXULElement("input", { is: "autocomplete-input" }); +} + +customElements.whenDefined("autocomplete-input").then(() => { + const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ); + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + const lazy = {}; + ChromeUtils.defineESModuleGetters(lazy, { + GlodaIMSearcher: "resource:///modules/GlodaIMSearcher.sys.mjs", + }); + ChromeUtils.defineModuleGetter( + lazy, + "Gloda", + "resource:///modules/gloda/GlodaPublic.jsm" + ); + ChromeUtils.defineModuleGetter( + lazy, + "GlodaMsgSearcher", + "resource:///modules/gloda/GlodaMsgSearcher.jsm" + ); + ChromeUtils.defineModuleGetter( + lazy, + "GlodaConstants", + "resource:///modules/gloda/GlodaConstants.jsm" + ); + + XPCOMUtils.defineLazyGetter( + lazy, + "glodaCompleter", + () => + Cc["@mozilla.org/autocomplete/search;1?name=gloda"].getService( + Ci.nsIAutoCompleteSearch + ).wrappedJSObject + ); + + /** + * The MozGlodaAutocompleteInput widget is used to display the autocomplete search bar. + * + * @augments {AutocompleteInput} + */ + class MozGlodaAutocompleteInput extends customElements.get( + "autocomplete-input" + ) { + constructor() { + super(); + + this.addEventListener( + "drop", + event => { + this.searchInputDNDObserver.onDrop(event); + }, + true + ); + + this.addEventListener("keypress", event => { + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + // Trigger the click event if a popup result is currently selected. + if (this.popup.richlistbox.selectedIndex != -1) { + this.popup.onPopupClick(event); + } else { + this.doSearch(); + } + event.preventDefault(); + event.stopPropagation(); + } + + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + this.clearSearch(); + event.preventDefault(); + event.stopPropagation(); + } + }); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + + this.hasConnected = true; + super.connectedCallback(); + + this.setAttribute("is", "gloda-autocomplete-input"); + + // @implements {nsIObserver} + this.searchInputDNDObserver = { + onDrop: event => { + if (event.dataTransfer.types.includes("text/x-moz-address")) { + this.focus(); + this.value = event.dataTransfer.getData("text/plain"); + // XXX for some reason the input field is _cleared_ even though + // the search works. + this.doSearch(); + } + event.stopPropagation(); + }, + }; + + // @implements {nsIObserver} + this.textObserver = { + observe: (subject, topic, data) => { + try { + // Some autocomplete controllers throw NS_ERROR_NOT_IMPLEMENTED. + subject.popupElement; + } catch (ex) { + return; + } + if ( + topic == "autocomplete-did-enter-text" && + document.activeElement == this + ) { + let selectedIndex = this.popup.selectedIndex; + let curResult = lazy.glodaCompleter.curResult; + if (!curResult) { + // autocomplete didn't even finish. + return; + } + let row = curResult.getObjectAt(selectedIndex); + if (row == null) { + return; + } + if (row.fullText) { + // The autocomplete-did-enter-text notification is synchronously + // generated by nsAutoCompleteController which will attempt to + // call ClosePopup after we return and then tell the searchbox + // about the text entered. Since doSearch may close the current + // tab (and thus destroy the XUL document that owns the popup and + // the input field), the search box may no longer have its + // binding attached when we return and telling it about the + // entered text could fail. + // To avoid this, we defer the doSearch call to the next turn of + // the event loop by using setTimeout. + setTimeout(this.doSearch.bind(this), 0); + } else if (row.nounDef) { + let theQuery = lazy.Gloda.newQuery( + lazy.GlodaConstants.NOUN_MESSAGE + ); + if (row.nounDef.name == "tag") { + theQuery = theQuery.tags(row.item); + } else if (row.nounDef.name == "identity") { + theQuery = theQuery.involves(row.item); + } + theQuery.orderBy("-date"); + document.getElementById("tabmail").openTab("glodaFacet", { + query: theQuery, + }); + } + } + }, + }; + + let keyLabel = + AppConstants.platform == "macosx" ? "keyLabelMac" : "keyLabelNonMac"; + let placeholder = this.getAttribute("emptytextbase").replace( + "#1", + this.getAttribute(keyLabel) + ); + + this.setAttribute("placeholder", placeholder); + + Services.obs.addObserver( + this.textObserver, + "autocomplete-did-enter-text" + ); + + // make sure we set our emptytext here from the get-go + if (this.hasAttribute("placeholder")) { + this.placeholder = this.getAttribute("placeholder"); + } + } + + set state(val) { + this.value = val.string; + } + + get state() { + return { string: this.value }; + } + + doSearch() { + if (this.value) { + let tabmail = document.getElementById("tabmail"); + // If the current tab is a gloda search tab, reset the value + // to the initial search value. Otherwise, clear it. This + // is the value that is going to be saved with the current + // tab when we switch back to it next. + let searchString = this.value; + + if (tabmail.currentTabInfo.mode.name == "glodaFacet") { + // We'd rather reuse the existing tab (and somehow do something + // smart with any preexisting facet choices, but that's a + // bit hard right now, so doing the cheap thing and closing + // this tab and starting over. + tabmail.closeTab(); + } + this.value = ""; // clear our value, to avoid persistence + let args = { + searcher: new lazy.GlodaMsgSearcher(null, searchString), + }; + if (Services.prefs.getBoolPref("mail.chat.enabled")) { + args.IMSearcher = new lazy.GlodaIMSearcher(null, searchString); + } + tabmail.openTab("glodaFacet", args); + } + } + + clearSearch() { + this.value = ""; + } + + disconnectedCallback() { + Services.obs.removeObserver( + this.textObserver, + "autocomplete-did-enter-text" + ); + this.hasConnected = false; + } + } + + MozXULElement.implementCustomInterface(MozGlodaAutocompleteInput, [ + Ci.nsIObserver, + ]); + customElements.define("gloda-autocomplete-input", MozGlodaAutocompleteInput, { + extends: "input", + }); +}); diff --git a/comm/mail/base/content/widgets/glodaFacet.js b/comm/mail/base/content/widgets/glodaFacet.js new file mode 100644 index 0000000000..c8d1e78dd8 --- /dev/null +++ b/comm/mail/base/content/widgets/glodaFacet.js @@ -0,0 +1,1823 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global DateFacetVis, FacetContext */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + const { TagUtils } = ChromeUtils.import("resource:///modules/TagUtils.jsm"); + const { FacetUtils } = ChromeUtils.import( + "resource:///modules/gloda/Facet.jsm" + ); + const { PluralForm } = ChromeUtils.importESModule( + "resource://gre/modules/PluralForm.sys.mjs" + ); + const { Gloda } = ChromeUtils.import("resource:///modules/gloda/Gloda.jsm"); + + var glodaFacetStrings = Services.strings.createBundle( + "chrome://messenger/locale/glodaFacetView.properties" + ); + + class MozFacetDate extends HTMLElement { + get build() { + return this.buildFunc; + } + + get brushItems() { + return items => this.vis.hoverItems(items); + } + + get clearBrushedItems() { + return () => this.vis.clearHover(); + } + + connectedCallback() { + const wrapper = document.createElement("div"); + wrapper.classList.add("facet", "date-wrapper"); + + const h2 = document.createElement("h2"); + + const canvas = document.createElement("div"); + canvas.classList.add("date-vis-frame"); + + const zoomOut = document.createElement("div"); + zoomOut.classList.add("facet-date-zoom-out"); + zoomOut.setAttribute("role", "image"); + zoomOut.addEventListener("click", () => FacetContext.zoomOut()); + + wrapper.appendChild(h2); + wrapper.appendChild(canvas); + wrapper.appendChild(zoomOut); + this.appendChild(wrapper); + + this.canUpdate = true; + this.canvasNode = canvas; + this.vis = null; + if ("faceter" in this) { + this.buildFunc(true); + } + } + + buildFunc(aDoSize) { + if (!this.vis) { + this.vis = new DateFacetVis(this, this.canvasNode); + this.vis.build(); + } else { + while (this.canvasNode.hasChildNodes()) { + this.canvasNode.lastChild.remove(); + } + if (aDoSize) { + this.vis.build(); + } else { + this.vis.rebuild(); + } + } + } + } + + customElements.define("facet-date", MozFacetDate); + + /** + * MozFacetResultsMessage shows the search results for the string entered in gloda-searchbox. + * + * @augments {HTMLElement} + */ + class MozFacetResultsMessage extends HTMLElement { + connectedCallback() { + const header = document.createElement("div"); + header.classList.add("results-message-header"); + + this.countNode = document.createElement("h2"); + this.countNode.classList.add("results-message-count"); + + this.toggleTimeline = document.createElement("button"); + this.toggleTimeline.setAttribute("id", "date-toggle"); + this.toggleTimeline.setAttribute("tabindex", 0); + this.toggleTimeline.classList.add("gloda-timeline-button"); + this.toggleTimeline.addEventListener("click", () => { + FacetContext.toggleTimeline(); + }); + + const timelineImage = document.createElement("img"); + timelineImage.setAttribute( + "src", + "chrome://messenger/skin/icons/popular.svg" + ); + timelineImage.setAttribute("alt", ""); + this.toggleTimeline.appendChild(timelineImage); + + this.toggleText = document.createElement("span"); + this.toggleTimeline.appendChild(this.toggleText); + + const sortDiv = document.createElement("div"); + sortDiv.classList.add("results-message-sort-bar"); + + this.sortSelect = document.createElement("select"); + this.sortSelect.setAttribute("id", "sortby"); + let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby"); + + let relevanceItem = document.createElement("option"); + relevanceItem.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.sort.relevance2" + ); + relevanceItem.setAttribute("value", "-dascore"); + relevanceItem.toggleAttribute( + "selected", + sortByPref <= 0 || sortByPref == 2 || sortByPref > 3 + ); + this.sortSelect.appendChild(relevanceItem); + + let dateItem = document.createElement("option"); + dateItem.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.sort.date2" + ); + dateItem.setAttribute("value", "-date"); + dateItem.toggleAttribute("selected", sortByPref == 1 || sortByPref == 3); + this.sortSelect.appendChild(dateItem); + + this.messagesNode = document.createElement("div"); + this.messagesNode.classList.add("messages"); + + header.appendChild(this.countNode); + header.appendChild(this.toggleTimeline); + header.appendChild(sortDiv); + + sortDiv.appendChild(this.sortSelect); + + this.appendChild(header); + this.appendChild(this.messagesNode); + } + + setMessages(messages) { + let topMessagesPluralFormat = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.header.countLabel.NMessages" + ); + let outOfPluralFormat = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.header.countLabel.ofN" + ); + let groupingFormat = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.header.countLabel.grouping" + ); + + let displayCount = messages.length; + let totalCount = FacetContext.activeSet.length; + + // set the count so CSS selectors can know what the results look like + this.setAttribute("state", totalCount <= 0 ? "empty" : "some"); + + let topMessagesStr = PluralForm.get( + displayCount, + topMessagesPluralFormat + ).replace("#1", displayCount.toLocaleString()); + let outOfStr = PluralForm.get(totalCount, outOfPluralFormat).replace( + "#1", + totalCount.toLocaleString() + ); + + this.countNode.textContent = groupingFormat + .replace("#1", topMessagesStr) + .replace("#2", outOfStr); + + this.toggleText.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.timeline.label" + ); + + let sortByPref = Services.prefs.getIntPref("gloda.facetview.sortby"); + this.sortSelect.addEventListener("change", () => { + if (sortByPref >= 2) { + Services.prefs.setIntPref( + "gloda.facetview.sortby", + this.sortSelect.value == "-dascore" ? 2 : 3 + ); + } + + FacetContext.sortBy = this.sortSelect.value; + }); + + while (this.messagesNode.hasChildNodes()) { + this.messagesNode.lastChild.remove(); + } + try { + // -- Messages + for (let message of messages) { + let msgNode = document.createElement("facet-result-message"); + msgNode.message = message; + msgNode.setAttribute("class", "message"); + this.messagesNode.appendChild(msgNode); + } + } catch (e) { + console.error(e); + } + } + } + + customElements.define("facet-results-message", MozFacetResultsMessage); + + class MozFacetBoolean extends HTMLElement { + constructor() { + super(); + + this.addEventListener("mouseover", event => { + FacetContext.hoverFacet( + this.faceter, + this.faceter.attrDef, + true, + this.trueValues + ); + }); + + this.addEventListener("mouseout", event => { + FacetContext.unhoverFacet( + this.faceter, + this.faceter.attrDef, + true, + this.trueValues + ); + }); + } + + connectedCallback() { + this.addChildren(); + + this.canUpdate = true; + this.bubble.addEventListener("click", event => { + return this.bubbleClicked(event); + }); + + if ("faceter" in this) { + this.build(true); + } + } + + addChildren() { + this.bubble = document.createElement("span"); + this.bubble.classList.add("facet-checkbox-bubble"); + + this.checkbox = document.createElement("input"); + this.checkbox.setAttribute("type", "checkbox"); + + this.labelNode = document.createElement("span"); + this.labelNode.classList.add("facet-checkbox-label"); + + this.countNode = document.createElement("span"); + this.countNode.classList.add("facet-checkbox-count"); + + this.bubble.appendChild(this.checkbox); + this.bubble.appendChild(this.labelNode); + this.bubble.appendChild(this.countNode); + + this.appendChild(this.bubble); + } + + set disabled(val) { + if (val) { + this.setAttribute("disabled", "true"); + this.checkbox.setAttribute("disabled", "true"); + } else { + this.removeAttribute("disabled"); + this.checkbox.removeAttribute("disabled"); + } + } + + get disabled() { + return this.getAttribute("disabled") == "true"; + } + + set checked(val) { + if (this.checked == val) { + return; + } + this.checkbox.checked = val; + if (val) { + this.setAttribute("checked", "true"); + if (!this.disabled) { + FacetContext.addFacetConstraint(this.faceter, true, this.trueGroups); + } + } else { + this.removeAttribute("checked"); + this.checkbox.removeAttribute("checked"); + if (!this.disabled) { + FacetContext.removeFacetConstraint( + this.faceter, + true, + this.trueGroups + ); + } + } + this.checkStateChanged(); + } + + get checked() { + return this.getAttribute("checked") == "true"; + } + + extraSetup() {} + + checkStateChanged() {} + + brushItems() {} + + clearBrushedItems() {} + + build(firstTime) { + if (firstTime) { + this.labelNode.textContent = this.facetDef.strings.facetNameLabel; + this.checkbox.setAttribute( + "aria-label", + this.facetDef.strings.facetNameLabel + ); + this.trueValues = []; + } + + // If we do not currently have a constraint applied and there is only + // one (or no) group, then: disable us, but reflect the underlying + // state of the data (checked or non-checked) + if (!this.faceter.constraint && this.orderedGroups.length <= 1) { + this.disabled = true; + let count = 0; + if (this.orderedGroups.length) { + // true case? + if (this.orderedGroups[0][0]) { + count = this.orderedGroups[0][1].length; + this.checked = true; + } else { + this.checked = false; + } + } + this.countNode.textContent = count.toLocaleString(); + return; + } + // if we were disabled checked before, clear ourselves out + if (this.disabled && this.checked) { + this.checked = false; + } + this.disabled = false; + + // if we are here, we have our 2 groups, find true... + // (note: it is possible to get jerked around by null values + // currently, so leave a reasonable failure case) + this.trueValues = []; + this.trueGroups = [true]; + for (let groupPair of this.orderedGroups) { + if (groupPair[0]) { + this.trueValues = groupPair[1]; + } + } + + this.countNode.textContent = this.trueValues.length.toLocaleString(); + } + + bubbleClicked(event) { + if (!this.disabled) { + this.checked = !this.checked; + } + event.stopPropagation(); + } + } + + customElements.define("facet-boolean", MozFacetBoolean); + + class MozFacetBooleanFiltered extends MozFacetBoolean { + static get observedAttributes() { + return ["checked", "disabled"]; + } + + connectedCallback() { + super.addChildren(); + + this.filterNode = document.createElement("select"); + this.filterNode.classList.add("facet-filter-list"); + this.appendChild(this.filterNode); + + this.canUpdate = true; + this.bubble.addEventListener("click", event => { + return super.bubbleClicked(event); + }); + + this.extraSetup(); + + if ("faceter" in this) { + this.build(true); + } + + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.checkbox) { + return; + } + + if (this.hasAttribute("checked")) { + this.checkbox.setAttribute("checked", this.getAttribute("checked")); + } else { + this.checkbox.removeAttribute("checked"); + } + + if (this.hasAttribute("disabled")) { + this.checkbox.setAttribute("disabled", this.getAttribute("disabled")); + } else { + this.checkbox.removeAttribute("disabled"); + } + } + + extraSetup() { + this.groupDisplayProperty = this.getAttribute("groupDisplayProperty"); + + this.filterNode.addEventListener("change", event => + this.filterChanged(event) + ); + + this.selectedValue = "all"; + } + + build(firstTime) { + if (firstTime) { + this.labelNode.textContent = this.facetDef.strings.facetNameLabel; + this.checkbox.setAttribute( + "aria-label", + this.facetDef.strings.facetNameLabel + ); + this.trueValues = []; + } + + // Only update count if anything other than "all" is selected. + // Otherwise we lose the set of attachment types in our select box, + // and that makes us sad. We do want to update on "all" though + // because other facets may further reduce the number of attachments + // we see. (Or if this is not just being used for attachments, it + // still holds.) + if (this.selectedValue != "all") { + let count = 0; + for (let groupPair of this.orderedGroups) { + if (groupPair[0] != null) { + count += groupPair[1].length; + } + } + this.countNode.textContent = count.toLocaleString(); + return; + } + + while (this.filterNode.hasChildNodes()) { + this.filterNode.lastChild.remove(); + } + + let allNode = document.createElement("option"); + allNode.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.filter." + + this.attrDef.attributeName + + ".allLabel" + ); + allNode.setAttribute("value", "all"); + if (this.selectedValue == "all") { + allNode.setAttribute("selected", "selected"); + } + this.filterNode.appendChild(allNode); + + // if we are here, we have our 2 groups, find true... + // (note: it is possible to get jerked around by null values + // currently, so leave a reasonable failure case) + // empty true groups is for the checkbox + this.trueGroups = []; + // the real true groups is the actual true values for our explicit + // filtering + this.realTrueGroups = []; + this.trueValues = []; + this.falseValues = []; + let selectNodes = []; + for (let groupPair of this.orderedGroups) { + if (groupPair[0] === null) { + this.falseValues.push.apply(this.falseValues, groupPair[1]); + } else { + this.trueValues.push.apply(this.trueValues, groupPair[1]); + + let groupValue = groupPair[0]; + let selNode = document.createElement("option"); + selNode.textContent = groupValue[this.groupDisplayProperty]; + selNode.setAttribute("value", this.realTrueGroups.length); + if (this.selectedValue == groupValue.category) { + selNode.setAttribute("selected", "selected"); + } + selectNodes.push(selNode); + + this.realTrueGroups.push(groupValue); + } + } + selectNodes.sort((a, b) => { + return a.textContent.localeCompare(b.textContent); + }); + selectNodes.forEach(selNode => { + this.filterNode.appendChild(selNode); + }); + + this.disabled = !this.trueValues.length; + + this.countNode.textContent = this.trueValues.length.toLocaleString(); + } + + checkStateChanged() { + // if they un-check us, revert our value to all. + if (!this.checked) { + this.selectedValue = "all"; + } + } + + filterChanged(event) { + if (!this.checked) { + return; + } + if (this.filterNode.value == "all") { + this.selectedValue = "all"; + FacetContext.addFacetConstraint( + this.faceter, + true, + this.trueGroups, + false, + true + ); + } else { + let groupValue = this.realTrueGroups[parseInt(this.filterNode.value)]; + this.selectedValue = groupValue.category; + FacetContext.addFacetConstraint( + this.faceter, + true, + [groupValue], + false, + true + ); + } + } + } + + customElements.define("facet-boolean-filtered", MozFacetBooleanFiltered); + + class MozFacetDiscrete extends HTMLElement { + constructor() { + super(); + + this.addEventListener("click", event => { + this.showPopup(event); + }); + + this.addEventListener("keypress", event => { + if (event.keyCode != KeyEvent.DOM_VK_RETURN) { + return; + } + this.showPopup(event); + }); + + this.addEventListener("keypress", event => { + this.activateLink(event); + }); + + this.addEventListener("mouseover", event => { + // we dispatch based on the class of the thing we clicked on. + // there are other ways we could accomplish this, but they all sorta suck. + if ( + event.target.hasAttribute("class") && + event.target.classList.contains("bar-link") + ) { + this.barHovered(event.target.parentNode, true); + } + }); + + this.addEventListener("mouseout", event => { + // we dispatch based on the class of the thing we clicked on. + // there are other ways we could accomplish this, but they all sorta suck. + if ( + event.target.hasAttribute("class") && + event.target.classList.contains("bar-link") + ) { + this.barHoverGone(event.target.parentNode, true); + } + }); + } + + connectedCallback() { + const facet = document.createElement("div"); + facet.classList.add("facet"); + + this.nameNode = document.createElement("h2"); + + this.contentBox = document.createElement("div"); + this.contentBox.classList.add("facet-content"); + + this.includeLabel = document.createElement("h3"); + this.includeLabel.classList.add("facet-included-header"); + + this.includeList = document.createElement("ul"); + this.includeList.classList.add("facet-included", "barry"); + + this.remainderLabel = document.createElement("h3"); + this.remainderLabel.classList.add("facet-remaindered-header"); + + this.remainderList = document.createElement("ul"); + this.remainderList.classList.add("facet-remaindered", "barry"); + + this.excludeLabel = document.createElement("h3"); + this.excludeLabel.classList.add("facet-excluded-header"); + + this.excludeList = document.createElement("ul"); + this.excludeList.classList.add("facet-excluded", "barry"); + + this.moreButton = document.createElement("button"); + this.moreButton.classList.add("facet-more"); + this.moreButton.setAttribute("needed", "false"); + this.moreButton.setAttribute("tabindex", "0"); + + this.contentBox.appendChild(this.includeLabel); + this.contentBox.appendChild(this.includeList); + this.contentBox.appendChild(this.remainderLabel); + this.contentBox.appendChild(this.remainderList); + this.contentBox.appendChild(this.excludeLabel); + this.contentBox.appendChild(this.excludeList); + this.contentBox.appendChild(this.moreButton); + + facet.appendChild(this.nameNode); + facet.appendChild(this.contentBox); + + this.appendChild(facet); + + this.canUpdate = false; + + if ("faceter" in this) { + this.build(true); + } + } + + build(firstTime) { + // -- Header Building + this.nameNode.textContent = this.facetDef.strings.facetNameLabel; + + // - include + // setup the include label + if ("includeLabel" in this.facetDef.strings) { + this.includeLabel.textContent = this.facetDef.strings.includeLabel; + } else { + this.includeLabel.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.included.fallbackLabel" + ); + } + this.includeLabel.setAttribute("state", "empty"); + + // - exclude + // setup the exclude label + if ("excludeLabel" in this.facetDef.strings) { + this.excludeLabel.textContent = this.facetDef.strings.excludeLabel; + } else { + this.excludeLabel.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.excluded.fallbackLabel" + ); + } + this.excludeLabel.setAttribute("state", "empty"); + + // - remainder + // setup the remainder label + if ("remainderLabel" in this.facetDef.strings) { + this.remainderLabel.textContent = this.facetDef.strings.remainderLabel; + } else { + this.remainderLabel.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.remainder.fallbackLabel" + ); + } + + // -- House-cleaning + // -- All/Top mode decision + this.modes = ["all"]; + if (this.maxDisplayRows >= this.orderedGroups.length) { + this.mode = "all"; + } else { + // top mode must be used + this.modes.push("top"); + this.mode = "top"; + this.topGroups = FacetUtils.makeTopGroups( + this.attrDef, + this.orderedGroups, + this.maxDisplayRows + ); + // setup the more button string + let groupCount = this.orderedGroups.length; + this.moreButton.textContent = PluralForm.get( + groupCount, + glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.mode.top.listAllLabel" + ) + ).replace("#1", groupCount); + } + + // -- Row Building + this.buildRows(); + } + + changeMode(newMode) { + this.mode = newMode; + this.setAttribute("mode", newMode); + this.buildRows(); + } + + buildRows() { + let nounDef = this.nounDef; + let useGroups = this.mode == "all" ? this.orderedGroups : this.topGroups; + + // should we just rely on automatic string coercion? + this.moreButton.setAttribute( + "needed", + this.mode == "top" ? "true" : "false" + ); + + let constraint = this.faceter.constraint; + + // -- empty all of our display buckets... + let remainderList = this.remainderList; + while (remainderList.hasChildNodes()) { + remainderList.lastChild.remove(); + } + let includeList = this.includeList; + let excludeList = this.excludeList; + while (includeList.hasChildNodes()) { + includeList.lastChild.remove(); + } + while (excludeList.hasChildNodes()) { + excludeList.lastChild.remove(); + } + + // -- first pass, check for ambiguous labels + // It's possible that multiple groups are identified by the same short + // string, in which case we want to use the longer string to + // disambiguate. For example, un-merged contacts can result in + // multiple identities having contacts with the same name. In that + // case we want to display both the contact name and the identity + // name. + // This is generically addressed by using the userVisibleString function + // defined on the noun type if it is defined. It takes an argument + // indicating whether it should be a short string or a long string. + // Our algorithm is somewhat dumb. We get the short strings, put them + // in a dictionary that maps to whether they are ambiguous or not. We + // do not attempt to map based on their id, so then when it comes time + // to actually build the labels, we must build the short string and + // then re-call for the long name. We could be smarter by building + // a list of the input values that resulted in the output string and + // then using that to back-update the id map, but it's more compelx and + // the performance difference is unlikely to be meaningful. + let ambiguousKeyValues; + if ("userVisibleString" in nounDef) { + ambiguousKeyValues = {}; + for (let groupPair of useGroups) { + let [groupValue] = groupPair; + + // skip null values, they are handled by the none special-case + if (groupValue == null) { + continue; + } + + let groupStr = nounDef.userVisibleString(groupValue, false); + // We use hasOwnProperty because it is possible that groupStr could + // be the same as the name of one of the attributes on + // Object.prototype. + if (ambiguousKeyValues.hasOwnProperty(groupStr)) { + ambiguousKeyValues[groupStr] = true; + } else { + ambiguousKeyValues[groupStr] = false; + } + } + } + + // -- create the items, assigning them to the right list based on + // existing constraint values + for (let groupPair of useGroups) { + let [groupValue, groupItems] = groupPair; + let li = document.createElement("li"); + li.setAttribute("class", "bar"); + li.setAttribute("tabindex", "0"); + li.setAttribute("role", "link"); + li.setAttribute("aria-haspopup", "true"); + li.groupValue = groupValue; + li.setAttribute("groupValue", groupValue); + li.groupItems = groupItems; + + let countSpan = document.createElement("span"); + countSpan.setAttribute("class", "bar-count"); + countSpan.textContent = groupItems.length.toLocaleString(); + li.appendChild(countSpan); + + let label = document.createElement("span"); + label.setAttribute("class", "bar-link"); + + // The null value is a special indicator for 'none' + if (groupValue == null) { + if ("noneLabel" in this.facetDef.strings) { + label.textContent = this.facetDef.strings.noneLabel; + } else { + label.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.facets.noneLabel" + ); + } + } else { + // Otherwise stringify the group object + let labelStr; + if (ambiguousKeyValues) { + labelStr = nounDef.userVisibleString(groupValue, false); + if (ambiguousKeyValues[labelStr]) { + labelStr = nounDef.userVisibleString(groupValue, true); + } + } else if ("labelFunc" in this.facetDef) { + labelStr = this.facetDef.labelFunc(groupValue); + } else { + labelStr = groupValue.toLocaleString().substring(0, 80); + } + label.textContent = labelStr; + label.setAttribute("title", labelStr); + } + li.appendChild(label); + + // root it under the appropriate list + if (constraint) { + if (constraint.isIncludedGroup(groupValue)) { + li.setAttribute("variety", "include"); + includeList.appendChild(li); + } else if (constraint.isExcludedGroup(groupValue)) { + li.setAttribute("variety", "exclude"); + excludeList.appendChild(li); + } else { + li.setAttribute("variety", "remainder"); + remainderList.appendChild(li); + } + } else { + li.setAttribute("variety", "remainder"); + remainderList.appendChild(li); + } + } + + this.updateHeaderStates(); + } + + /** + * - Mark the include/exclude headers as "some" if there is anything in their + * - lists, mark the remainder header as "needed" if either of include / + * - exclude exist so we need that label. + */ + updateHeaderStates(items) { + this.includeLabel.setAttribute( + "state", + this.includeList.childElementCount ? "some" : "empty" + ); + this.excludeLabel.setAttribute( + "state", + this.excludeList.childElementCount ? "some" : "empty" + ); + this.remainderLabel.setAttribute( + "needed", + (this.includeList.childElementCount || + this.excludeList.childElementCount) && + this.remainderList.childElementCount + ? "true" + : "false" + ); + + // nuke the style attributes. + this.includeLabel.removeAttribute("style"); + this.excludeLabel.removeAttribute("style"); + this.remainderLabel.removeAttribute("style"); + } + + brushItems(items) {} + + clearBrushedItems() {} + + afterListVisible(variety, callback) { + let labelNode = this[variety + "Label"]; + let listNode = this[variety + "List"]; + + // if there are already things displayed, no need + if (listNode.childElementCount) { + callback(); + return; + } + + let remListVisible = this.remainderLabel.getAttribute("needed") == "true"; + let remListShouldBeVisible = this.remainderList.childElementCount > 1; + + labelNode.setAttribute("state", "some"); + + let showNodes = [labelNode]; + if (remListVisible != remListShouldBeVisible) { + showNodes = [labelNode, this.remainderLabel]; + } + + showNodes.forEach(node => (node.style.display = "block")); + + callback(); + } + + _flyBarAway(barNode, variety, callback) { + function getRect(aElement) { + let box = aElement.getBoundingClientRect(); + let documentElement = aElement.ownerDocument.documentElement; + return { + top: box.top + window.pageYOffset - documentElement.clientTop, + left: box.left + window.pageXOffset - documentElement.clientLeft, + width: box.width, + height: box.height, + }; + } + // figure out our origin location prior to adding the target or it + // will shift us down. + let origin = getRect(barNode); + + // clone the node into its target location + let targetNode = barNode.cloneNode(true); + targetNode.groupValue = barNode.groupValue; + targetNode.groupItems = barNode.groupItems; + targetNode.setAttribute("variety", variety); + + let targetParent = this[variety + "List"]; + targetParent.appendChild(targetNode); + + // create a flying clone + let flyingNode = barNode.cloneNode(true); + + let dest = getRect(targetNode); + + // if the flying box wants to go higher than the content box goes, just + // send it to the top of the content box instead. + let contentRect = getRect(this.contentBox); + if (dest.top < contentRect.top) { + dest.top = contentRect.top; + } + + // likewise if it wants to go further south than the content box, stop + // that + if (dest.top > contentRect.top + contentRect.height) { + dest.top = contentRect.top + contentRect.height - dest.height; + } + + flyingNode.style.position = "absolute"; + flyingNode.style.width = origin.width + "px"; + flyingNode.style.height = origin.height + "px"; + flyingNode.style.top = origin.top + "px"; + flyingNode.style.left = origin.left + "px"; + flyingNode.style.zIndex = 1000; + + flyingNode.style.transitionDuration = + Math.abs(dest.top - origin.top) * 2 + "ms"; + flyingNode.style.transitionProperty = "top, left"; + + flyingNode.addEventListener("transitionend", () => { + barNode.remove(); + targetNode.style.display = "block"; + flyingNode.remove(); + + if (callback) { + setTimeout(callback, 50); + } + }); + + document.body.appendChild(flyingNode); + + // Adding setTimeout to improve the facet-discrete animation. + // See Bug 1439323 for more detail. + setTimeout(() => { + // animate the flying clone... flying! + window.requestAnimationFrame(() => { + flyingNode.style.top = dest.top + "px"; + flyingNode.style.left = dest.left + "px"; + }); + + // hide the target (cloned) node + targetNode.style.display = "none"; + + // hide the original node and remove its JS properties + barNode.style.visibility = "hidden"; + delete barNode.groupValue; + delete barNode.groupItems; + }, 100); + } + + barClicked(barNode, variety) { + let groupValue = barNode.groupValue; + // These determine what goAnimate actually does. + // flyAway allows us to cancel flying in the case the constraint is + // being fully dropped and so the facet is just going to get rebuilt + let flyAway = true; + + const goAnimate = () => { + setTimeout(() => { + if (flyAway) { + this.afterListVisible(variety, () => { + this._flyBarAway(barNode, variety, () => { + this.updateHeaderStates(); + }); + }); + } + }, 0); + }; + + // Immediately apply the facet change, triggering the animation after + // the faceting completes. + if (variety == "remainder") { + let currentVariety = barNode.getAttribute("variety"); + let constraintGone = FacetContext.removeFacetConstraint( + this.faceter, + currentVariety == "include", + [groupValue], + goAnimate + ); + + // we will automatically rebuild if the constraint is gone, so + // just make the animation a no-op. + if (constraintGone) { + flyAway = false; + } + } else { + // include/exclude + let revalidate = FacetContext.addFacetConstraint( + this.faceter, + variety == "include", + [groupValue], + false, + false, + goAnimate + ); + + // revalidate means we need to blow away the other dudes, in which + // case it makes the most sense to just trigger a rebuild of ourself + if (revalidate) { + flyAway = false; + this.build(false); + } + } + } + + barHovered(barNode, aInclude) { + let groupValue = barNode.groupValue; + let groupItems = barNode.groupItems; + + FacetContext.hoverFacet( + this.faceter, + this.attrDef, + groupValue, + groupItems + ); + } + + /** + * HoverGone! HoverGone! + * We know it's gone, but where has it gone? + */ + barHoverGone(barNode, include) { + let groupValue = barNode.groupValue; + let groupItems = barNode.groupItems; + + FacetContext.unhoverFacet( + this.faceter, + this.attrDef, + groupValue, + groupItems + ); + } + + includeFacet(node) { + this.barClicked( + node, + node.getAttribute("variety") == "remainder" ? "include" : "remainder" + ); + } + + undoFacet(node) { + this.barClicked( + node, + node.getAttribute("variety") == "remainder" ? "include" : "remainder" + ); + } + + excludeFacet(node) { + this.barClicked(node, "exclude"); + } + + showPopup(event) { + try { + // event.target could be the <li> node, or a span inside + // of it, or perhaps the facet-more button, or maybe something + // else that we'll handle in the next version. We walk up its + // parent chain until we get to the right level of the DOM + // hierarchy, or the facet-content which seems to be the root. + if (this.currentNode) { + this.currentNode.removeAttribute("selected"); + } + + let node = event.target; + + while ( + !(node && node.hasAttribute && node.hasAttribute("class")) || + (!node.classList.contains("bar") && + !node.classList.contains("facet-more") && + !node.classList.contains("facet-content")) + ) { + node = node.parentNode; + } + + if (!(node && node.hasAttribute && node.hasAttribute("class"))) { + return false; + } + + this.currentNode = node; + node.setAttribute("selected", "true"); + + if (node.classList.contains("bar")) { + document.querySelector("facet-popup-menu").show(event, this, node); + } else if (node.classList.contains("facet-more")) { + this.changeMode("all"); + } + + return false; + } catch (e) { + return console.error(e); + } + } + + activateLink(event) { + try { + let node = event.target; + + while ( + !node.hasAttribute("class") || + (!node.classList.contains("facet-more") && + !node.classList.contains("facet-content")) + ) { + node = node.parentNode; + } + + if (node.classList.contains("facet-more")) { + this.changeMode("all"); + } + + return false; + } catch (e) { + return console.error(e); + } + } + } + + customElements.define("facet-discrete", MozFacetDiscrete); + + class MozFacetPopupMenu extends HTMLElement { + constructor() { + super(); + + this.addEventListener("keypress", event => { + switch (event.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + this.hide(); + break; + + case KeyEvent.DOM_VK_DOWN: + this.moveFocus(event, 1); + break; + + case KeyEvent.DOM_VK_TAB: + if (event.shiftKey) { + this.moveFocus(event, -1); + break; + } + + this.moveFocus(event, 1); + break; + + case KeyEvent.DOM_VK_UP: + this.moveFocus(event, -1); + break; + + default: + break; + } + }); + } + + connectedCallback() { + const parentDiv = document.createElement("div"); + parentDiv.classList.add("parent"); + parentDiv.setAttribute("tabIndex", "0"); + + this.includeNode = document.createElement("div"); + this.includeNode.classList.add("popup-menuitem", "top"); + this.includeNode.setAttribute("tabindex", "0"); + this.includeNode.onmouseover = () => { + this.focus(); + }; + this.includeNode.onkeypress = event => { + if (event.keyCode == event.DOM_VK_RETURN) { + this.doInclude(); + } + }; + this.includeNode.onmouseup = () => { + this.doInclude(); + }; + + this.excludeNode = document.createElement("div"); + this.excludeNode.classList.add("popup-menuitem", "bottom"); + this.excludeNode.setAttribute("tabindex", "0"); + this.excludeNode.onmouseover = () => { + this.focus(); + }; + this.excludeNode.onkeypress = event => { + if (event.keyCode == event.DOM_VK_RETURN) { + this.doExclude(); + } + }; + this.excludeNode.onmouseup = () => { + this.doExclude(); + }; + + this.undoNode = document.createElement("div"); + this.undoNode.classList.add("popup-menuitem", "undo"); + this.undoNode.setAttribute("tabindex", "0"); + this.undoNode.onmouseover = () => { + this.focus(); + }; + this.undoNode.onkeypress = event => { + if (event.keyCode == event.DOM_VK_RETURN) { + this.doUndo(); + } + }; + this.undoNode.onmouseup = () => { + this.doUndo(); + }; + + parentDiv.appendChild(this.includeNode); + parentDiv.appendChild(this.excludeNode); + parentDiv.appendChild(this.undoNode); + + this.appendChild(parentDiv); + } + + _getLabel(facetDef, facetValue, groupValue, stringName) { + let labelFormat; + if (stringName in facetDef.strings) { + labelFormat = facetDef.strings[stringName]; + } else { + labelFormat = glodaFacetStrings.GetStringFromName( + `glodaFacetView.facets.${stringName}.fallbackLabel` + ); + } + + if (!labelFormat.includes("#1")) { + return labelFormat; + } + + return labelFormat.replace("#1", facetValue); + } + + build(facetDef, facetValue, groupValue) { + try { + if (groupValue) { + this.includeNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mustMatchLabel" + ); + this.excludeNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "cantMatchLabel" + ); + this.undoNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mayMatchLabel" + ); + } else { + this.includeNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mustMatchNoneLabel" + ); + this.excludeNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mustMatchSomeLabel" + ); + this.undoNode.textContent = this._getLabel( + facetDef, + facetValue, + groupValue, + "mayMatchAnyLabel" + ); + } + } catch (e) { + console.error(e); + } + } + + moveFocus(event, delta) { + try { + // We probably want something quite generic in the long term, but that + // is way too much for now (needs to skip over invisible items, etc) + let focused = document.activeElement; + if (focused == this.includeNode) { + this.excludeNode.focus(); + } else if (focused == this.excludeNode) { + this.includeNode.focus(); + } + event.preventDefault(); + event.stopPropagation(); + } catch (e) { + console.error(e); + } + } + + selectItem(event) { + try { + let focused = document.activeElement; + if (focused == this.includeNode) { + this.doInclude(); + } else if (focused == this.excludeNode) { + this.doExclude(); + } else { + this.doUndo(); + } + } catch (e) { + console.error(e); + } + } + + show(event, facetNode, barNode) { + try { + this.node = barNode; + this.facetNode = facetNode; + let facetDef = facetNode.facetDef; + let groupValue = barNode.groupValue; + let variety = barNode.getAttribute("variety"); + let label = barNode.querySelector(".bar-link").textContent; + this.build(facetDef, label, groupValue); + this.node.setAttribute("selected", "true"); + const rtl = window.getComputedStyle(this).direction == "rtl"; + /* We show different menus if we're on an "unselected" facet value, + or if we're on a preselected facet value, whether included or + excluded. The variety attribute handles that through CSS */ + this.setAttribute("variety", variety); + let rect = barNode.getBoundingClientRect(); + let x, y; + if (event.type == "click") { + // center the menu on the mouse click + if (rtl) { + x = event.pageX + 10; + } else { + x = event.pageX - 10; + } + y = Math.max(20, event.pageY - 15); + } else { + if (rtl) { + x = rect.left + rect.width / 2 + 20; + } else { + x = rect.left + rect.width / 2 - 20; + } + y = rect.top - 10; + } + if (rtl) { + this.style.left = x - this.getBoundingClientRect().width + "px"; + } else { + this.style.left = x + "px"; + } + this.style.top = y + "px"; + + if (variety == "remainder") { + // include + this.includeNode.focus(); + } else { + // undo + this.undoNode.focus(); + } + } catch (e) { + console.error(e); + } + } + + hide() { + try { + this.setAttribute("variety", "invisible"); + if (this.node) { + this.node.removeAttribute("selected"); + this.node.focus(); + } + } catch (e) { + console.error(e); + } + } + + doInclude() { + try { + this.facetNode.includeFacet(this.node); + this.hide(); + } catch (e) { + console.error(e); + } + } + + doExclude() { + this.facetNode.excludeFacet(this.node); + this.hide(); + } + + doUndo() { + this.facetNode.undoFacet(this.node); + this.hide(); + } + } + + customElements.define("facet-popup-menu", MozFacetPopupMenu); + + /** + * MozResultMessage displays an excerpt of a message. Typically these are used in the gloda + * results listing, showing the messages that matched. + */ + class MozFacetResultMessage extends HTMLElement { + constructor() { + super(); + + this.addEventListener("mouseover", event => { + FacetContext.hoverFacet( + FacetContext.fakeResultFaceter, + FacetContext.fakeResultAttr, + this.message, + [this.message] + ); + }); + + this.addEventListener("mouseout", event => { + FacetContext.unhoverFacet( + FacetContext.fakeResultFaceter, + FacetContext.fakeResultAttr, + this.message, + [this.message] + ); + }); + } + + connectedCallback() { + const messageHeader = document.createElement("div"); + + const messageLine = document.createElement("div"); + messageLine.classList.add("message-line"); + + const messageMeta = document.createElement("div"); + messageMeta.classList.add("message-meta"); + + this.addressesGroup = document.createElement("div"); + this.addressesGroup.classList.add("message-addresses-group"); + + this.authorGroup = document.createElement("div"); + this.authorGroup.classList.add("message-author-group"); + + this.author = document.createElement("span"); + this.author.classList.add("message-author"); + + this.date = document.createElement("div"); + this.date.classList.add("message-date"); + + this.authorGroup.appendChild(this.author); + this.authorGroup.appendChild(this.date); + this.addressesGroup.appendChild(this.authorGroup); + messageMeta.appendChild(this.addressesGroup); + messageLine.appendChild(messageMeta); + + const messageSubjectGroup = document.createElement("div"); + messageSubjectGroup.classList.add("message-subject-group"); + + this.star = document.createElement("span"); + this.star.classList.add("message-star"); + + this.subject = document.createElement("span"); + this.subject.classList.add("message-subject"); + this.subject.setAttribute("tabindex", "0"); + this.subject.setAttribute("role", "link"); + + this.tags = document.createElement("span"); + this.tags.classList.add("message-tags"); + + this.recipientsGroup = document.createElement("div"); + this.recipientsGroup.classList.add("message-recipients-group"); + + this.to = document.createElement("span"); + this.to.classList.add("message-to-label"); + + this.recipients = document.createElement("div"); + this.recipients.classList.add("message-recipients"); + + this.recipientsGroup.appendChild(this.to); + this.recipientsGroup.appendChild(this.recipients); + messageSubjectGroup.appendChild(this.star); + messageSubjectGroup.appendChild(this.subject); + messageSubjectGroup.appendChild(this.tags); + messageSubjectGroup.appendChild(this.recipientsGroup); + messageLine.appendChild(messageSubjectGroup); + messageHeader.appendChild(messageLine); + this.appendChild(messageHeader); + + this.snippet = document.createElement("pre"); + this.snippet.classList.add("message-body"); + + this.attachments = document.createElement("div"); + this.attachments.classList.add("message-attachments"); + + this.appendChild(this.snippet); + this.appendChild(this.attachments); + + this.build(); + } + + /* eslint-disable complexity */ + build() { + let message = this.message; + + let subject = this.subject; + // -- eventify + subject.onclick = event => { + FacetContext.showConversationInTab(this, event.button == 1); + }; + subject.onkeypress = event => { + if (Event.keyCode == event.DOM_VK_RETURN) { + FacetContext.showConversationInTab(this, event.shiftKey); + } + }; + + // -- Content Poking + if (message.subject.trim() == "") { + subject.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.result.message.noSubject" + ); + } else { + subject.textContent = message.subject; + } + let authorNode = this.author; + authorNode.setAttribute("title", message.from.value); + authorNode.textContent = message.from.contact.name; + let toNode = this.to; + toNode.textContent = glodaFacetStrings.GetStringFromName( + "glodaFacetView.result.message.toLabel" + ); + + // this.author.textContent = ; + let { makeFriendlyDateAgo } = ChromeUtils.import( + "resource:///modules/TemplateUtils.jsm" + ); + this.date.textContent = makeFriendlyDateAgo(message.date); + + // - Recipients + try { + let recipientsNode = this.recipients; + if (message.recipients) { + let recipientCount = 0; + const MAX_RECIPIENTS = 3; + let totalRecipientCount = message.recipients.length; + let recipientSeparator = glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.recipientSeparator" + ); + for (let index in message.recipients) { + let recipNode = document.createElement("span"); + recipNode.setAttribute("class", "message-recipient"); + recipNode.textContent = message.recipients[index].contact.name; + recipientsNode.appendChild(recipNode); + recipientCount++; + if (recipientCount == MAX_RECIPIENTS) { + break; + } + if (index != totalRecipientCount - 1) { + // add separators (usually commas) + let sepNode = document.createElement("span"); + sepNode.setAttribute("class", "message-recipient-separator"); + sepNode.textContent = recipientSeparator; + recipientsNode.appendChild(sepNode); + } + } + if (totalRecipientCount > MAX_RECIPIENTS) { + let nOthers = totalRecipientCount - recipientCount; + let andNOthers = document.createElement("span"); + andNOthers.setAttribute("class", "message-recipients-andothers"); + + let andOthersLabel = PluralForm.get( + nOthers, + glodaFacetStrings.GetStringFromName( + "glodaFacetView.results.message.andOthers" + ) + ).replace("#1", nOthers); + + andNOthers.textContent = andOthersLabel; + recipientsNode.appendChild(andNOthers); + } + } + } catch (e) { + console.error(e); + } + + // - Starred + let starNode = this.star; + if (message.starred) { + starNode.setAttribute("starred", "true"); + } + + // - Attachments + if (message.attachmentNames) { + let attachmentsNode = this.attachments; + let imgNode = document.createElement("div"); + imgNode.setAttribute("class", "message-attachment-icon"); + attachmentsNode.appendChild(imgNode); + for (let attach of message.attachmentNames) { + let attachNode = document.createElement("div"); + attachNode.setAttribute("class", "message-attachment"); + if (attach.length >= 28) { + attach = attach.substring(0, 24) + "…"; + } + attachNode.textContent = attach; + attachmentsNode.appendChild(attachNode); + } + } + + // - Tags + let tagsNode = this.tags; + if ("tags" in message && message.tags.length) { + for (let tag of message.tags) { + let tagNode = document.createElement("span"); + tagNode.setAttribute("class", "message-tag"); + let color = MailServices.tags.getColorForKey(tag.key); + if (color) { + let textColor = !TagUtils.isColorContrastEnough(color) + ? "white" + : "black"; + tagNode.setAttribute( + "style", + "color: " + textColor + "; background-color: " + color + ";" + ); + } + tagNode.textContent = tag.tag; + tagsNode.appendChild(tagNode); + } + } + + // - Body + if (message.indexedBodyText) { + let bodyText = message.indexedBodyText; + + let matches = []; + if ("stashedColumns" in FacetContext.collection) { + let collection; + if ( + "IMCollection" in FacetContext && + message instanceof Gloda.lookupNounDef("im-conversation").clazz + ) { + collection = FacetContext.IMCollection; + } else { + collection = FacetContext.collection; + } + let offsets = collection.stashedColumns[message.id][0]; + let offsetNums = offsets.split(" ").map(x => parseInt(x)); + for (let i = 0; i < offsetNums.length; i += 4) { + // i is the column index. The indexedBodyText is in the column 0. + // Ignore matches for other columns. + if (offsetNums[i] != 0) { + continue; + } + + // i+1 is the term index, indicating which queried term was found. + // We can ignore for now... + + // i+2 is the *byte* offset at which the term is in the string. + // i+3 is the term's length. + matches.push([offsetNums[i + 2], offsetNums[i + 3]]); + } + + // Sort the matches by index, just to be sure. + // They are probably already sorted, but if they aren't it could + // mess things up at the next step. + matches.sort((a, b) => a[0] - b[0]); + + // Convert the byte offsets and lengths into character indexes. + let charCodeToByteCount = c => { + // UTF-8 stores: + // - code points below U+0080 on 1 byte, + // - code points below U+0800 on 2 bytes, + // - code points U+D800 through U+DFFF are UTF-16 surrogate halves + // (they indicate that JS has split a 4 bytes UTF-8 character + // in two halves of 2 bytes each), + // - other code points on 3 bytes. + if (c < 0x80) { + return 1; + } + if (c < 0x800 || (c >= 0xd800 && c <= 0xdfff)) { + return 2; + } + return 3; + }; + let byteOffset = 0; + let offset = 0; + for (let match of matches) { + while (byteOffset < match[0]) { + byteOffset += charCodeToByteCount(bodyText.charCodeAt(offset++)); + } + match[0] = offset; + for (let i = offset; i < offset + match[1]; ++i) { + let size = charCodeToByteCount(bodyText.charCodeAt(i)); + if (size > 1) { + match[1] -= size - 1; + } + } + } + } + + // how many lines of context we want before the first match: + const kContextLines = 2; + + let startIndex = 0; + if (matches.length > 0) { + // Find where the snippet should begin to show at least the + // first match and kContextLines of context before the match. + startIndex = matches[0][0]; + for (let context = kContextLines; context >= 0; --context) { + startIndex = bodyText.lastIndexOf("\n", startIndex - 1); + if (startIndex == -1) { + startIndex = 0; + break; + } + } + } + + // start assuming it's just one line that we want to show + let idxNewline = -1; + let ellipses = "…"; + + let maxLineCount = 5; + if (startIndex != 0) { + // Avoid displaying an ellipses followed by an empty line. + while (bodyText[startIndex + 1] == "\n") { + ++startIndex; + } + bodyText = ellipses + bodyText.substring(startIndex); + // The first line will only contain the ellipsis as the character + // at startIndex is always \n, so we show an additional line. + ++maxLineCount; + } + + for ( + let newlineCount = 0; + newlineCount < maxLineCount; + newlineCount++ + ) { + idxNewline = bodyText.indexOf("\n", idxNewline + 1); + if (idxNewline == -1) { + ellipses = ""; + break; + } + } + let snippet = ""; + if (idxNewline > -1) { + snippet = bodyText.substring(0, idxNewline); + } else { + snippet = bodyText; + } + if (ellipses) { + snippet = snippet.trimRight() + ellipses; + } + + let parent = this.snippet; + let node = document.createTextNode(snippet); + parent.appendChild(node); + + let offset = startIndex ? startIndex - 1 : 0; // The ellipsis takes 1 character. + for (let match of matches) { + if (idxNewline > -1 && match[0] > startIndex + idxNewline) { + break; + } + let secondNode = node.splitText(match[0] - offset); + node = secondNode.splitText(match[1]); + offset += match[0] + match[1] - offset; + let span = document.createElement("span"); + span.textContent = secondNode.data; + if (!this.firstMatchText) { + this.firstMatchText = secondNode.data; + } + span.setAttribute("class", "message-body-fulltext-match"); + parent.replaceChild(span, secondNode); + } + } + + // - Misc attributes + if (!message.read) { + this.setAttribute("unread", "true"); + } + } + } + + customElements.define("facet-result-message", MozFacetResultMessage); +} diff --git a/comm/mail/base/content/widgets/header-fields.js b/comm/mail/base/content/widgets/header-fields.js new file mode 100644 index 0000000000..10ec83b45c --- /dev/null +++ b/comm/mail/base/content/widgets/header-fields.js @@ -0,0 +1,973 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global gMessageHeader, gShowCondensedEmailAddresses, openUILink */ + +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + + const lazy = {}; + ChromeUtils.defineModuleGetter( + lazy, + "DisplayNameUtils", + "resource:///modules/DisplayNameUtils.jsm" + ); + ChromeUtils.defineModuleGetter( + lazy, + "TagUtils", + "resource:///modules/TagUtils.jsm" + ); + + class MultiRecipientRow extends HTMLDivElement { + /** + * The number of lines of recipients to display before adding a <more> + * indicator to the widget. This can be increased using the preference + * mailnews.headers.show_n_lines_before_more. + * + * @type {integer} + */ + #maxLinesBeforeMore = 1; + + /** + * The array of all the recipients that need to be shown in this widget. + * + * @type {Array<object>} + */ + #recipients = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "multi-recipient-row"); + this.classList.add("multi-recipient-row"); + + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + // message-header-to-list-name + // message-header-from-list-name + // message-header-cc-list-name + // message-header-bcc-list-name + // message-header-sender-list-name + // message-header-reply-to-list-name + document.l10n.setAttributes( + this.heading, + `message-header-${this.dataset.headerName}-list-name` + ); + this.appendChild(this.heading); + + this.recipientsList = document.createElement("ol"); + this.recipientsList.classList.add("recipients-list"); + this.recipientsList.setAttribute("aria-labelledby", this.heading.id); + this.appendChild(this.recipientsList); + + this.moreButton = document.createElement("button"); + this.moreButton.setAttribute("type", "button"); + this.moreButton.classList.add("show-more-recipients", "plain"); + this.moreButton.addEventListener( + "mousedown", + // Prevent focus being transferred to the button before it is removed. + event => event.preventDefault() + ); + this.moreButton.addEventListener("click", () => this.showAllRecipients()); + + document.l10n.setAttributes( + this.moreButton, + "message-header-field-show-more" + ); + + // @implements {nsIObserver} + this.ABObserver = { + /** + * Array list of all observable notifications. + * + * @type {Array<string>} + */ + _notifications: [ + "addrbook-directory-created", + "addrbook-directory-deleted", + "addrbook-contact-created", + "addrbook-contact-updated", + "addrbook-contact-deleted", + ], + + addObservers() { + for (let topic of this._notifications) { + Services.obs.addObserver(this, topic); + } + this._added = true; + window.addEventListener("unload", this); + }, + + removeObservers() { + if (!this._added) { + return; + } + for (let topic of this._notifications) { + Services.obs.removeObserver(this, topic); + } + this._added = false; + window.removeEventListener("unload", this); + }, + + handleEvent() { + this.removeObservers(); + }, + + observe: (subject, topic, data) => { + switch (topic) { + case "addrbook-directory-created": + case "addrbook-directory-deleted": + subject.QueryInterface(Ci.nsIAbDirectory); + this.directoryChanged(subject); + break; + case "addrbook-contact-created": + case "addrbook-contact-updated": + case "addrbook-contact-deleted": + subject.QueryInterface(Ci.nsIAbCard); + this.contactUpdated(subject); + break; + } + }, + }; + + this.ABObserver.addObservers(); + } + + /** + * Clear things out when the element is removed from the DOM. + */ + disconnectedCallback() { + this.ABObserver.removeObservers(); + } + + /** + * Loop through all available recipients and check if any of those belonged + * to the created or removed address book. + * + * @param {nsIAbDirectory} subject - The created or removed Address Book. + */ + directoryChanged(subject) { + if (!(subject instanceof Ci.nsIAbDirectory)) { + return; + } + + for (let recipient of [...this.recipientsList.childNodes].filter( + r => r.cardDetails?.book?.dirPrefId == subject.dirPrefId + )) { + recipient.updateRecipient(); + } + } + + /** + * Loop through all available recipients and update the UI to reflect if + * they were saved, updated, or removed as contacts in an address book. + * + * @param {nsIAbCard} subject - The changed contact card. + */ + contactUpdated(subject) { + if (!(subject instanceof Ci.nsIAbCard)) { + // Bail out if this is not a valid Address Book Card object. + return; + } + + if (!subject.isMailList && !subject.emailAddresses.length) { + // Bail out if we don't have any addresses to match against. + return; + } + + let addresses = subject.emailAddresses; + for (let recipient of [...this.recipientsList.childNodes].filter( + r => r.emailAddress && addresses.includes(r.emailAddress) + )) { + recipient.updateRecipient(); + } + } + + /** + * Add a recipient to be shown in this widget. The recipient won't be shown + * until the row view is built. + * + * @param {object} recipient - The recipient element. + * @param {string} recipient.displayName - The recipient display name. + * @param {string} [recipient.emailAddress] - The recipient email address. + * @param {string} [recipient.fullAddress] - The recipient full address. + */ + addRecipient(recipient) { + this.#recipients.push(recipient); + } + + buildView() { + this.#maxLinesBeforeMore = Services.prefs.getIntPref( + "mailnews.headers.show_n_lines_before_more" + ); + let showAllHeaders = + this.#maxLinesBeforeMore < 1 || + Services.prefs.getIntPref("mail.show_headers") == + Ci.nsMimeHeaderDisplayTypes.AllHeaders || + this.dataset.showAll == "true"; + this.buildRecipients(showAllHeaders); + } + + buildRecipients(showAllHeaders) { + // Determine focus before clearing the children. + let focusIndex = [...this.recipientsList.childNodes].findIndex(node => + node.contains(document.activeElement) + ); + this.recipientsList.replaceChildren(); + gMessageHeader.toggleScrollableHeader(showAllHeaders); + + // Store the available width of the entire row. + // FIXME! The size of the rows can variate depending on when adjacent + // elements are generated (e.g.: TO row + date row), therefore this size + // is not always accurate when viewing the first email. We should defer + // the generation of the multi recipient rows only after all the other + // headers have been populated. + let availableWidth = !showAllHeaders + ? this.recipientsList.getBoundingClientRect().width + : 0; + + // Track the space occupied by recipients per row. Every time we exceed + // the available space of a single row, we reset this value. + let currentRowWidth = 0; + // Track how many rows are being populated by recipients. + let rows = 1; + for (let [count, recipient] of this.#recipients.entries()) { + let li = document.createElement("li", { is: "header-recipient" }); + // Set an id before connected callback is called on the element. + li.id = `${this.dataset.headerName}Recipient${count}`; + // Append the element to the DOM to trigger the connectedCallback. + this.recipientsList.appendChild(li); + li.dataset.headerName = this.dataset.headerName; + li.recipient = recipient; + + // Bail out if we need to show all elements. + if (showAllHeaders) { + continue; + } + + // Keep track of how much space our recipients are occupying. + let width = li.getBoundingClientRect().width; + // FIXME! If we have more than one recipient, we add a comma as pseudo + // element after the previous element. Account for that by adding an + // arbitrary 30px size to simulate extra characters space. This is a bit + // of an extreme sizing as it's almost as large as the more button, but + // it's necessary to make sure we never encounter that scenario. + if (count > 0) { + width += 30; + } + currentRowWidth += width; + + if (currentRowWidth <= availableWidth) { + continue; + } + + // If the recipients available in the current row exceed the + // available space, increase the row count and set the value of the + // last added list item to the next row width counter. + if (rows < this.#maxLinesBeforeMore) { + rows++; + currentRowWidth = width; + continue; + } + + // Append the "more" button inside a list item to be properly handled + // as an inline element of the recipients list UI. + let buttonLi = document.createElement("li"); + buttonLi.appendChild(this.moreButton); + this.recipientsList.appendChild(buttonLi); + currentRowWidth += buttonLi.getBoundingClientRect().width; + + // Reverse loop through the added list item and remove them until + // they all fit in the current row alongside the "more" button. + for (; count && currentRowWidth > availableWidth; count--) { + let toRemove = this.recipientsList.childNodes[count]; + currentRowWidth -= toRemove.getBoundingClientRect().width; + toRemove.remove(); + } + + // Skip the "more" button, which is present if we reached this stage. + let lastRecipientIndex = this.recipientsList.childNodes.length - 2; + // Add a unique class to the last visible recipient to remove the + // comma separator added via pseudo element. + this.recipientsList.childNodes[lastRecipientIndex].classList.add( + "last-before-button" + ); + + break; + } + + if (focusIndex >= 0) { + // If we had focus before, restore focus to the same index, or the last node. + let focusNode = + this.recipientsList.childNodes[ + Math.min(focusIndex, this.recipientsList.childNodes.length - 1) + ]; + if (focusNode.contains(this.moreButton)) { + // The button is focusable. + this.moreButton.focus(); + } else { + // The item is focusable. + focusNode.focus(); + } + } + } + + /** + * Show all recipients available in this widget. + */ + showAllRecipients() { + this.buildRecipients(true); + } + + /** + * Empty the widget. + */ + clear() { + this.#recipients = []; + this.recipientsList.replaceChildren(); + } + } + customElements.define("multi-recipient-row", MultiRecipientRow, { + extends: "div", + }); + + class HeaderRecipient extends HTMLLIElement { + /** + * The object holding the recipient information. + * + * @type {object} + * @property {string} displayName - The recipient display name. + * @property {string} [emailAddress] - The recipient email address. + * @property {string} [fullAddress] - The recipient full address. + */ + #recipient = {}; + + /** + * The Card object if the recipients is saved in the address book. + * + * @type {object} + * @property {?object} book - The address book in which the contact is + * saved, if we have a card. + * @property {?object} card - The saved contact card, if present. + */ + cardDetails = {}; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-recipient"); + this.classList.add("header-recipient"); + this.tabIndex = 0; + + this.avatar = document.createElement("div"); + this.avatar.classList.add("recipient-avatar"); + this.appendChild(this.avatar); + + this.email = document.createElement("span"); + this.email.classList.add("recipient-single-line"); + this.email.id = `${this.id}Display`; + this.appendChild(this.email); + + this.multiLine = document.createElement("span"); + this.multiLine.classList.add("recipient-multi-line"); + + this.nameLine = document.createElement("span"); + this.nameLine.classList.add("recipient-multi-line-name"); + this.multiLine.appendChild(this.nameLine); + + this.addressLine = document.createElement("span"); + this.addressLine.classList.add("recipient-multi-line-address"); + this.multiLine.appendChild(this.addressLine); + + this.appendChild(this.multiLine); + + this.abIndicator = document.createElement("button"); + this.abIndicator.classList.add( + "recipient-address-book-button", + "plain-button" + ); + // We make the button non-focusable since its functionality is equivalent + // to the first item in the popup menu, so we can save a tab-stop. + this.abIndicator.tabIndex = -1; + this.abIndicator.addEventListener("click", event => { + event.stopPropagation(); + if (this.cardDetails.card) { + gMessageHeader.editContact(this); + return; + } + + this.addToAddressBook(); + }); + + let img = document.createElement("img"); + img.id = `${this.id}AbIcon`; + img.src = "chrome://messenger/skin/icons/new/address-book-indicator.svg"; + document.l10n.setAttributes( + img, + "message-header-address-not-in-address-book-icon2" + ); + + this.abIndicator.appendChild(img); + this.appendChild(this.abIndicator); + + // Use the email and icon as the accessible name. We do this to stop the + // button title from contributing to the accessible name. + // TODO: If the button or its title is removed, or the title replaces the + // image alt text, then remove this aria-labelledby attribute. The id's + // will no longer be necessary either. + this.setAttribute("aria-labelledby", `${this.email.id} ${img.id}`); + + this.addEventListener("contextmenu", event => { + gMessageHeader.openEmailAddressPopup(event, this); + }); + this.addEventListener("click", event => { + gMessageHeader.openEmailAddressPopup(event, this); + }); + this.addEventListener("keypress", event => { + if (event.key == "Enter") { + gMessageHeader.openEmailAddressPopup(event, this); + } + }); + } + + set recipient(recipient) { + this.#recipient = recipient; + this.updateRecipient(); + } + + get displayName() { + return this.#recipient.displayName; + } + + get emailAddress() { + return this.#recipient.emailAddress; + } + + get fullAddress() { + return this.#recipient.fullAddress; + } + + updateRecipient() { + if (!this.emailAddress) { + this.abIndicator.hidden = true; + this.email.textContent = this.displayName; + if (this.dataset.headerName == "from") { + this.nameLine.textContent = this.displayName; + this.addressLine.textContent = ""; + this.avatar.replaceChildren(); + this.avatar.classList.remove("has-avatar"); + } + this.cardDetails = {}; + return; + } + + this.abIndicator.hidden = false; + let card = MailServices.ab.cardForEmailAddress( + this.#recipient.emailAddress + ); + this.cardDetails = { + card, + book: card + ? MailServices.ab.getDirectoryFromUID(card.directoryUID) + : null, + }; + + let displayName = lazy.DisplayNameUtils.formatDisplayName( + this.emailAddress, + this.displayName, + this.dataset.headerName, + this.cardDetails.card + ); + + // Show only the display name if we have a valid card and the user wants + // to show a condensed header (without the full email address) for saved + // contacts. + if (gShowCondensedEmailAddresses && displayName) { + this.email.textContent = displayName; + this.email.setAttribute("title", this.#recipient.fullAddress); + } else { + this.email.textContent = this.#recipient.fullAddress; + this.email.removeAttribute("title"); + } + + if (this.dataset.headerName == "from") { + if (gShowCondensedEmailAddresses) { + this.nameLine.textContent = + displayName || this.displayName || this.fullAddress; + } else { + this.nameLine.textContent = this.fullAddress; + } + this.addressLine.textContent = this.emailAddress; + } + + let hasCard = this.cardDetails.card; + // Update the style of the indicator button. + this.abIndicator.classList.toggle("in-address-book", hasCard); + document.l10n.setAttributes( + this.abIndicator, + hasCard + ? "message-header-address-in-address-book-button" + : "message-header-address-not-in-address-book-button" + ); + document.l10n.setAttributes( + this.abIndicator.querySelector("img"), + hasCard + ? "message-header-address-in-address-book-icon2" + : "message-header-address-not-in-address-book-icon2" + ); + + if (this.dataset.headerName == "from") { + this._updateAvatar(); + } + } + + _updateAvatar() { + this.avatar.replaceChildren(); + + if (!this.cardDetails.card) { + this._createAvatarPlaceholder(); + return; + } + + // We have a card, so let's try to fetch the image. + let card = this.cardDetails.card; + let photoURL = card.photoURL; + if (photoURL) { + let img = document.createElement("img"); + document.l10n.setAttributes(img, "message-header-recipient-avatar", { + address: this.emailAddress, + }); + // TODO: We should fetch a dynamically generated smaller version of the + // uploaded picture to avoid loading large images that will only be used + // in smaller format. + img.src = photoURL; + this.avatar.appendChild(img); + this.avatar.classList.add("has-avatar"); + } else { + this._createAvatarPlaceholder(); + } + } + + _createAvatarPlaceholder() { + let letter = document.createElement("span"); + letter.textContent = Array.from( + this.nameLine.textContent || this.displayName || this.fullAddress + )[0]?.toUpperCase(); + letter.setAttribute("aria-hidden", "true"); + this.avatar.appendChild(letter); + this.avatar.classList.remove("has-avatar"); + } + + addToAddressBook() { + let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance( + Ci.nsIAbCard + ); + card.displayName = this.#recipient.displayName; + card.primaryEmail = this.#recipient.emailAddress; + + let addressBook = MailServices.ab.getDirectory( + "jsaddrbook://abook.sqlite" + ); + addressBook.addCard(card); + } + } + customElements.define("header-recipient", HeaderRecipient, { + extends: "li", + }); + + class SimpleHeaderRow extends HTMLDivElement { + constructor() { + super(); + + this.addEventListener("contextmenu", event => { + gMessageHeader.openCopyPopup(event, this); + }); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "simple-header-row"); + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + let sep = document.createElement("span"); + sep.classList.add("screen-reader-only"); + sep.setAttribute("data-l10n-name", "field-separator"); + this.heading.appendChild(sep); + + if ( + ["organization", "subject", "date", "user-agent"].includes( + this.dataset.headerName + ) + ) { + // message-header-organization-field + // message-header-subject-field + // message-header-date-field + // message-header-user-agent-field + document.l10n.setAttributes( + this.heading, + `message-header-${this.dataset.headerName}-field` + ); + } else { + // If this simple row is used by an autogenerated custom header, + // use directly that header value as label. + document.l10n.setAttributes( + this.heading, + "message-header-custom-field", + { + fieldName: this.dataset.prettyHeaderName, + } + ); + } + this.appendChild(this.heading); + + this.classList.add("header-row"); + this.tabIndex = 0; + + this.value = document.createElement("span"); + this.appendChild(this.value); + } + + /** + * Set the text content for this row. + * + * @param {string} val - The content string to be added to this row. + */ + set headerValue(val) { + this.value.textContent = val; + // NOTE: In principle, we could use aria-labelledby and point to the + // heading and value elements. However, for some reason the expected + // accessible name is not read out when focused whilst using Orca screen + // reader. Instead, only the content of the value element is read out. + // This may be because this element has no proper ARIA role since we are + // extending a div, which is not a best approach, so we can't expect + // proper support. + // TODO: This area needs some proper semantics to associate the fieldname + // with the field value, whilst being focusable to allow the user to open + // a context menu on the row. + this.setAttribute( + "aria-label", + `${this.heading.textContent} ${this.value.textContent}` + ); + } + } + customElements.define("simple-header-row", SimpleHeaderRow, { + extends: "div", + }); + + class UrlHeaderRow extends SimpleHeaderRow { + connectedCallback() { + if (this.hasConnected) { + return; + } + super.connectedCallback(); + + this.setAttribute("is", "url-header-row"); + document.l10n.setAttributes(this.heading, "message-header-website-field"); + + this.value.classList.add("text-link"); + this.addEventListener("click", event => { + if (event.button != 2) { + openUILink(encodeURI(this.value.textContent), event); + } + }); + this.addEventListener("keydown", event => { + if (event.key == "Enter") { + openUILink(encodeURI(this.value.textContent), event); + } + }); + } + } + customElements.define("url-header-row", UrlHeaderRow, { + extends: "div", + }); + + class HeaderNewsgroupsRow extends HTMLDivElement { + /** + * The array of all the newsgroups that need to be shown in this row. + * + * @type {Array<object>} + */ + #newsgroups = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-newsgroups-row"); + this.classList.add("header-newsgroups-row"); + + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + // message-header-newsgroups-list-name + // message-header-followup-to-list-name + document.l10n.setAttributes( + this.heading, + `message-header-${this.dataset.headerName}-list-name` + ); + this.appendChild(this.heading); + + this.newsgroupsList = document.createElement("ol"); + this.newsgroupsList.classList.add("newsgroups-list"); + this.newsgroupsList.setAttribute("aria-labelledby", this.heading.id); + this.appendChild(this.newsgroupsList); + } + + addNewsgroup(newsgroup) { + this.#newsgroups.push(newsgroup); + } + + buildView() { + this.newsgroupsList.replaceChildren(); + for (let newsgroup of this.#newsgroups) { + let li = document.createElement("li", { is: "header-newsgroup" }); + this.newsgroupsList.appendChild(li); + li.textContent = newsgroup; + } + } + + clear() { + this.#newsgroups = []; + this.newsgroupsList.replaceChildren(); + } + } + customElements.define("header-newsgroups-row", HeaderNewsgroupsRow, { + extends: "div", + }); + + class HeaderNewsgroup extends HTMLLIElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-newsgroup"); + this.classList.add("header-newsgroup"); + this.tabIndex = 0; + + this.addEventListener("contextmenu", event => { + gMessageHeader.openNewsgroupPopup(event, this); + }); + this.addEventListener("click", event => { + gMessageHeader.openNewsgroupPopup(event, this); + }); + this.addEventListener("keypress", event => { + if (event.key == "Enter") { + gMessageHeader.openNewsgroupPopup(event, this); + } + }); + } + } + customElements.define("header-newsgroup", HeaderNewsgroup, { + extends: "li", + }); + + class HeaderTagsRow extends HTMLDivElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-tags-row"); + this.classList.add("header-tags-row"); + + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + document.l10n.setAttributes( + this.heading, + "message-header-tags-list-name" + ); + this.appendChild(this.heading); + + this.tagsList = document.createElement("ol"); + this.tagsList.classList.add("tags-list"); + this.tagsList.setAttribute("aria-labelledby", this.heading.id); + this.appendChild(this.tagsList); + } + + buildTags(tags) { + // Clear old tags. + this.tagsList.replaceChildren(); + + for (let tag of tags) { + // For each tag, create a label, give it the font color that corresponds to the + // color of the tag and append it. + let tagName; + try { + // if we got a bad tag name, getTagForKey will throw an exception, skip it + // and go to the next one. + tagName = MailServices.tags.getTagForKey(tag); + } catch (ex) { + continue; + } + + // Create a label for the tag name and set the color. + let li = document.createElement("li"); + li.tabIndex = 0; + li.classList.add("tag"); + li.textContent = tagName; + + let color = MailServices.tags.getColorForKey(tag); + if (color) { + let textColor = !lazy.TagUtils.isColorContrastEnough(color) + ? "white" + : "black"; + li.setAttribute( + "style", + `color: ${textColor}; background-color: ${color};` + ); + } + + this.tagsList.appendChild(li); + } + } + + clear() { + this.tagsList.replaceChildren(); + } + } + customElements.define("header-tags-row", HeaderTagsRow, { + extends: "div", + }); + + class MultiMessageIdsRow extends HTMLDivElement { + /** + * The array of all the IDs that need to be shown in this row. + * + * @type {Array<object>} + */ + #ids = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "multi-message-ids-row"); + this.classList.add("multi-message-ids-row"); + + this.heading = document.createElement("span"); + this.heading.id = `${this.dataset.headerName}Heading`; + this.heading.classList.add("row-heading"); + let sep = document.createElement("span"); + sep.classList.add("screen-reader-only"); + sep.setAttribute("data-l10n-name", "field-separator"); + this.heading.appendChild(sep); + + // message-header-references-field + // message-header-message-id-field + // message-header-in-reply-to-field + document.l10n.setAttributes( + this.heading, + `message-header-${this.dataset.headerName}-field` + ); + this.appendChild(this.heading); + + this.idsList = document.createElement("ol"); + this.idsList.classList.add("ids-list"); + this.appendChild(this.idsList); + + this.toggleButton = document.createElement("button"); + this.toggleButton.setAttribute("type", "button"); + this.toggleButton.classList.add("show-more-ids", "plain"); + this.toggleButton.addEventListener( + "mousedown", + // Prevent focus being transferred to the button before it is removed. + event => event.preventDefault() + ); + this.toggleButton.addEventListener("click", () => this.buildView(true)); + + document.l10n.setAttributes( + this.toggleButton, + "message-ids-field-show-all" + ); + } + + addId(id) { + this.#ids.push(id); + } + + buildView(showAll = false) { + this.idsList.replaceChildren(); + for (let [count, id] of this.#ids.entries()) { + let li = document.createElement("li", { is: "header-message-id" }); + li.id = id; + this.idsList.appendChild(li); + if (!showAll && count < this.#ids.length - 1 && this.#ids.length > 1) { + li.messageId.textContent = count + 1; + li.messageId.title = id; + } else { + li.messageId.textContent = id; + } + } + + if (!showAll && this.#ids.length > 1) { + this.idsList.lastElementChild.classList.add("last-before-button"); + let liButton = document.createElement("li"); + liButton.appendChild(this.toggleButton); + this.idsList.appendChild(liButton); + } + } + + clear() { + this.#ids = []; + this.idsList.replaceChildren(); + } + } + customElements.define("multi-message-ids-row", MultiMessageIdsRow, { + extends: "div", + }); + + class HeaderMessageId extends HTMLLIElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "header-message-id"); + this.classList.add("header-message-id"); + + this.messageId = document.createElement("span"); + this.messageId.classList.add("text-link"); + this.messageId.tabIndex = 0; + this.appendChild(this.messageId); + + this.messageId.addEventListener("contextmenu", event => { + gMessageHeader.openMessageIdPopup(event, this); + }); + this.messageId.addEventListener("click", event => { + gMessageHeader.onMessageIdClick(event); + }); + this.messageId.addEventListener("keypress", event => { + if (event.key == "Enter") { + gMessageHeader.onMessageIdClick(event); + } + }); + } + } + customElements.define("header-message-id", HeaderMessageId, { + extends: "li", + }); +} diff --git a/comm/mail/base/content/widgets/mailWidgets.js b/comm/mail/base/content/widgets/mailWidgets.js new file mode 100644 index 0000000000..6ad566b742 --- /dev/null +++ b/comm/mail/base/content/widgets/mailWidgets.js @@ -0,0 +1,2477 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from ../../../components/compose/content/addressingWidgetOverlay.js */ +/* import-globals-from ../../../components/compose/content/MsgComposeCommands.js */ + +/* global MozElements */ +/* global MozXULElement */ +/* global gFolderDisplay */ +/* global PluralForm */ +/* global onRecipientsChanged */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" + ); + const LazyModules = {}; + + ChromeUtils.defineModuleGetter( + LazyModules, + "DBViewWrapper", + "resource:///modules/DBViewWrapper.jsm" + ); + ChromeUtils.defineModuleGetter( + LazyModules, + "MailUtils", + "resource:///modules/MailUtils.jsm" + ); + ChromeUtils.defineModuleGetter( + LazyModules, + "MimeParser", + "resource:///modules/mimeParser.jsm" + ); + ChromeUtils.defineModuleGetter( + LazyModules, + "TagUtils", + "resource:///modules/TagUtils.jsm" + ); + + // NOTE: Icon column headers should have their "label" attribute set to + // describe the icon for the accessibility tree. + // + // NOTE: Ideally we could listen for the "alt" attribute and pass it on to the + // contained <img>, but the accessibility tree only seems to read the "label" + // for a <treecol>, and ignores the alt text. + class MozTreecolImage extends customElements.get("treecol") { + static get observedAttributes() { + return ["src"]; + } + + connectedCallback() { + if (this.hasChildNodes() || this.delayConnectedCallback()) { + return; + } + this.image = document.createElement("img"); + this.image.classList.add("treecol-icon"); + + this.appendChild(this.image); + this._updateAttributes(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + _updateAttributes() { + if (!this.image) { + return; + } + + const src = this.getAttribute("src"); + + if (src != null) { + this.image.setAttribute("src", src); + } else { + this.image.removeAttribute("src"); + } + } + } + customElements.define("treecol-image", MozTreecolImage, { + extends: "treecol", + }); + + /** + * Class extending treecols. This features a customized treecolpicker that + * features a menupopup with more items than the standard one. + * + * @augments {MozTreecols} + */ + class MozThreadPaneTreecols extends customElements.get("treecols") { + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + let treecolpicker = this.querySelector("treecolpicker:not([is]"); + + // Can't change the super treecolpicker by setting + // is="thread-pane-treecolpicker" since that needs to be there at the + // parsing stage to take effect. + // So, remove the existing treecolpicker, and add a new one. + if (treecolpicker) { + treecolpicker.remove(); + } + if (!this.querySelector("treecolpicker[is=thread-pane-treecolpicker]")) { + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <treecolpicker is="thread-pane-treecolpicker" + class="thread-tree-col-picker" + tooltiptext="&columnChooser2.tooltip;" + fixed="true"> + </treecolpicker> + `, + ["chrome://messenger/locale/messenger.dtd"] + ) + ); + } + // Exceptionally apply super late, so we get the other goodness from there + // now that the treecolpicker is corrected. + super.connectedCallback(); + } + } + customElements.define("thread-pane-treecols", MozThreadPaneTreecols, { + extends: "treecols", + }); + + /** + * Class extending treecolpicker. This implements UI to apply column settings + * of the current thread pane to other mail folders too. + * + * @augments {MozTreecolPicker} + */ + class MozThreadPaneTreeColpicker extends customElements.get("treecolpicker") { + connectedCallback() { + super.connectedCallback(); + if (this.delayConnectedCallback()) { + return; + } + MozXULElement.insertFTLIfNeeded("messenger/mailWidgets.ftl"); + let popup = this.querySelector(`menupopup[anonid="popup"]`); + + // We'll add an "Apply columns to..." menu + popup.appendChild( + MozXULElement.parseXULToFragment( + ` + <menu class="applyTo-menu" label="&columnPicker.applyTo.label;"> + <menupopup> + <menu class="applyToFolder-menu" + label="&columnPicker.applyToFolder.label;"> + <menupopup is="folder-menupopup" + class="applyToFolder" + showFileHereLabel="true" + position="start_before"></menupopup> + </menu> + <menu class="applyToFolderAndChildren-menu" + label="&columnPicker.applyToFolderAndChildren.label;"> + <menupopup is="folder-menupopup" + class="applyToFolderAndChildren" + showFileHereLabel="true" + showAccountsFileHere="true" + position="start_before"></menupopup> + </menu> + </menupopup> + </menu> + <menu class="applyViewTo-menu" data-l10n-id="apply-current-view-to-menu"> + <menupopup> + <menu class="applyViewToFolder-menu" + label="&columnPicker.applyToFolder.label;"> + <menupopup is="folder-menupopup" + class="applyViewToFolder" + showFileHereLabel="true" + position="start_before"></menupopup> + </menu> + <menu class="applyViewToFolderAndChildren-menu" + label="&columnPicker.applyToFolderAndChildren.label;"> + <menupopup is="folder-menupopup" + class="applyViewToFolderAndChildren" + showFileHereLabel="true" + showAccountsFileHere="true" + position="start_before"></menupopup> + </menu> + </menupopup> + </menu> + `, + ["chrome://messenger/locale/messenger.dtd"] + ) + ); + + let confirmApplyCols = (destFolder, useChildren) => { + // Confirm the action with the user. + let bundle = document.getElementById("bundle_messenger"); + let title = useChildren + ? "threadPane.columnPicker.confirmFolder.withChildren.title" + : "threadPane.columnPicker.confirmFolder.noChildren.title"; + let message = useChildren + ? "threadPane.columnPicker.confirmFolder.withChildren.message" + : "threadPane.columnPicker.confirmFolder.noChildren.message"; + let confirmed = Services.prompt.confirm( + null, + bundle.getString(title), + bundle.getFormattedString(message, [destFolder.prettyName]) + ); + if (confirmed) { + this._applyColumns(destFolder, useChildren); + } + }; + + this.querySelector(".applyToFolder-menu").addEventListener( + "command", + event => { + confirmApplyCols(event.target._folder, false); + } + ); + + this.querySelector(".applyToFolderAndChildren-menu").addEventListener( + "command", + event => { + confirmApplyCols(event.target._folder, true); + } + ); + + let confirmApplyView = async (destFolder, useChildren) => { + let msgId = useChildren + ? "threadpane-apply-changes-prompt-with-children-text" + : "threadpane-apply-changes-prompt-no-children-text"; + let [title, message] = await document.l10n.formatValues([ + { id: "threadpane-apply-changes-prompt-title" }, + { id: msgId, args: { name: destFolder.prettyName } }, + ]); + if (Services.prompt.confirm(null, title, message)) { + this._applyView(destFolder, useChildren); + } + }; + + this.querySelector(".applyViewToFolder-menu").addEventListener( + "command", + event => { + confirmApplyView(event.target._folder, false); + } + ); + + this.querySelector(".applyViewToFolderAndChildren-menu").addEventListener( + "command", + event => { + confirmApplyView(event.target._folder, true); + } + ); + } + + _applyColumns(destFolder, useChildren) { + // Get the current folder's column state, plus the "swapped" column + // state, which swaps "From" and "Recipient" if only one is shown. + // This is useful for copying an incoming folder's columns to an + // outgoing folder, or vice versa. + let colState = gFolderDisplay.getColumnStates(); + + let myColStateString = JSON.stringify(colState); + let swappedColStateString; + if (colState.senderCol.visible != colState.recipientCol.visible) { + let tmp = colState.senderCol; + colState.senderCol = colState.recipientCol; + colState.recipientCol = tmp; + swappedColStateString = JSON.stringify(colState); + } else { + swappedColStateString = myColStateString; + } + + let isOutgoing = function (folder) { + return folder.isSpecialFolder( + LazyModules.DBViewWrapper.prototype.OUTGOING_FOLDER_FLAGS, + true + ); + }; + + let amIOutgoing = isOutgoing(gFolderDisplay.displayedFolder); + + let colStateString = function (folder) { + return isOutgoing(folder) == amIOutgoing + ? myColStateString + : swappedColStateString; + }; + + // Now propagate appropriately... + const propName = gFolderDisplay.PERSISTED_COLUMN_PROPERTY_NAME; + if (useChildren) { + LazyModules.MailUtils.takeActionOnFolderAndDescendents( + destFolder, + folder => { + folder.setStringProperty(propName, colStateString(folder)); + // Force the reference to be forgotten. + folder.msgDatabase = null; + } + ).then(() => { + Services.obs.notifyObservers( + gFolderDisplay.displayedFolder, + "msg-folder-columns-propagated" + ); + }); + } else { + destFolder.setStringProperty(propName, colStateString(destFolder)); + // null out to avoid memory bloat. + destFolder.msgDatabase = null; + } + } + + _applyView(destFolder, useChildren) { + let viewFlags = + gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.viewFlags; + let sortType = + gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortType; + let sortOrder = + gFolderDisplay.displayedFolder.msgDatabase.dBFolderInfo.sortOrder; + if (useChildren) { + LazyModules.MailUtils.takeActionOnFolderAndDescendents( + destFolder, + folder => { + folder.msgDatabase.dBFolderInfo.viewFlags = viewFlags; + folder.msgDatabase.dBFolderInfo.sortType = sortType; + folder.msgDatabase.dBFolderInfo.sortOrder = sortOrder; + folder.msgDatabase = null; + } + ).then(() => { + Services.obs.notifyObservers( + gFolderDisplay.displayedFolder, + "msg-folder-views-propagated" + ); + }); + } else { + destFolder.msgDatabase.dBFolderInfo.viewFlags = viewFlags; + destFolder.msgDatabase.dBFolderInfo.sortType = sortType; + destFolder.msgDatabase.dBFolderInfo.sortOrder = sortOrder; + // null out to avoid memory bloat + destFolder.msgDatabase = null; + } + } + } + customElements.define( + "thread-pane-treecolpicker", + MozThreadPaneTreeColpicker, + { extends: "treecolpicker" } + ); + + // The menulist CE is defined lazily. Create one now to get menulist defined, + // allowing us to inherit from it. + if (!customElements.get("menulist")) { + delete document.createXULElement("menulist"); + } + { + /** + * MozMenulistEditable is a menulist widget that can be made editable by setting editable="true". + * With an additional type="description" the list also contains an additional label that can hold + * for instance, a description of a menu item. + * It is typically used e.g. for the "Custom From Address..." feature to let the user chose and + * edit the address to send from. + * + * @augments {MozMenuList} + */ + class MozMenulistEditable extends customElements.get("menulist") { + static get markup() { + // Accessibility information of these nodes will be + // presented on XULComboboxAccessible generated from <menulist>; + // hide these nodes from the accessibility tree. + return ` + <html:link rel="stylesheet" href="chrome://global/skin/menulist.css"/> + <html:input part="text-input" type="text" allowevents="true"/> + <hbox id="label-box" part="label-box" flex="1" role="none"> + <label id="label" part="label" crop="end" flex="1" role="none"/> + <label id="highlightable-label" part="label" crop="end" flex="1" role="none"/> + </hbox> + <dropmarker part="dropmarker" exportparts="icon: dropmarker-icon" type="menu" role="none"/> + <html:slot/> + `; + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + + this.shadowRoot.appendChild(this.constructor.fragment); + this._inputField = this.shadowRoot.querySelector("input"); + this._labelBox = this.shadowRoot.getElementById("label-box"); + this._dropmarker = this.shadowRoot.querySelector("dropmarker"); + + if (this.getAttribute("type") == "description") { + this._description = document.createXULElement("label"); + this._description.id = this._description.part = "description"; + this._description.setAttribute("crop", "end"); + this._description.setAttribute("role", "none"); + this.shadowRoot.getElementById("label").after(this._description); + } + + this.initializeAttributeInheritance(); + + this.mSelectedInternal = null; + this.setInitialSelection(); + + this._handleMutation = mutations => { + this.editable = this.getAttribute("editable") == "true"; + }; + this.mAttributeObserver = new MutationObserver(this._handleMutation); + this.mAttributeObserver.observe(this, { + attributes: true, + attributeFilter: ["editable"], + }); + + this._keypress = event => { + if (event.key == "ArrowDown") { + this.open = true; + } + }; + this._inputField.addEventListener("keypress", this._keypress); + this._change = event => { + event.stopPropagation(); + this.selectedItem = null; + this.setAttribute("value", this._inputField.value); + // Start the event again, but this time with the menulist as target. + this.dispatchEvent(new CustomEvent("change", { bubbles: true })); + }; + this._inputField.addEventListener("change", this._change); + + this._popupHiding = event => { + // layerX is 0 if the user clicked outside the popup. + if (this.editable && event.layerX > 0) { + this._inputField.select(); + } + }; + if (!this.menupopup) { + this.appendChild(MozXULElement.parseXULToFragment(`<menupopup />`)); + } + this.menupopup.addEventListener("popuphiding", this._popupHiding); + } + + disconnectedCallback() { + super.disconnectedCallback(); + + this.mAttributeObserver.disconnect(); + this._inputField.removeEventListener("keypress", this._keypress); + this._inputField.removeEventListener("change", this._change); + this.menupopup.removeEventListener("popuphiding", this._popupHiding); + + for (let prop of [ + "_inputField", + "_labelBox", + "_dropmarker", + "_description", + ]) { + if (this[prop]) { + this[prop].remove(); + this[prop] = null; + } + } + } + + static get inheritedAttributes() { + let attrs = super.inheritedAttributes; + attrs.input = "value,disabled"; + attrs["#description"] = "value=description"; + return attrs; + } + + set editable(val) { + if (val == this.editable) { + return; + } + + if (!val) { + // If we were focused and transition from editable to not editable, + // focus the parent menulist so that the focus does not get stuck. + if (this._inputField == document.activeElement) { + window.setTimeout(() => this.focus(), 0); + } + } + + this.setAttribute("editable", val); + } + + get editable() { + return this.getAttribute("editable") == "true"; + } + + set value(val) { + this._inputField.value = val; + this.setAttribute("value", val); + this.setAttribute("label", val); + } + + get value() { + if (this.editable) { + return this._inputField.value; + } + return super.value; + } + + get label() { + if (this.editable) { + return this._inputField.value; + } + return super.label; + } + + set placeholder(val) { + this._inputField.placeholder = val; + } + + get placeholder() { + return this._inputField.placeholder; + } + + set selectedItem(val) { + if (val) { + this._inputField.value = val.getAttribute("value"); + } + super.selectedItem = val; + } + + get selectedItem() { + return super.selectedItem; + } + + focus() { + if (this.editable) { + this._inputField.focus(); + } else { + super.focus(); + } + } + + select() { + if (this.editable) { + this._inputField.select(); + } + } + } + + const MenuBaseControl = MozElements.BaseControlMixin( + MozElements.MozElementMixin(XULMenuElement) + ); + MenuBaseControl.implementCustomInterface(MozMenulistEditable, [ + Ci.nsIDOMXULMenuListElement, + Ci.nsIDOMXULSelectControlElement, + ]); + + customElements.define("menulist-editable", MozMenulistEditable, { + extends: "menulist", + }); + } + + /** + * The MozAttachmentlist widget lists attachments for a mail. This is typically used to show + * attachments while writing a new mail as well as when reading mails. + * + * @augments {MozElements.RichListBox} + */ + class MozAttachmentlist extends MozElements.RichListBox { + constructor() { + super(); + + this.messenger = Cc["@mozilla.org/messenger;1"].createInstance( + Ci.nsIMessenger + ); + + this.addEventListener("keypress", event => { + switch (event.key) { + case " ": + // Allow plain spacebar to select the focused item. + if (!event.shiftKey && !event.ctrlKey) { + this.addItemToSelection(this.currentItem); + } + // Prevent inbuilt scrolling. + event.preventDefault(); + break; + + case "Enter": + if (this.currentItem && !event.ctrlKey && !event.shiftKey) { + this.addItemToSelection(this.currentItem); + let evt = document.createEvent("XULCommandEvent"); + evt.initCommandEvent( + "command", + true, + true, + window, + 0, + event.ctrlKey, + event.altKey, + event.shiftKey, + event.metaKey, + null + ); + this.currentItem.dispatchEvent(evt); + } + break; + } + }); + + // Make sure we keep the focus. + this.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + + if (document.commandDispatcher.focusedElement != this) { + this.focus(); + } + }); + } + + connectedCallback() { + super.connectedCallback(); + if (this.delayConnectedCallback()) { + return; + } + + let children = Array.from(this._childNodes); + + children + .filter(child => child.getAttribute("selected") == "true") + .forEach(this.selectedItems.append, this.selectedItems); + + children + .filter(child => !child.hasAttribute("context")) + .forEach(child => + child.setAttribute("context", this.getAttribute("itemcontext")) + ); + } + + get itemCount() { + return this._childNodes.length; + } + + /** + * Get the preferred height (the height that would allow us to fit + * everything without scrollbars) of the attachmentlist's bounding + * rectangle. Add 3px to account for item's margin. + */ + get preferredHeight() { + return this.scrollHeight + this.getBoundingClientRect().height + 3; + } + + get _childNodes() { + return this.querySelectorAll("richlistitem.attachmentItem"); + } + + getIndexOfItem(item) { + for (let i = 0; i < this._childNodes.length; i++) { + if (this._childNodes[i] === item) { + return i; + } + } + return -1; + } + + getItemAtIndex(index) { + if (index >= 0 && index < this._childNodes.length) { + return this._childNodes[index]; + } + return null; + } + + getRowCount() { + return this._childNodes.length; + } + + getIndexOfFirstVisibleRow() { + if (this._childNodes.length == 0) { + return -1; + } + + // First try to estimate which row is visible, assuming they're all the same height. + let box = this; + let estimatedRow = Math.floor( + box.scrollTop / this._childNodes[0].getBoundingClientRect().height + ); + let estimatedIndex = estimatedRow * this._itemsPerRow(); + let offset = this._childNodes[estimatedIndex].screenY - box.screenY; + + if (offset > 0) { + // We went too far! Go back until we find an item totally off-screen, then return the one + // after that. + for (let i = estimatedIndex - 1; i >= 0; i--) { + let childBoxObj = this._childNodes[i].getBoundingClientRect(); + if (childBoxObj.screenY + childBoxObj.height <= box.screenY) { + return i + 1; + } + } + + // If we get here, we must have gone back to the beginning of the list, so just return 0. + return 0; + } + + // We didn't go far enough! Keep going until we find an item at least partially on-screen. + for (let i = estimatedIndex; i < this._childNodes.length; i++) { + let childBoxObj = this._childNodes[i].getBoundingClientRect(); + if (childBoxObj.screenY + childBoxObj.height > box.screenY > 0) { + return i; + } + } + + return null; + } + + ensureIndexIsVisible(index) { + this.ensureElementIsVisible(this.getItemAtIndex(index)); + } + + ensureElementIsVisible(item) { + let box = this; + + // Are we too far down? + if (item.screenY < box.screenY) { + box.scrollTop = + item.getBoundingClientRect().y - box.getBoundingClientRect().y; + } else if ( + item.screenY + item.getBoundingClientRect().height > + box.screenY + box.getBoundingClientRect().height + ) { + // ... or not far enough? + box.scrollTop = + item.getBoundingClientRect().y + + item.getBoundingClientRect().height - + box.getBoundingClientRect().y - + box.getBoundingClientRect().height; + } + } + + scrollToIndex(index) { + let box = this; + let item = this.getItemAtIndex(index); + if (!item) { + return; + } + box.scrollTop = + item.getBoundingClientRect().y - box.getBoundingClientRect().y; + } + + appendItem(attachment, name) { + // -1 appends due to the way getItemAtIndex is implemented. + return this.insertItemAt(-1, attachment, name); + } + + insertItemAt(index, attachment, name) { + let item = this.ownerDocument.createXULElement("richlistitem"); + item.classList.add("attachmentItem"); + item.setAttribute("role", "option"); + + item.addEventListener("dblclick", event => { + let evt = document.createEvent("XULCommandEvent"); + evt.initCommandEvent( + "command", + true, + true, + window, + 0, + event.ctrlKey, + event.altKey, + event.shiftKey, + event.metaKey, + null + ); + item.dispatchEvent(evt); + }); + + let makeDropIndicator = placementClass => { + let img = document.createElement("img"); + img.setAttribute( + "src", + "chrome://messenger/skin/icons/tab-drag-indicator.svg" + ); + img.setAttribute("alt", ""); + img.classList.add("attach-drop-indicator", placementClass); + return img; + }; + + item.appendChild(makeDropIndicator("before")); + + let icon = this.ownerDocument.createElement("img"); + icon.setAttribute("alt", ""); + icon.setAttribute("draggable", "false"); + // Allow the src to be invalid. + icon.classList.add("attachmentcell-icon", "invisible-on-broken"); + item.appendChild(icon); + + let textLabel = this.ownerDocument.createElement("span"); + textLabel.classList.add("attachmentcell-name"); + item.appendChild(textLabel); + + let extensionLabel = this.ownerDocument.createElement("span"); + extensionLabel.classList.add("attachmentcell-extension"); + item.appendChild(extensionLabel); + + let sizeLabel = this.ownerDocument.createElement("span"); + sizeLabel.setAttribute("role", "note"); + sizeLabel.classList.add("attachmentcell-size"); + item.appendChild(sizeLabel); + + item.appendChild(makeDropIndicator("after")); + + item.setAttribute("context", this.getAttribute("itemcontext")); + + item.attachment = attachment; + this.invalidateItem(item, name); + this.insertBefore(item, this.getItemAtIndex(index)); + return item; + } + + /** + * Set the attachment icon source. + * + * @param {MozRichlistitem} item - The attachment item to set the icon of. + * @param {string|null} src - The src to set. + */ + setAttachmentIconSrc(item, src) { + let icon = item.querySelector(".attachmentcell-icon"); + icon.setAttribute("src", src); + } + + /** + * Refresh the attachment icon using the attachment details. + * + * @param {MozRichlistitem} item - The attachment item to refresh the icon + * for. + */ + refreshAttachmentIcon(item) { + let src; + let attachment = item.attachment; + let type = attachment.contentType; + if (type == "text/x-moz-deleted") { + src = "chrome://messenger/skin/icons/attachment-deleted.svg"; + } else if (!item.loaded || item.uploading) { + src = "chrome://global/skin/icons/loading.png"; + } else if (item.cloudIcon) { + src = item.cloudIcon; + } else { + let iconName = attachment.name; + if (iconName.toLowerCase().endsWith(".eml")) { + // Discard file names derived from subject headers with special + // characters. + iconName = "message.eml"; + } else if (attachment.url) { + // For local file urls, we are better off using the full file url + // because moz-icon will actually resolve the file url and get the + // right icon from the file url. All other urls, we should try to + // extract the file name from them. This fixes issues where an icon + // wasn't showing up if you dragged a web url that had a query or + // reference string after the file name and for mailnews urls where + // the filename is hidden in the url as a &filename= part. + let url = Services.io.newURI(attachment.url); + if ( + url instanceof Ci.nsIURL && + url.fileName && + !url.schemeIs("file") + ) { + iconName = url.fileName; + } + } + src = `moz-icon://${iconName}?size=16&contentType=${type}`; + } + + this.setAttachmentIconSrc(item, src); + } + + /** + * Get whether the attachment list is fully loaded. + * + * @returns {boolean} - Whether all the attachments in the list are fully + * loaded. + */ + isLoaded() { + // Not loaded if at least one loading. + for (let item of this.querySelectorAll(".attachmentItem")) { + if (!item.loaded) { + return false; + } + } + return true; + } + + /** + * Set the attachment item's loaded state. + * + * @param {MozRichlistitem} item - The attachment item. + * @param {boolean} loaded - Whether the attachment is fully loaded. + */ + setAttachmentLoaded(item, loaded) { + item.loaded = loaded; + this.refreshAttachmentIcon(item); + } + + /** + * Set the attachment item's cloud icon, if any. + * + * @param {MozRichlistitem} item - The attachment item. + * @param {?string} cloudIcon - The icon of the cloud provider where the + * attachment was uploaded. Will be used as file type icon in the list of + * attachments, if specified. + */ + setCloudIcon(item, cloudIcon) { + item.cloudIcon = cloudIcon; + this.refreshAttachmentIcon(item); + } + + /** + * Set the attachment item's displayed name. + * + * @param {MozRichlistitem} item - The attachment item. + * @param {string} name - The name to display for the attachment. + */ + setAttachmentName(item, name) { + item.setAttribute("name", name); + // Extract what looks like the file extension so we can always show it, + // even if the full name would overflow. + // NOTE: This is a convenience feature rather than a security feature + // since the content type of an attachment need not match the extension. + let found = name.match(/^(.+)(\.[a-zA-Z0-9_#$!~+-]{1,16})$/); + item.querySelector(".attachmentcell-name").textContent = + found?.[1] || name; + item.querySelector(".attachmentcell-extension").textContent = + found?.[2] || ""; + } + + /** + * Set the attachment item's displayed size. + * + * @param {MozRichlistitem} item - The attachment item. + * @param {string} size - The size to display for the attachment. + */ + setAttachmentSize(item, size) { + item.setAttribute("size", size); + let sizeEl = item.querySelector(".attachmentcell-size"); + sizeEl.textContent = size; + sizeEl.hidden = !size; + } + + invalidateItem(item, name) { + let attachment = item.attachment; + + this.setAttachmentName(item, name || attachment.name); + let size = + attachment.size == null || attachment.size == -1 + ? "" + : this.messenger.formatFileSize(attachment.size); + if (size && item.cloudHtmlFileSize > 0) { + size = `${this.messenger.formatFileSize( + item.cloudHtmlFileSize + )} (${size})`; + } + this.setAttachmentSize(item, size); + + // By default, items are considered loaded. + item.loaded = true; + this.refreshAttachmentIcon(item); + return item; + } + + /** + * Find the attachmentitem node for the specified nsIMsgAttachment. + */ + findItemForAttachment(aAttachment) { + for (let i = 0; i < this.itemCount; i++) { + let item = this.getItemAtIndex(i); + if (item.attachment == aAttachment) { + return item; + } + } + return null; + } + + _fireOnSelect() { + if (!this._suppressOnSelect && !this.suppressOnSelect) { + this.dispatchEvent( + new Event("select", { bubbles: false, cancelable: true }) + ); + } + } + + _itemsPerRow() { + // For 0 or 1 children, we can assume that they all fit in one row. + if (this._childNodes.length < 2) { + return this._childNodes.length; + } + + let itemWidth = + this._childNodes[1].getBoundingClientRect().x - + this._childNodes[0].getBoundingClientRect().x; + + // Each item takes up a full row + if (itemWidth == 0) { + return 1; + } + return Math.floor(this.clientWidth / itemWidth); + } + + _itemsPerCol(aItemsPerRow) { + let itemsPerRow = aItemsPerRow || this._itemsPerRow(); + + if (this._childNodes.length == 0) { + return 0; + } + + if (this._childNodes.length <= itemsPerRow) { + return 1; + } + + let itemHeight = + this._childNodes[itemsPerRow].getBoundingClientRect().y - + this._childNodes[0].getBoundingClientRect().y; + + return Math.floor(this.clientHeight / itemHeight); + } + + /** + * Set the width of each child to the largest width child to create a + * grid-like effect for the flex-wrapped attachment list. + */ + setOptimumWidth() { + if (this._childNodes.length == 0) { + return; + } + + let width = 0; + for (let child of this._childNodes) { + // Unset the width, then the child will expand or shrink to its + // "natural" size in the flex-wrapped container. I.e. its preferred + // width bounded by the width of the container's content space. + child.style.width = null; + width = Math.max(width, child.getBoundingClientRect().width); + } + for (let child of this._childNodes) { + child.style.width = `${width}px`; + } + } + } + + customElements.define("attachment-list", MozAttachmentlist, { + extends: "richlistbox", + }); + + /** + * The MailAddressPill widget is used to display the email addresses in the + * messengercompose.xhtml window. + * + * @augments {MozXULElement} + */ + class MailAddressPill extends MozXULElement { + static get inheritedAttributes() { + return { + ".pill-label": "crop,value=label", + }; + } + + /** + * Indicates whether the address of this pill is for a mail list. + * + * @type {boolean} + */ + isMailList = false; + + /** + * If this pill is for a mail list, this provides the URI. + * + * @type {?string} + */ + listURI = null; + + /** + * If this pill is for a mail list, this provides the total count of + * its addresses. + * + * @type {number} + */ + listAddressCount = 0; + + connectedCallback() { + if (this.hasChildNodes() || this.delayConnectedCallback()) { + return; + } + + this.classList.add("address-pill"); + this.setAttribute("context", "emailAddressPillPopup"); + this.setAttribute("allowevents", "true"); + + this.labelView = document.createXULElement("hbox"); + this.labelView.setAttribute("flex", "1"); + + this.pillLabel = document.createXULElement("label"); + this.pillLabel.classList.add("pill-label"); + this.pillLabel.setAttribute("crop", "center"); + + this.pillIndicator = document.createElement("img"); + this.pillIndicator.setAttribute( + "src", + "chrome://messenger/skin/icons/pill-indicator.svg" + ); + this.pillIndicator.setAttribute("alt", ""); + this.pillIndicator.classList.add("pill-indicator"); + this.pillIndicator.hidden = true; + + this.labelView.appendChild(this.pillLabel); + this.labelView.appendChild(this.pillIndicator); + + this.appendChild(this.labelView); + this._setupEmailInput(); + + this._setupEventListeners(); + this.initializeAttributeInheritance(); + + // @implements {nsIObserver} + this.inputObserver = { + observe: (subject, topic, data) => { + if (topic == "autocomplete-did-enter-text" && this.isEditing) { + this.updatePill(); + } + }, + }; + + Services.obs.addObserver( + this.inputObserver, + "autocomplete-did-enter-text" + ); + + // Remove the observer on window unload as the disconnectedCallback() + // will never be called when closing a window, so we might therefore + // leak if XPCOM isn't smart enough. + window.addEventListener( + "unload", + () => { + this.removeObserver(); + }, + { once: true } + ); + } + + get emailAddress() { + return this.getAttribute("emailAddress"); + } + + set emailAddress(val) { + this.setAttribute("emailAddress", val); + } + + get label() { + return this.getAttribute("label"); + } + + set label(val) { + this.setAttribute("label", val); + } + + get fullAddress() { + return this.getAttribute("fullAddress"); + } + + set fullAddress(val) { + this.setAttribute("fullAddress", val); + } + + get displayName() { + return this.getAttribute("displayName"); + } + + set displayName(val) { + this.setAttribute("displayName", val); + } + + get emailInput() { + return this.querySelector(`input[is="autocomplete-input"]`); + } + + /** + * Get the main addressing input field the pill belongs to. + */ + get rowInput() { + return this.closest(".address-container").querySelector( + ".address-row-input" + ); + } + + /** + * Check if the pill is currently in "Edit Mode", meaning the label is + * hidden and the html:input field is visible. + * + * @returns {boolean} true if the pill is currently being edited. + */ + get isEditing() { + return !this.emailInput.hasAttribute("hidden"); + } + + get fragment() { + if (!this.constructor.hasOwnProperty("_fragment")) { + this.constructor._fragment = MozXULElement.parseXULToFragment(` + <html:input is="autocomplete-input" + type="text" + class="input-pill" + disableonsend="true" + autocompletesearch="mydomain addrbook ldap news" + autocompletesearchparam="{}" + timeout="200" + maxrows="6" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="2" + ignoreblurwhilesearching="true" + hidden="hidden"/> + `); + } + return document.importNode(this.constructor._fragment, true); + } + + _setupEmailInput() { + this.appendChild(this.fragment); + this.emailInput.value = this.fullAddress; + } + + _setupEventListeners() { + this.addEventListener("blur", event => { + // Prevent deselecting a pill on blur if: + // - The related target is null (context menu was opened, bug 1729741). + // - The related target is another pill (multi selection and deslection + // are handled by the click event listener added on pill creation). + if ( + !event.relatedTarget || + event.relatedTarget.tagName == "mail-address-pill" + ) { + return; + } + + this.closest("mail-recipients-area").deselectAllPills(); + }); + + this.emailInput.addEventListener("keypress", event => { + if (this.hasAttribute("disabled")) { + return; + } + this.onEmailInputKeyPress(event); + }); + + // Disable the inbuilt autocomplete on blur as we handle it here. + this.emailInput._dontBlur = true; + + this.emailInput.addEventListener("blur", () => { + // If the input is still the active element after blur (when switching + // to another window), return to prevent autocompletion and + // pillification and let the user continue editing the address later. + if (document.activeElement == this.emailInput) { + return; + } + + if ( + this.emailInput.forceComplete && + this.emailInput.mController.matchCount >= 1 + ) { + // If input.forceComplete is true and there are autocomplete matches, + // we need to call the inbuilt Enter handler to force the input text + // to the best autocomplete match because we've set input._dontBlur. + this.emailInput.mController.handleEnter(true); + return; + } + + this.updatePill(); + }); + } + + /** + * Simple email address validation. + * + * @param {string} address - An email address. + */ + isValidAddress(address) { + return /^[^\s@]+@[^\s@]+$/.test(address); + } + + /** + * Convert the pill into "Edit Mode" by hiding the label and showing the + * html:input element. + */ + startEditing() { + // Record the intention of editing a pill as a change in the recipient + // even if the text is not actually changed in order to prevent accidental + // data loss. + onRecipientsChanged(); + + // We need to set the min and max width before hiding and showing the + // child nodes in order to prevent unwanted jumps in the resizing of the + // edited pill. Both properties are necessary to handle flexbox. + this.style.setProperty("max-width", `${this.clientWidth}px`); + this.style.setProperty("min-width", `${this.clientWidth}px`); + + this.classList.add("editing"); + this.labelView.setAttribute("hidden", "true"); + this.emailInput.removeAttribute("hidden"); + this.emailInput.focus(); + + // Account for pill padding. + let inputWidth = this.emailInput.clientWidth + 15; + + // In case the original address is shorter than the input field child node + // force resize the pill container to prevent overflows. + if (inputWidth > this.clientWidth) { + this.style.setProperty("max-width", `${inputWidth}px`); + this.style.setProperty("min-width", `${inputWidth}px`); + } + } + + /** + * Revert the pill UI to a regular selectable element, meaning the label is + * visible and the html:input field is hidden. + * + * @param {Event} event - The DOM Event. + */ + onEmailInputKeyPress(event) { + switch (event.key) { + case "Escape": + this.emailInput.value = this.fullAddress; + this.resetPill(); + break; + case "Delete": + case "Backspace": + if (!this.emailInput.value.trim() && !event.repeat) { + this.rowInput.focus(); + this.remove(); + } + break; + } + } + + async updatePill() { + let addresses = MailServices.headerParser.makeFromDisplayAddress( + this.emailInput.value + ); + let row = this.closest(".address-row"); + + if (!addresses[0]) { + this.rowInput.focus(); + this.remove(); + // Update aria labels of all pills in the row, as pill count changed. + updateAriaLabelsOfAddressRow(row); + onRecipientsChanged(); + return; + } + + this.label = addresses[0].toString(); + this.emailAddress = addresses[0].email || ""; + this.fullAddress = addresses[0].toString(); + this.displayName = addresses[0].name || ""; + // We need to detach the autocomplete Controller to prevent the input + // to be filled with the previously selected address when the "blur" + // event gets triggered. + this.emailInput.detachController(); + // Attach it again to enable autocomplete. + this.emailInput.attachController(); + + this.resetPill(); + + // Update the aria label of edited pill only, as pill count didn't change. + // Unfortunately, we still need to get the row's pills for counting once. + let pills = row.querySelectorAll("mail-address-pill"); + this.setAttribute( + "aria-label", + await document.l10n.formatValue("pill-aria-label", { + email: this.fullAddress, + count: pills.length, + }) + ); + + onRecipientsChanged(); + } + + resetPill() { + this.updatePillStatus(); + this.style.removeProperty("max-width"); + this.style.removeProperty("min-width"); + this.classList.remove("editing"); + this.labelView.removeAttribute("hidden"); + this.emailInput.setAttribute("hidden", "hidden"); + let textLength = this.emailInput.value.length; + this.emailInput.setSelectionRange(textLength, textLength); + this.rowInput.focus(); + } + + /** + * Check if an address is valid or it exists in the address book and update + * the helper icons accordingly. + */ + async updatePillStatus() { + let isValid = this.isValidAddress(this.emailAddress); + let listNames = LazyModules.MimeParser.parseHeaderField( + this.fullAddress, + LazyModules.MimeParser.HEADER_ADDRESS + ); + + if (listNames.length > 0) { + let mailList = MailServices.ab.getMailListFromName(listNames[0].name); + this.isMailList = !!mailList; + if (this.isMailList) { + this.listURI = mailList.URI; + this.listAddressCount = mailList.childCards.length; + } else { + this.listURI = ""; + this.listAddressCount = 0; + } + } + + let isNewsgroup = this.emailInput.classList.contains("news-input"); + + if (!isValid && !this.isMailList && !isNewsgroup) { + this.classList.add("invalid-address"); + this.setAttribute( + "tooltiptext", + await document.l10n.formatValue("pill-tooltip-invalid-address", { + email: this.fullAddress, + }) + ); + this.pillIndicator.hidden = true; + + // Interrupt if the address is not valid as we don't need to check for + // other conditions. + return; + } + + this.classList.remove("invalid-address"); + this.removeAttribute("tooltiptext"); + this.pillIndicator.hidden = true; + + // Check if the address is not in the Address Book only if it's not a + // mail list or a newsgroup. + if ( + !isNewsgroup && + !this.isMailList && + !MailServices.ab.cardForEmailAddress(this.emailAddress) + ) { + this.setAttribute( + "tooltiptext", + await document.l10n.formatValue("pill-tooltip-not-in-address-book", { + email: this.fullAddress, + }) + ); + this.pillIndicator.hidden = false; + } + } + + /** + * Get the nearest sibling pill which is not selected. + * + * @param {("next"|"previous")} [siblingsType="next"] - Iterate next or + * previous siblings. + * @returns {HTMLElement} - The nearest unselected sibling element, or null. + */ + getUnselectedSiblingPill(siblingsType = "next") { + if (siblingsType == "next") { + // Check for next siblings. + let element = this.nextElementSibling; + while (element) { + if (!element.hasAttribute("selected")) { + return element; + } + element = element.nextElementSibling; + } + + return null; + } + + // Check for previous siblings. + let element = this.previousElementSibling; + while (element) { + if (!element.hasAttribute("selected")) { + return element; + } + element = element.previousElementSibling; + } + + return null; + } + + removeObserver() { + Services.obs.removeObserver( + this.inputObserver, + "autocomplete-did-enter-text" + ); + } + } + + customElements.define("mail-address-pill", MailAddressPill); + + /** + * The MailRecipientsArea widget is used to display the recipient rows in the + * header area of the messengercompose.xul window. + * + * @augments {MozXULElement} + */ + class MailRecipientsArea extends MozXULElement { + connectedCallback() { + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + + for (let input of this.querySelectorAll(".mail-input,.news-input")) { + // Disable inbuilt autocomplete on blur to handle it with our handlers. + input._dontBlur = true; + + setupAutocompleteInput(input); + + input.addEventListener("keypress", event => { + // Ctrl+Shift+Tab is handled by moveFocusToNeighbouringArea. + if (event.key != "Tab" || !event.shiftKey || event.ctrlKey) { + return; + } + event.preventDefault(); + this.moveFocusToPreviousElement(input); + }); + + input.addEventListener("input", event => { + addressInputOnInput(event, false); + }); + } + + // Force the focus on the first available input field if Tab is + // pressed on the extraAddressRowsMenuButton label. + document + .getElementById("extraAddressRowsMenuButton") + .addEventListener("keypress", event => { + if (event.key == "Tab" && !event.shiftKey) { + event.preventDefault(); + let row = this.querySelector(".address-row:not(.hidden)"); + let removeFieldButton = row.querySelector(".remove-field-button"); + // If the close button is hidden, focus on the input field. + if (removeFieldButton.hidden) { + row.querySelector(".address-row-input").focus(); + return; + } + // Focus on the close button. + removeFieldButton.focus(); + } + }); + + this.addEventListener("dragstart", event => { + // Check if we're dragging a pill, as the drag target might be another + // element like row or pill <input> when dragging selected plain text. + let targetPill = event.target.closest( + "mail-address-pill:not(.editing)" + ); + if (!targetPill) { + return; + } + if (!targetPill.hasAttribute("selected")) { + // If the drag action starts from a non-selected pill, + // deselect all selected pills and select only the target pill. + for (let pill of this.getAllSelectedPills()) { + pill.removeAttribute("selected"); + } + targetPill.toggleAttribute("selected"); + } + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.dropEffect = "move"; + event.dataTransfer.setData("text/pills", "pills"); + event.dataTransfer.setDragImage(targetPill, 50, 12); + }); + + this.addEventListener("dragover", event => { + event.preventDefault(); + }); + + this.addEventListener("dragenter", event => { + if (!event.dataTransfer.getData("text/pills")) { + return; + } + + // If the current drop target is a pill, add drop indicator style to it. + event.target + .closest("mail-address-pill") + ?.classList.add("drop-indicator"); + + // If the current drop target is inside an address row, add the + // indicator style for the row's address container. + event.target + .closest(".address-row") + ?.querySelector(".address-container") + .classList.add("drag-address-container"); + }); + + this.addEventListener("dragleave", event => { + if (!event.dataTransfer.getData("text/pills")) { + return; + } + // If dragleave from pill, remove its drop indicator style. + event.target + .closest("mail-address-pill") + ?.classList.remove("drop-indicator"); + + // If dragleave from address row, remove the indicator style of its + // address container. + event.target + .closest(".address-row") + ?.querySelector(".address-container") + .classList.remove("drag-address-container"); + }); + + this.addEventListener("drop", event => { + // First handle cases where the dropped data is not pills. + if (!event.dataTransfer.getData("text/pills")) { + // Bail out if the dropped data comes from the contacts sidebar. + // Those addresses will be added immediately as pills without going + // through the input field as plain text. + if (event.dataTransfer.types.includes("moz/abcard")) { + return; + } + + // Dropped data should be plain text (images are handled elsewhere). + // We currently only support dropping text directly into the row input + // (Bug 1706187), which is inbuilt: no further handling required here. + // Input element resizing is automatically handled by its input event. + return; + } + + // Pills have been dropped ("text/pills"). + let targetAddressRow = event.target.closest(".address-row"); + // Return if pills have been dropped outside an address row. + if ( + !targetAddressRow || + targetAddressRow.classList.contains("address-row-raw") + ) { + return; + } + + // Pills have been dropped somewhere inside an address row. + // If they have been dropped directly on an address container, use that. + // Otherwise ensure having an addressContainer for drop targets inside + // the row, but outside the address container (e.g. the row label). + let targetAddressContainer = event.target.closest(".address-container"); + let addressContainer = + targetAddressContainer || + targetAddressRow.querySelector(".address-container"); + + // Recreate pills in the target address container. + // If dropped on a pill, append pills before that pill. Otherwise if + // dropped into an address container, append pills after existing pills. + // Otherwise if dropped elsewhere on the row (e.g. on the row label), + // append pills before existing pills. + let targetPill = event.target.closest("mail-address-pill"); + this.createDNDPills( + addressContainer, + targetPill || !targetAddressContainer, + targetPill ? targetPill.fullAddress : null + ); + addressContainer.classList.remove("drag-address-container"); + }); + } + + /** + * Check if the current size of the recipient input field doesn't exceed its + * container width. This might happen if the user pastes a very long string + * with multiple addresses when pills are already present. + * + * @param {Element} input - The HTML input field. + * @param {integer} length - The amount of characters in the input field. + */ + resizeInputField(input, length) { + // Set a minimum size of 1 in case no characters were written in the field + // in order to force the smallest size possible and avoid blank rows when + // multiple pills fill the entire recipient row. + input.setAttribute("size", length || 1); + + // If the previously set size causes the input field to grow beyond 80% of + // its parent container, we remove the size attribute to let the CSS flex + // attribute let it grow naturally to fill the available space. + if ( + input.clientWidth > + input.closest(".address-container").clientWidth * 0.8 + ) { + input.removeAttribute("size"); + } + } + + /** + * Move the dragged pills to another address row. + * + * @param {string} addressContainer - The address container on which pills + * have been dropped. + * @param {boolean} [appendStart] - If the selected addresses should be + * appended at the start or at the end of existing addresses. + * Specifying targetAddress will override this. + * @param {string} [targetAddress] - The existing address before which all + * selected addresses should be appended. + */ + createDNDPills(addressContainer, appendStart, targetAddress) { + let existingPills = + addressContainer.querySelectorAll("mail-address-pill"); + let existingAddresses = [...existingPills].map(pill => pill.fullAddress); + let selectedAddresses = [...this.getAllSelectedPills()].map( + pill => pill.fullAddress + ); + let originalTargetIndex = existingAddresses.indexOf(targetAddress); + + // Remove all the duplicate existing addresses. + for (let address of selectedAddresses) { + let index = existingAddresses.indexOf(address); + if (index > -1) { + existingAddresses.splice(index, 1); + } + } + + let combinedAddresses; + // If selected pills have been dropped on another pill, they should be + // inserted before that pill, otherwise use appendStart. + if (targetAddress) { + // Merge the two arrays in the right order. If the target address has + // been removed by deduplication above, use its original index. + existingAddresses.splice( + existingAddresses.includes(targetAddress) + ? existingAddresses.indexOf(targetAddress) + : originalTargetIndex, + 0, + ...selectedAddresses + ); + combinedAddresses = existingAddresses; + } else { + combinedAddresses = appendStart + ? selectedAddresses.concat(existingAddresses) + : existingAddresses.concat(selectedAddresses); + } + + // Remove all selected pills. + for (let pill of this.getAllSelectedPills()) { + pill.remove(); + } + + // Existing pills are removed before creating new ones in the right order. + for (let pill of existingPills) { + pill.remove(); + } + + // Create pills for all the combined addresses. + let row = addressContainer.closest(".address-row"); + for (let address of combinedAddresses) { + addressRowAddRecipientsArray( + row, + [address], + selectedAddresses.includes(address) + ); + } + + // Move the focus to the first selected pill. + this.getAllSelectedPills()[0].focus(); + } + + /** + * Create a new address row and a menuitem for revealing it. + * + * @param {object} recipient - An object for various element attributes. + * @param {boolean} rawInput - A flag to disable pills and autocompletion. + * @returns {object} - The newly created elements. + * @property {Element} row - The address row. + * @property {Element} showRowMenuItem - The menu item that shows the row. + */ + // NOTE: This is currently never called with rawInput = false, so it may be + // out of date if used. + buildRecipientRow(recipient, rawInput = false) { + let row = document.createXULElement("hbox"); + row.setAttribute("id", recipient.rowId); + row.classList.add("address-row"); + row.dataset.recipienttype = recipient.type; + + let firstCol = document.createXULElement("hbox"); + firstCol.classList.add("aw-firstColBox"); + + row.classList.add("hidden"); + + let closeButton = document.createElement("button"); + closeButton.classList.add("remove-field-button", "plain-button"); + document.l10n.setAttributes(closeButton, "remove-address-row-button", { + type: recipient.type, + }); + let closeIcon = document.createElement("img"); + closeIcon.setAttribute("src", "chrome://global/skin/icons/close.svg"); + // Button's title is the accessible name. + closeIcon.setAttribute("alt", ""); + closeButton.appendChild(closeIcon); + + closeButton.addEventListener("click", event => { + closeLabelOnClick(event); + }); + firstCol.appendChild(closeButton); + row.appendChild(firstCol); + + let labelContainer = document.createXULElement("hbox"); + labelContainer.setAttribute("align", "top"); + labelContainer.setAttribute("pack", "end"); + labelContainer.setAttribute("flex", 1); + labelContainer.classList.add("address-label-container"); + labelContainer.setAttribute( + "style", + getComposeBundle().getString("headersSpaceStyle") + ); + + let label = document.createXULElement("label"); + label.setAttribute("id", recipient.labelId); + label.setAttribute("value", recipient.type); + label.setAttribute("control", recipient.inputId); + label.setAttribute("flex", 1); + label.setAttribute("crop", "end"); + labelContainer.appendChild(label); + row.appendChild(labelContainer); + + let inputContainer = document.createXULElement("hbox"); + inputContainer.setAttribute("id", recipient.containerId); + inputContainer.setAttribute("flex", 1); + inputContainer.setAttribute("align", "center"); + inputContainer.classList.add( + "input-container", + "wrap-container", + "address-container" + ); + inputContainer.addEventListener("click", focusAddressInputOnClick); + + // Set up the row input for the row. + let input = document.createElement( + "input", + rawInput + ? undefined + : { + is: "autocomplete-input", + } + ); + input.setAttribute("id", recipient.inputId); + input.setAttribute("size", 1); + input.setAttribute("type", "text"); + input.setAttribute("disableonsend", true); + input.classList.add("plain", "address-input", "address-row-input"); + + if (!rawInput) { + // Regular autocomplete address input, not other header with raw input. + // Set various attributes for autocomplete. + input.setAttribute("autocompletesearch", "mydomain addrbook ldap news"); + input.setAttribute("autocompletesearchparam", "{}"); + input.setAttribute("timeout", 200); + input.setAttribute("maxrows", 6); + input.setAttribute("completedefaultindex", true); + input.setAttribute("forcecomplete", true); + input.setAttribute("completeselectedindex", true); + input.setAttribute("minresultsforpopup", 2); + input.setAttribute("ignoreblurwhilesearching", true); + // Disable the inbuilt autocomplete on blur as we handle it below. + input._dontBlur = true; + + setupAutocompleteInput(input); + + // Handle keydown event in autocomplete address input of row with pills. + // input.onBeforeHandleKeyDown() gets called by the toolkit autocomplete + // before going into autocompletion. + input.onBeforeHandleKeyDown = event => { + addressInputOnBeforeHandleKeyDown(event); + }; + } else { + // Handle keydown event in other header input (rawInput), which does not + // have autocomplete and its associated keydown handling. + row.classList.add("address-row-raw"); + input.addEventListener("keydown", otherHeaderInputOnKeyDown); + input.addEventListener("input", event => { + addressInputOnInput(event, true); + }); + } + + input.addEventListener("blur", () => { + addressInputOnBlur(input); + }); + input.addEventListener("focus", () => { + addressInputOnFocus(input); + }); + + inputContainer.appendChild(input); + row.appendChild(inputContainer); + + // Create the menuitem that shows the row on selection. + let showRowMenuItem = document.createXULElement("menuitem"); + showRowMenuItem.classList.add("subviewbutton", "menuitem-iconic"); + showRowMenuItem.setAttribute("id", recipient.showRowMenuItemId); + showRowMenuItem.setAttribute("disableonsend", true); + showRowMenuItem.setAttribute("label", recipient.type); + + showRowMenuItem.addEventListener("command", () => + showAndFocusAddressRow(row.id) + ); + + row.dataset.showSelfMenuitem = showRowMenuItem.id; + + return { row, showRowMenuItem }; + } + + /** + * Create a new recipient pill. + * + * @param {HTMLElement} element - The original autocomplete input that + * generated the pill. + * @param {Array} address - The array containing the recipient's info. + * @returns {Element} The newly created pill. + */ + createRecipientPill(element, address) { + let pill = document.createXULElement("mail-address-pill"); + + pill.label = address.toString(); + pill.emailAddress = address.email || ""; + pill.fullAddress = address.toString(); + pill.displayName = address.name || ""; + + pill.addEventListener("click", event => { + if (pill.hasAttribute("disabled")) { + return; + } + // Remove pills on middle mouse button click, but not with selection + // modifier keys. + if ( + event.button == 1 && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + ) { + if (!pill.hasAttribute("selected")) { + this.deselectAllPills(); + pill.setAttribute("selected", "selected"); + } + this.removeSelectedPills(); + return; + } + + // Edit pill on unmodified single left-click on single selected pill, + // which also fires for unmodified double-click ("dblclick") on a pill. + if ( + event.button == 0 && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !pill.isEditing && + pill.hasAttribute("selected") && + this.getAllSelectedPills().length == 1 + ) { + this.startEditing(pill, event); + return; + } + + // Handle selection, especially with Ctrl/Cmd and/or Shift modifiers. + this.checkSelected(pill, event); + }); + + pill.addEventListener("keydown", event => { + if (!pill.isEditing || pill.hasAttribute("disabled")) { + return; + } + this.handleKeyDown(pill, event); + }); + + pill.addEventListener("keypress", event => { + if (pill.hasAttribute("disabled")) { + return; + } + this.handleKeyPress(pill, event); + }); + + element.closest(".address-container").insertBefore(pill, element); + + // The emailInput attribute is accessible only after the pill has been + // appended to the DOM. + let excludedClasses = [ + "mail-primary-input", + "news-primary-input", + "address-row-input", + ]; + for (let cssClass of element.classList) { + if (excludedClasses.includes(cssClass)) { + continue; + } + pill.emailInput.classList.add(cssClass); + } + pill.emailInput.setAttribute( + "aria-labelledby", + element.getAttribute("aria-labelledby") + ); + element.removeAttribute("aria-labelledby"); + + let params = JSON.parse( + pill.emailInput.getAttribute("autocompletesearchparam") + ); + params.type = element.closest(".address-row").dataset.recipienttype; + pill.emailInput.setAttribute( + "autocompletesearchparam", + JSON.stringify(params) + ); + + pill.updatePillStatus(); + + return pill; + } + + /** + * Handle keydown event on a pill in the mail-recipients-area. + * + * @param {Element} pill - The mail-address-pill element where Event fired. + * @param {Event} event - The DOM Event. + */ + handleKeyDown(pill, event) { + switch (event.key) { + case " ": + case ",": + // Behaviour consistent with row input: + // If keydown would normally replace all of the current trimmed input, + // including if the current input is empty, then suppress the key and + // clear the input instead. + let input = pill.emailInput; + let selection = input.value.substring( + input.selectionStart, + input.selectionEnd + ); + if (selection.includes(input.value.trim())) { + event.preventDefault(); + input.value = ""; + } + break; + } + } + + /** + * Handle keypress event on a pill in the mail-recipients-area. + * + * @param {Element} pill - The mail-address-pill element where Event fired. + * @param {Event} event - The DOM Event. + */ + handleKeyPress(pill, event) { + if (pill.isEditing) { + return; + } + + switch (event.key) { + case "Enter": + case "F2": // For Windows users + this.startEditing(pill, event); + break; + + case "Delete": + case "Backspace": + // We must never delete a focused pill which is not selected. + // If no pills selected, just select the focused pill. + // For rapid repeated deletions (esp. from holding BACKSPACE), + // stop before selecting another focused pill for deletion. + if (!this.hasSelectedPills() && !event.repeat) { + pill.setAttribute("selected", "selected"); + break; + } + // Delete selected pills, handle focus and select another pill + // where applicable. + let focusType = event.key == "Delete" ? "next" : "previous"; + this.removeSelectedPills(focusType, true); + break; + + case "ArrowLeft": + if (pill.previousElementSibling) { + this.checkKeyboardSelected(event, pill.previousElementSibling); + } + break; + + case "ArrowRight": + this.checkKeyboardSelected(event, pill.nextElementSibling); + break; + + case " ": + this.checkSelected(pill, event); + break; + + case "Home": + let firstPill = pill + .closest(".address-container") + .querySelector("mail-address-pill"); + if (!event.ctrlKey) { + // Unmodified navigation: select only first pill and focus it below. + // ### Todo: We can't handle Shift+Home yet, so it ends up here. + this.deselectAllPills(); + firstPill.setAttribute("selected", "selected"); + } + firstPill.focus(); + break; + + case "End": + if (!event.ctrlKey) { + // Unmodified navigation: focus row input. + // ### Todo: We can't handle Shift+End yet, so it ends up here. + pill.rowInput.focus(); + break; + } + // Navigation with Ctrl modifier key: focus last pill. + pill + .closest(".address-container") + .querySelector("mail-address-pill:last-of-type") + .focus(); + break; + + case "Tab": + for (let item of this.getSiblingPills(pill)) { + item.removeAttribute("selected"); + } + // Ctrl+Tab is handled by moveFocusToNeighbouringArea. + if (event.ctrlKey) { + return; + } + event.preventDefault(); + if (event.shiftKey) { + this.moveFocusToPreviousElement(pill); + return; + } + pill.rowInput.focus(); + break; + + case "a": + if ( + !(event.ctrlKey || event.metaKey) || + event.repeat || + event.shiftKey + ) { + // Bail out if it's not Ctrl+A or Cmd+A, if the Shift key is + // pressed, or if repeated keypress. + break; + } + if ( + pill + .closest(".address-container") + .querySelector("mail-address-pill:not([selected])") + ) { + // For non-repeated Ctrl+A, if there's at least one unselected pill, + // first select all pills of the same .address-container. + this.selectSiblingPills(pill); + break; + } + // For non-repeated Ctrl+A, if pills in same container are already + // selected, select all pills of the entire <mail-recipients-area>. + this.selectAllPills(); + break; + + case "c": + if (event.ctrlKey || event.metaKey) { + this.copySelectedPills(); + } + break; + + case "x": + if (event.ctrlKey || event.metaKey) { + this.cutSelectedPills(); + } + break; + } + } + + /** + * Handle the selection and focus of recipient pill elements on mouse click + * and spacebar keypress events. + * + * @param {HTMLElement} pill - The <mail-address-pill> element, event target. + * @param {Event} event - A DOM click or keypress Event. + */ + checkSelected(pill, event) { + // Interrupt if the pill is in edit mode or a right click was detected. + // Selecting pills on right click will be handled by the opening of the + // context menu. + if (pill.isEditing || event.button == 2) { + return; + } + + if (!event.ctrlKey && !event.metaKey && event.key != " ") { + this.deselectAllPills(); + } + + pill.toggleAttribute("selected"); + + // We need to force the focus on a pill that receives a click event + // (or a spacebar keypress), as macOS doesn't automatically move the focus + // on this custom element (bug 1645643, bug 1645916). + pill.focus(); + } + + /** + * Handle the selection and focus of the pill elements on keyboard + * navigation. + * + * @param {Event} event - A DOM keyboard event. + * @param {HTMLElement} targetElement - A mail-address-pill or address input + * element navigated to. + */ + checkKeyboardSelected(event, targetElement) { + let sourcePill = + event.target.tagName == "mail-address-pill" ? event.target : null; + let targetPill = + targetElement.tagName == "mail-address-pill" ? targetElement : null; + + if (event.shiftKey) { + if (sourcePill) { + sourcePill.setAttribute("selected", "selected"); + } + if (event.key == "Home" && !sourcePill) { + // Shift+Home from address input. + this.selectSiblingPills(targetPill); + } + if (targetPill) { + targetPill.setAttribute("selected", "selected"); + } + } else if (!event.ctrlKey) { + // Non-modified navigation keys must select the target pill and deselect + // all others. Also some other keys like Backspace from rowInput. + this.deselectAllPills(); + if (targetPill) { + targetPill.setAttribute("selected", "selected"); + } else { + // Focus the input navigated to. + targetElement.focus(); + } + } + + // If targetElement is a pill, focus it. + if (targetPill) { + targetPill.focus(); + } + } + + /** + * Trigger the pill.startEditing() method. + * + * @param {XULElement} pill - The mail-address-pill element. + * @param {Event} event - The DOM Event. + */ + startEditing(pill, event) { + if (pill.isEditing) { + event.stopPropagation(); + return; + } + + pill.startEditing(); + } + + /** + * Copy the selected pills to clipboard. + */ + copySelectedPills() { + let selectedAddresses = [ + ...document.getElementById("recipientsContainer").getAllSelectedPills(), + ].map(pill => pill.fullAddress); + + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + clipboard.copyString(selectedAddresses.join(", ")); + } + + /** + * Cut the selected pills to clipboard. + */ + cutSelectedPills() { + this.copySelectedPills(); + this.removeSelectedPills(); + } + + /** + * Move the selected email address pills to another address row. + * + * @param {Element} row - The address row to move the pills to. + */ + moveSelectedPills(row) { + // Store all the selected addresses inside an array. + let selectedAddresses = [...this.getAllSelectedPills()].map( + pill => pill.fullAddress + ); + + // Return if no pills selected. + if (!selectedAddresses.length) { + return; + } + + // Remove the selected pills. + this.removeSelectedPills("next", false, true); + + // Create new address pills inside the target address row and + // maintain the current selection. + addressRowAddRecipientsArray(row, selectedAddresses, true); + + // Move focus to the last selected pill. + let selectedPills = this.getAllSelectedPills(); + selectedPills[selectedPills.length - 1].focus(); + } + + /** + * Delete all selected pills and handle focus and selection smartly as needed. + * + * @param {("next"|"previous")} [focusType="next"] - How to move focus after + * removing pills: try to focus one of the next siblings (for DEL etc.) + * or one of the previous siblings (for BACKSPACE). + * @param {boolean} [select=false] - After deletion, whether to select the + * focused pill where applicable. + * @param {boolean} [moved=false] - Whether the method was originally called + * from moveSelectedPills(). + */ + removeSelectedPills(focusType = "next", select = false, moved = false) { + // Return if no pills selected. + let firstSelectedPill = this.querySelector("mail-address-pill[selected]"); + if (!firstSelectedPill) { + return; + } + // Get the pill which has focus before we start removing selected pills, + // which may or may not include the focused pill. If no pill has focus, + // consider the first selected pill as focused pill for our purposes. + let pill = + this.querySelector("mail-address-pill:focus") || firstSelectedPill; + + // We'll look hard for an appropriate element to focus after the removal. + let focusElement = null; + // Get addressContainer and rowInput now as pill might be deleted later. + let addressContainer = pill.closest(".address-container"); + let rowInput = pill.rowInput; + let unselectedSourcePill = false; + + if (pill.hasAttribute("selected")) { + // Find focus (1): Focused pill is selected and will be deleted; + // try nearest sibling, observing focusType direction. + focusElement = pill.getUnselectedSiblingPill(focusType); + } else { + // The source pill isn't selected; keep it focused ("satellite focus"). + unselectedSourcePill = true; + focusElement = pill; + } + + // Remove selected pills. + let selectedPills = this.getAllSelectedPills(); + for (let sPill of selectedPills) { + sPill.remove(); + } + + // Find focus (2): When deleting backwards, if no previous sibling found, + // this means that the first pill was deleted. Try the first remaining pill, + // but don't auto-select it because it's in the opposite direction. + if (!focusElement && focusType == "previous") { + focusElement = addressContainer.querySelector("mail-address-pill"); + } else if ( + select && + focusElement && + selectedPills.length == 1 && + !unselectedSourcePill + ) { + // If select = true (DEL or BACKSPACE), and we found a pill to focus in + // round (1), and we have removed a single pill only, and it's not a + // case of "satellite focus" (see above): + // Conveniently select the nearest pill for rapid consecutive deletions. + focusElement.setAttribute("selected", "selected"); + } + // Find focus (3): If all else fails (no pills left in addressContainer, + // or last pill deleted forwards): Focus rowInput. + if (!focusElement) { + focusElement = rowInput; + } + focusElement.focus(); + + // Update aria labels for all rows as we allow cross-row pill removal. + // This may not yet be micro-performance optimized; see bug 1671261. + updateAriaLabelsAndTooltipsOfAllAddressRows(); + + // Don't trigger some methods if the pills were removed automatically + // during the move to another addressing widget. + if (!moved) { + onRecipientsChanged(); + } + } + + /** + * Select all pills of the same address row (.address-container). + * + * @param {Element} pill - A <mail-address-pill> element. All pills in the + * same .address-container will be selected. + */ + selectSiblingPills(pill) { + for (let sPill of this.getSiblingPills(pill)) { + sPill.setAttribute("selected", "selected"); + } + } + + /** + * Select all pills of the <mail-recipients-area> element. + */ + selectAllPills() { + for (let pill of this.getAllPills()) { + pill.setAttribute("selected", "selected"); + } + } + + /** + * Deselect all the pills of the <mail-recipients-area> element. + */ + deselectAllPills() { + for (let pill of this.querySelectorAll(`mail-address-pill[selected]`)) { + pill.removeAttribute("selected"); + } + } + + /** + * Return all pills of the same address row (.address-container). + * + * @param {Element} pill - A <mail-address-pill> element. All pills in the + * same .address-container will be returned. + * @returns {NodeList} NodeList of <mail-address-pill> elements in same field. + */ + getSiblingPills(pill) { + return pill + .closest(".address-container") + .querySelectorAll("mail-address-pill"); + } + + /** + * Return all pills of the <mail-recipients-area> element. + * + * @returns {NodeList} NodeList of all <mail-address-pill> elements. + */ + getAllPills() { + return this.querySelectorAll("mail-address-pill"); + } + + /** + * Return all currently selected pills in the <mail-recipients-area>. + * + * @returns {NodeList} NodeList of all selected <mail-address-pill> elements. + */ + getAllSelectedPills() { + return this.querySelectorAll("mail-address-pill[selected]"); + } + + /** + * Check if any pill in the <mail-recipients-area> is selected. + * + * @returns {boolean} true if any pill is selected. + */ + hasSelectedPills() { + return Boolean(this.querySelector("mail-address-pill[selected]")); + } + + /** + * Move the focus to the previous focusable element. + * + * @param {Element} element - The element where the event was triggered. + */ + moveFocusToPreviousElement(element) { + let row = element.closest(".address-row"); + // Move focus on the close label if not collapsed. + if (!row.querySelector(".remove-field-button").hidden) { + row.querySelector(".remove-field-button").focus(); + return; + } + // If a previous address row is available and not hidden, + // focus on the autocomplete input field. + let previousRow = row.previousElementSibling; + while (previousRow) { + if (!previousRow.classList.contains("hidden")) { + previousRow.querySelector(".address-row-input").focus(); + return; + } + previousRow = previousRow.previousElementSibling; + } + // Move the focus on the previous button: either the + // extraAddressRowsMenuButton, or one of "<type>ShowAddressRowButton". + let buttons = document.querySelectorAll( + "#extraAddressRowsArea button:not([hidden])" + ); + if (buttons.length) { + // Select the last available label. + buttons[buttons.length - 1].focus(); + return; + } + // Move the focus on the msgIdentity if no extra recipients are available. + document.getElementById("msgIdentity").focus(); + } + } + + customElements.define("mail-recipients-area", MailRecipientsArea); +} diff --git a/comm/mail/base/content/widgets/pane-splitter.js b/comm/mail/base/content/widgets/pane-splitter.js new file mode 100644 index 0000000000..d201f3286f --- /dev/null +++ b/comm/mail/base/content/widgets/pane-splitter.js @@ -0,0 +1,562 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +{ + /** + * A widget for adjusting the size of its {@link PaneSplitter#resizeElement}. + * By default, the splitter will resize the height of the resizeElement, but + * this can be changed using the "resize-direction" attribute. + * + * If dragged, the splitter will set a CSS variable on the parent element, + * which is named from the id of the element plus "width" or "height" as + * appropriate (e.g. --splitter-width). The variable should be used to set the + * border-area width or height of the resizeElement. + * + * Often, you will want to naturally limit the size of the resizeElement to + * prevent it exceeding its min or max size bounds, and to remain within the + * available space of its container. One way to do this is to use a grid + * layout on the container and size the resizeElement's row with + * "minmax(auto, --splitter-height)", or similar for the column when adjusting + * the width. + * + * This splitter element fires a "splitter-resizing" event as dragging begins, + * and "splitter-resized" when it ends. + * + * The resizeElement can be collapsed and expanded. Whilst collapsed, the + * "collapsed-by-splitter" class will be added to the resizeElement and the + * "--<id>-width" or "--<id>-height" CSS variable, will be be set to "0px". + * The "splitter-collapsed" and "splitter-expanded" events are fired as + * appropriate. If the splitter has a "collapse-width" or "collapse-height" + * attribute, collapsing and expanding happens automatically when below the + * given size. + */ + class PaneSplitter extends HTMLHRElement { + static observedAttributes = ["resize-direction", "resize-id", "id"]; + + connectedCallback() { + this.addEventListener("mousedown", this); + // Try and find the _resizeElement from the resize-id attribute. + this._updateResizeElement(); + this._updateStyling(); + } + + attributeChangedCallback(name, oldValue, newValue) { + switch (name) { + case "resize-direction": + this._updateResizeDirection(); + break; + case "resize-id": + this._updateResizeElement(); + break; + case "id": + this._updateStyling(); + break; + } + } + + /** + * The direction the splitter resizes the controlled element. Resizing + * horizontally changes its width, whilst resizing vertically changes its + * height. + * + * This corresponds to the "resize-direction" attribute and defaults to + * "vertical" when none is given. + * + * @type {"vertical"|"horizontal"} + */ + get resizeDirection() { + return this.getAttribute("resize-direction") ?? "vertical"; + } + + set resizeDirection(val) { + this.setAttribute("resize-direction", val); + } + + _updateResizeDirection() { + // The resize direction has changed. To be safe, make sure we're no longer + // resizing. + this.endResize(); + this._updateStyling(); + } + + _resizeElement = null; + + /** + * The element that is being sized by the splitter. It must have a set id. + * + * If the "resize-id" attribute is set, it will be used to choose this + * element by its id. + * + * @type {?HTMLElement} + */ + get resizeElement() { + // Make sure the resizeElement is up to date. + this._updateResizeElement(); + return this._resizeElement; + } + + set resizeElement(element) { + if (!element?.id) { + element = null; + } + this._updateResizeElement(element); + // Set the resize-id attribute. + // NOTE: This will trigger a second call to _updateResizeElement, but it + // should end early because the resize-id matches the just set + // _resizeElement. + if (element) { + this.setAttribute("resize-id", element.id); + } else { + this.removeAttribute("resize-id"); + } + } + + /** + * Update the _resizeElement property. + * + * @param {?HTMLElement} [element] - The resizeElement to set, or leave + * undefined to use the resize-id attribute to find the element. + */ + _updateResizeElement(element) { + if (element == undefined) { + // Use the resize-id to find the element. + let resizeId = this.getAttribute("resize-id"); + if (resizeId) { + if (this._resizeElement?.id == resizeId) { + // Avoid looking up the element since we already have it. + return; + } + // Try and find the element. + // NOTE: If we don't find the element now, then we still keep the same + // resize-id attribute and we'll try again the next time this method + // is called. + element = this.ownerDocument.getElementById(resizeId); + } else { + element = null; + } + } + if (element == this._resizeElement) { + return; + } + + // Make sure we stop resizing the current _resizeElement. + this.endResize(); + if (this._resizeElement) { + // Clean up previous element. + this._resizeElement.classList.remove("collapsed-by-splitter"); + } + this._resizeElement = element; + this._beforeElement = + element && + !!( + this.compareDocumentPosition(element) & + Node.DOCUMENT_POSITION_FOLLOWING + ); + // Are we already collapsed? + this._isCollapsed = this._resizeElement?.classList.contains( + "collapsed-by-splitter" + ); + this._updateStyling(); + } + + _width = null; + + /** + * The desired width of the resizeElement. This is used to set the + * --<id>-width CSS variable on the parent when the resizeDirection is + * "horizontal" and the resizeElement is not collapsed. If its value is + * null, the same CSS variable is removed from the parent instead. + * + * Note, this value is persistent across collapse states, so the width + * before collapsing can be returned to on expansion. + * + * Use this value in persistent storage. + * + * @type {?number} + */ + get width() { + return this._width; + } + + set width(width) { + if (width == this._width) { + return; + } + this._width = width; + this._updateStyling(); + } + + _height = null; + + /** + * The desired height of the resizeElement. This is used to set the + * -<id>-height CSS variable on the parent when the resizeDirection is + * "vertical" and the resizeElement is not collapsed. If its value is null, + * the same CSS variable is removed from the parent instead. + * + * Note, this value is persistent across collapse states, so the height + * before collapsing can be returned to on expansion. + * + * Use this value in persistent storage. + * + * @type {?number} + */ + get height() { + return this._height; + } + + set height(height) { + if (height == this._height) { + return; + } + this._height = height; + this._updateStyling(); + } + + /** + * Update the width or height of the splitter, depending on its + * resizeDirection. + * + * If a trySize is given, the width or height of the splitter will be set to + * the given value, before being set to the actual size of the + * resizeElement. This acts as an automatic bounding process, without + * knowing the details of the layout and its constraints. + * + * If no trySize is given, then the width and height will be set to the + * actual size of the resizeElement. + * + * @param {?number} [trySize] - The size to try and achieve. + */ + _updateSize(trySize) { + let vertical = this.resizeDirection == "vertical"; + if (trySize != undefined) { + if (vertical) { + this.height = trySize; + } else { + this.width = trySize; + } + } + // Now that the width and height are updated, we fetch the size the + // element actually took. + let actual = this._getActualResizeSize(); + if (vertical) { + this.height = actual; + } else { + this.width = actual; + } + } + + /** + * Get the actual size of the resizeElement, regardless of the current + * width or height property values. This causes a reflow, and it gets + * called on every mousemove event while dragging, so it's very expensive + * but practically unavoidable. + * + * @returns {number} - The border area size of the resizeElement. + */ + _getActualResizeSize() { + let resizeRect = this.resizeElement.getBoundingClientRect(); + if (this.resizeDirection == "vertical") { + return resizeRect.height; + } + return resizeRect.width; + } + + /** + * Collapses the controlled pane. A collapsed pane does not affect the + * `width` or `height` properties. Fires a "splitter-collapsed" event. + */ + collapse() { + if (this._isCollapsed) { + return; + } + this._isCollapsed = true; + this._updateStyling(); + this._updateDragCursor(); + this.dispatchEvent( + new CustomEvent("splitter-collapsed", { bubbles: true }) + ); + } + + /** + * Expands the controlled pane. It returns to the width or height it had + * when collapsed. Fires a "splitter-expanded" event. + */ + expand() { + if (!this._isCollapsed) { + return; + } + this._isCollapsed = false; + this._updateStyling(); + this._updateDragCursor(); + this.dispatchEvent( + new CustomEvent("splitter-expanded", { bubbles: true }) + ); + } + + _isCollapsed = false; + + /** + * If the controlled pane is collapsed. + * + * @type {boolean} + */ + get isCollapsed() { + return this._isCollapsed; + } + + set isCollapsed(collapsed) { + if (collapsed) { + this.collapse(); + } else { + this.expand(); + } + } + + /** + * Collapse the splitter if it is expanded, or expand it if collapsed. + */ + toggleCollapsed() { + this.isCollapsed = !this._isCollapsed; + } + + /** + * If the splitter is disabled. + * + * @type {boolean} + */ + get isDisabled() { + return this.hasAttribute("disabled"); + } + + set isDisabled(disabled) { + if (disabled) { + this.setAttribute("disabled", true); + return; + } + this.removeAttribute("disabled"); + } + + /** + * Update styling to reflect the current state. + */ + _updateStyling() { + if (!this.resizeElement || !this.parentNode || !this.id) { + // Wait until we have a resizeElement, a parent and an id. + return; + } + + if (this.id != this._cssName?.basis) { + // Clear the old names. + if (this._cssName) { + this.parentNode.style.removeProperty(this._cssName.width); + this.parentNode.style.removeProperty(this._cssName.height); + } + this._cssName = { + basis: this.id, + height: `--${this.id}-height`, + width: `--${this.id}-width`, + }; + } + + let vertical = this.resizeDirection == "vertical"; + let height = this.isCollapsed ? 0 : this.height; + if (!vertical || height == null) { + // If we are resizing horizontally or the "height" property is set to + // null, we remove the CSS height variable. The height of the element + // is left to be determined by the CSS stylesheet rules. + this.parentNode.style.removeProperty(this._cssName.height); + } else { + this.parentNode.style.setProperty(this._cssName.height, `${height}px`); + } + let width = this.isCollapsed ? 0 : this.width; + if (vertical || width == null) { + // If we are resizing vertically or the "width" property is set to + // null, we remove the CSS width variable. The width of the element + // is left to be determined by the CSS stylesheet rules. + this.parentNode.style.removeProperty(this._cssName.width); + } else { + this.parentNode.style.setProperty(this._cssName.width, `${width}px`); + } + this.resizeElement.classList.toggle( + "collapsed-by-splitter", + this.isCollapsed + ); + this.classList.toggle("splitter-collapsed", this.isCollapsed); + this.classList.toggle("splitter-before", this._beforeElement); + } + + handleEvent(event) { + switch (event.type) { + case "mousedown": + this._onMouseDown(event); + break; + case "mousemove": + this._onMouseMove(event); + break; + case "mouseup": + this._onMouseUp(event); + break; + } + } + + _onMouseDown(event) { + if (!this.resizeElement || this.isDisabled) { + return; + } + if (event.buttons != 1) { + return; + } + + let vertical = this.resizeDirection == "vertical"; + let collapseSize = + Number( + this.getAttribute(vertical ? "collapse-height" : "collapse-width") + ) || 0; + let ltrDir = this.parentNode.matches(":dir(ltr)"); + + this._dragStartInfo = { + wasCollapsed: this.isCollapsed, + // Whether this will resize vertically. + vertical, + pos: vertical ? event.clientY : event.clientX, + // Whether decreasing X/Y should increase the size. + negative: vertical + ? this._beforeElement + : this._beforeElement == ltrDir, + size: this._getActualResizeSize(), + collapseSize, + }; + + event.preventDefault(); + window.addEventListener("mousemove", this); + window.addEventListener("mouseup", this); + // Block all other pointer events whilst resizing. This ensures we don't + // trigger any styling or other effects whilst resizing. This also ensures + // that the MouseEvent's clientX and clientY will always be relative to + // the current window, rather than some ancestor xul:browser's window. + document.documentElement.style.pointerEvents = "none"; + this._updateDragCursor(); + this.classList.add("splitter-resizing"); + } + + _updateDragCursor() { + if (!this._dragStartInfo) { + return; + } + let cursor; + let { vertical, negative } = this._dragStartInfo; + if (this.isCollapsed) { + if (vertical) { + cursor = negative ? "n-resize" : "s-resize"; + } else { + cursor = negative ? "w-resize" : "e-resize"; + } + } else { + cursor = vertical ? "ns-resize" : "ew-resize"; + } + document.documentElement.style.cursor = cursor; + } + + /** + * If `mousemove` events will be ignored because the screen hasn't been + * updated since the last one. + * + * @type {boolean} + */ + _mouseMoveBlocked = false; + + _onMouseMove(event) { + if (event.buttons != 1) { + // The button was released and we didn't get a mouseup event (e.g. + // releasing the mouse above a disabled html:button), or the + // button(s) pressed changed. Either way, stop dragging. + this.endResize(); + return; + } + + event.preventDefault(); + + // Ensure the expensive part of this function runs no more than once + // per frame. Doing it more frequently is just wasting CPU time. + if (this._mouseMoveBlocked) { + return; + } + this._mouseMoveBlocked = true; + requestAnimationFrame(() => (this._mouseMoveBlocked = false)); + + let { wasCollapsed, vertical, negative, pos, size, collapseSize } = + this._dragStartInfo; + + let delta = (vertical ? event.clientY : event.clientX) - pos; + if (negative) { + delta *= -1; + } + + if (!this._started) { + if (Math.abs(delta) < 3) { + return; + } + this._started = true; + this.dispatchEvent( + new CustomEvent("splitter-resizing", { bubbles: true }) + ); + } + + size += delta; + if (collapseSize) { + let pastCollapseThreshold = size < collapseSize - 20; + if (wasCollapsed) { + if (!pastCollapseThreshold) { + this._dragStartInfo.wasCollapsed = false; + } + pastCollapseThreshold = size < 20; + } + + if (pastCollapseThreshold) { + this.collapse(); + return; + } + + this.expand(); + size = Math.max(size, collapseSize); + } + this._updateSize(Math.max(0, size)); + } + + _onMouseUp(event) { + event.preventDefault(); + this.endResize(); + } + + /** + * Stop the resizing operation if it is currently active. + */ + endResize() { + if (!this._dragStartInfo) { + return; + } + let didStart = this._started; + + delete this._dragStartInfo; + delete this._started; + + window.removeEventListener("mousemove", this); + window.removeEventListener("mouseup", this); + document.documentElement.style.pointerEvents = null; + document.documentElement.style.cursor = null; + this.classList.remove("splitter-resizing"); + + // Make sure our property corresponds to the actual final size. + this._updateSize(); + + if (didStart) { + this.dispatchEvent( + new CustomEvent("splitter-resized", { bubbles: true }) + ); + } + } + } + customElements.define("pane-splitter", PaneSplitter, { extends: "hr" }); +} diff --git a/comm/mail/base/content/widgets/statuspanel.js b/comm/mail/base/content/widgets/statuspanel.js new file mode 100644 index 0000000000..8d30ea4697 --- /dev/null +++ b/comm/mail/base/content/widgets/statuspanel.js @@ -0,0 +1,78 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* global MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + class MozStatuspanel extends MozXULElement { + static get observedAttributes() { + return ["label", "mirror"]; + } + + connectedCallback() { + const hbox = document.createXULElement("hbox"); + hbox.classList.add("statuspanel-inner"); + + const label = document.createXULElement("label"); + label.classList.add("statuspanel-label"); + label.setAttribute("flex", "1"); + label.setAttribute("crop", "end"); + + hbox.appendChild(label); + this.appendChild(hbox); + + this._labelElement = label; + + this._updateAttributes(); + this._setupEventListeners(); + } + + attributeChangedCallback() { + this._updateAttributes(); + } + + set label(val) { + if (!this.label) { + this.removeAttribute("mirror"); + } + this.setAttribute("label", val); + } + + get label() { + return this.getAttribute("label"); + } + + _updateAttributes() { + if (!this._labelElement) { + return; + } + + if (this.hasAttribute("label")) { + this._labelElement.setAttribute("value", this.getAttribute("label")); + } else { + this._labelElement.removeAttribute("value"); + } + + if (this.hasAttribute("mirror")) { + this._labelElement.setAttribute("mirror", this.getAttribute("mirror")); + } else { + this._labelElement.removeAttribute("mirror"); + } + } + + _setupEventListeners() { + this.addEventListener("mouseover", event => { + if (this.hasAttribute("mirror")) { + this.removeAttribute("mirror"); + } else { + this.setAttribute("mirror", "true"); + } + }); + } + } + + customElements.define("statuspanel", MozStatuspanel); +} diff --git a/comm/mail/base/content/widgets/tabmail-tab.js b/comm/mail/base/content/widgets/tabmail-tab.js new file mode 100644 index 0000000000..7de115149b --- /dev/null +++ b/comm/mail/base/content/widgets/tabmail-tab.js @@ -0,0 +1,179 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global MozElements, MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozTabmailTab widget behaves as a tab in the messenger window. + * It is used to navigate between different views. It displays information + * about the view: i.e. name and icon. + * + * @augments {MozElements.MozTab} + */ + class MozTabmailTab extends MozElements.MozTab { + static get inheritedAttributes() { + return { + ".tab-background": "pinned,selected,titlechanged", + ".tab-line": "selected=visuallyselected", + ".tab-content": "pinned,selected,titlechanged,title=label", + ".tab-throbber": "fadein,pinned,busy,progress,selected", + ".tab-icon-image": "fadein,pinned,selected", + ".tab-label-container": "pinned,selected=visuallyselected", + ".tab-text": "text=label,accesskey,fadein,pinned,selected", + ".tab-close-button": "fadein,pinned,selected=visuallyselected", + }; + } + + connectedCallback() { + if (this.delayConnectedCallback() || this.hasChildNodes()) { + return; + } + + this.setAttribute("is", "tabmail-tab"); + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <stack class="tab-stack" flex="1"> + <vbox class="tab-background"> + <hbox class="tab-line"></hbox> + </vbox> + <html:div class="tab-content"> + <hbox class="tab-throbber" role="presentation"></hbox> + <html:img class="tab-icon-image" alt="" role="presentation" /> + <hbox class="tab-label-container" + onoverflow="this.setAttribute('textoverflow', 'true');" + onunderflow="this.removeAttribute('textoverflow');" + flex="1"> + <label class="tab-text tab-label" role="presentation"></label> + </hbox> + <!-- We make the button non-focusable, otherwise each close + - button creates a new tab stop. See bug 1754097 --> + <html:button class="plain-button tab-close-button" + tabindex="-1" + title="&closeTab.label;"> + <!-- Button title should provide the accessible context. --> + <html:img class="tab-close-icon" alt="" + src="chrome://global/skin/icons/close.svg" /> + </html:button> + </html:div> + </stack> + `, + ["chrome://messenger/locale/tabmail.dtd"] + ) + ); + + this.addEventListener( + "dragstart", + event => { + document.dragTab = this; + }, + true + ); + + this.addEventListener( + "dragover", + event => { + document.dragTab = null; + }, + true + ); + + let closeButton = this.querySelector(".tab-close-button"); + + // Prevent switching to the tab before closing it by stopping the + // mousedown event. + closeButton.addEventListener("mousedown", event => { + if (event.button != 0) { + return; + } + event.stopPropagation(); + }); + + closeButton.addEventListener("click", () => + document.getElementById("tabmail").removeTabByNode(this) + ); + + // Middle mouse button click on the tab also closes it. + this.addEventListener("click", event => { + if (event.button != 1) { + return; + } + document.getElementById("tabmail").removeTabByNode(this); + }); + + this.setAttribute("context", "tabContextMenu"); + + this.mCorrespondingMenuitem = null; + + this.initializeAttributeInheritance(); + } + + get linkedBrowser() { + let tabmail = document.getElementById("tabmail"); + let tab = tabmail._getTabContextForTabbyThing(this, false)[1]; + return tabmail.getBrowserForTab(tab); + } + + get mode() { + let tabmail = document.getElementById("tabmail"); + let tab = tabmail._getTabContextForTabbyThing(this, false)[1]; + return tab.mode; + } + + /** + * Set the displayed icon for the tab. + * + * If a fallback source if given, it will be used instead if the given icon + * source is missing or loads with an error. + * + * If both sources are null, then the icon will become invisible. + * + * @param {string|null} iconSrc - The icon source to display in the tab, or + * null to just use the fallback source. + * @param {?string} [fallbackSrc] - The fallback source to display if the + * iconSrc is missing or broken. + */ + setIcon(iconSrc, fallbackSrc) { + let icon = this.querySelector(".tab-icon-image"); + if (!fallbackSrc) { + if (iconSrc) { + icon.setAttribute("src", iconSrc); + } else { + icon.removeAttribute("src"); + } + return; + } + if (!iconSrc) { + icon.setAttribute("src", fallbackSrc); + return; + } + if (iconSrc == icon.getAttribute("src")) { + return; + } + + // Set the tab image, and use the fallback if an error occurs. + // Set up a one time listener for either error or load. + let listener = event => { + icon.removeEventListener("error", listener); + icon.removeEventListener("load", listener); + if (event.type == "error") { + icon.setAttribute("src", fallbackSrc); + } + }; + icon.addEventListener("error", listener); + icon.addEventListener("load", listener); + icon.setAttribute("src", iconSrc); + } + } + + MozXULElement.implementCustomInterface(MozTabmailTab, [ + Ci.nsIDOMXULSelectControlItemElement, + ]); + + customElements.define("tabmail-tab", MozTabmailTab, { extends: "tab" }); +} diff --git a/comm/mail/base/content/widgets/tabmail-tabs.js b/comm/mail/base/content/widgets/tabmail-tabs.js new file mode 100644 index 0000000000..004a60122d --- /dev/null +++ b/comm/mail/base/content/widgets/tabmail-tabs.js @@ -0,0 +1,723 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global MozElements, MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" + ); + + /** + * The MozTabs widget holds all the tabs for the main tab UI. + * + * @augments {MozTabs} + */ + class MozTabmailTabs extends customElements.get("tabs") { + constructor() { + super(); + + this.addEventListener("dragstart", event => { + let draggedTab = this._getDragTargetTab(event); + + if (!draggedTab) { + return; + } + + let tab = this.tabmail.selectedTab; + + if (!tab || !tab.canClose) { + return; + } + + let dt = event.dataTransfer; + + // If we drag within the same window, we use the tab directly + dt.mozSetDataAt("application/x-moz-tabmail-tab", draggedTab, 0); + + // Otherwise we use session restore & JSON to migrate the tab. + let uri = this.tabmail.persistTab(tab); + + // In case the tab implements session restore, we use JSON to convert + // it into a string. + // + // If a tab does not support session restore it returns null. We can't + // moved such tabs to a new window. However moving them within the same + // window works perfectly fine. + if (uri) { + uri = JSON.stringify(uri); + } + + dt.mozSetDataAt("application/x-moz-tabmail-json", uri, 0); + + dt.mozCursor = "default"; + + // Create Drag Image. + let panel = document.getElementById("tabpanelcontainer"); + + let thumbnail = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + thumbnail.width = Math.ceil(screen.availWidth / 5.75); + thumbnail.height = Math.round(thumbnail.width * 0.5625); + + let snippetWidth = panel.getBoundingClientRect().width * 0.6; + let scale = thumbnail.width / snippetWidth; + + let ctx = thumbnail.getContext("2d"); + + ctx.scale(scale, scale); + + ctx.drawWindow( + window, + panel.screenX - window.mozInnerScreenX, + panel.screenY - window.mozInnerScreenY, + snippetWidth, + snippetWidth * 0.5625, + "rgb(255,255,255)" + ); + + dt = event.dataTransfer; + dt.setDragImage(thumbnail, 0, 0); + + event.stopPropagation(); + }); + + this.addEventListener("dragover", event => { + let dt = event.dataTransfer; + + if (dt.mozItemCount == 0) { + return; + } + + // Bug 516247: + // in case the user is dragging something else than a tab, and + // keeps hovering over a tab, we assume he wants to switch to this tab. + if ( + dt.mozTypesAt(0)[0] != "application/x-moz-tabmail-tab" && + dt.mozTypesAt(0)[1] != "application/x-moz-tabmail-json" + ) { + let tab = this._getDragTargetTab(event); + + if (!tab) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (!this._dragTime) { + this._dragTime = Date.now(); + return; + } + + if (Date.now() <= this._dragTime + this._dragOverDelay) { + return; + } + + if (this.tabmail.tabContainer.selectedItem == tab) { + return; + } + + this.tabmail.tabContainer.selectedItem = tab; + + return; + } + + // As some tabs do not support session restore they can't be + // moved to a different or new window. We should not show + // a dropmarker in such a case. + if (!dt.mozGetDataAt("application/x-moz-tabmail-json", 0)) { + let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0); + + if (!draggedTab) { + return; + } + + if (this.tabmail.tabContainer.getIndexOfItem(draggedTab) == -1) { + return; + } + } + + dt.effectAllowed = "copyMove"; + + event.preventDefault(); + event.stopPropagation(); + + let ltr = window.getComputedStyle(this).direction == "ltr"; + let ind = this._tabDropIndicator; + let arrowScrollbox = this.arrowScrollbox; + + // Let's scroll + let pixelsToScroll = 0; + if (arrowScrollbox.getAttribute("overflow") == "true") { + switch (event.target) { + case arrowScrollbox._scrollButtonDown: + pixelsToScroll = arrowScrollbox.scrollIncrement * -1; + break; + case arrowScrollbox._scrollButtonUp: + pixelsToScroll = arrowScrollbox.scrollIncrement; + break; + } + + if (ltr) { + pixelsToScroll = pixelsToScroll * -1; + } + + if (pixelsToScroll) { + // Hide Indicator while Scrolling + ind.hidden = true; + arrowScrollbox.scrollByPixels(pixelsToScroll); + return; + } + } + + let newIndex = this._getDropIndex(event); + + // Fix the DropIndex in case it points to tab that can't be closed. + let tabInfo = this.tabmail.tabInfo; + + while (newIndex < tabInfo.length && !tabInfo[newIndex].canClose) { + newIndex++; + } + + let scrollRect = this.arrowScrollbox.scrollClientRect; + let rect = this.getBoundingClientRect(); + let minMargin = scrollRect.left - rect.left; + let maxMargin = Math.min( + minMargin + scrollRect.width, + scrollRect.right + ); + + if (!ltr) { + [minMargin, maxMargin] = [ + this.clientWidth - maxMargin, + this.clientWidth - minMargin, + ]; + } + + let newMargin; + let tabs = this.allTabs; + + if (newIndex == tabs.length) { + let tabRect = tabs[newIndex - 1].getBoundingClientRect(); + + if (ltr) { + newMargin = tabRect.right - rect.left; + } else { + newMargin = rect.right - tabRect.left; + } + } else { + let tabRect = tabs[newIndex].getBoundingClientRect(); + + if (ltr) { + newMargin = tabRect.left - rect.left; + } else { + newMargin = rect.right - tabRect.right; + } + } + + ind.hidden = false; + + newMargin -= ind.clientWidth / 2; + + ind.style.insetInlineStart = `${Math.round(newMargin)}px`; + }); + + this.addEventListener("drop", event => { + let dt = event.dataTransfer; + + if (dt.mozItemCount != 1) { + return; + } + + let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0); + + if (!draggedTab) { + return; + } + + event.stopPropagation(); + this._tabDropIndicator.hidden = true; + + // Is the tab one of our children? + if (this.tabmail.tabContainer.getIndexOfItem(draggedTab) == -1) { + // It's a tab from an other window, so we have to trigger session + // restore to get our tab + + let tabmail2 = draggedTab.ownerDocument.getElementById("tabmail"); + if (!tabmail2) { + return; + } + + let draggedJson = dt.mozGetDataAt( + "application/x-moz-tabmail-json", + 0 + ); + if (!draggedJson) { + return; + } + + draggedJson = JSON.parse(draggedJson); + + // Some tab exist only once, so we have to gamble a bit. We close + // the tab and try to reopen it. If something fails the tab is gone. + + tabmail2.closeTab(draggedTab, true); + + if (!this.tabmail.restoreTab(draggedJson)) { + return; + } + + draggedTab = + this.tabmail.tabContainer.allTabs[ + this.tabmail.tabContainer.allTabs.length - 1 + ]; + } + + let idx = this._getDropIndex(event); + + // Fix the DropIndex in case it points to tab that can't be closed + let tabInfo = this.tabmail.tabInfo; + while (idx < tabInfo.length && !tabInfo[idx].canClose) { + idx++; + } + + this.tabmail.moveTabTo(draggedTab, idx); + + this.tabmail.switchToTab(draggedTab); + this.tabmail.updateCurrentTab(); + }); + + this.addEventListener("dragend", event => { + // Note: while this case is correctly handled here, this event + // isn't dispatched when the tab is moved within the tabstrip, + // see bug 460801. + + // The user pressed ESC to cancel the drag, or the drag succeeded. + let dt = event.dataTransfer; + if (dt.mozUserCancelled || dt.dropEffect != "none") { + return; + } + + // Disable detach within the browser toolbox. + let eX = event.screenX; + let wX = window.screenX; + + // Check if the drop point is horizontally within the window. + if (eX > wX && eX < wX + window.outerWidth) { + let bo = this.arrowScrollbox; + // Also avoid detaching if the the tab was dropped too close to + // the tabbar (half a tab). + let endScreenY = bo.screenY + 1.5 * bo.getBoundingClientRect().height; + let eY = event.screenY; + + if (eY < endScreenY && eY > window.screenY) { + return; + } + } + + // User wants to deatach tab from window... + if (dt.mozItemCount != 1) { + return; + } + + let draggedTab = dt.mozGetDataAt("application/x-moz-tabmail-tab", 0); + + if (!draggedTab) { + return; + } + + this.tabmail.replaceTabWithWindow(draggedTab); + }); + + this.addEventListener("dragleave", event => { + this._dragTime = 0; + + this._tabDropIndicator.hidden = true; + event.stopPropagation(); + }); + } + + connectedCallback() { + if (this.delayConnectedCallback()) { + return; + } + super.connectedCallback(); + + this.tabmail = document.getElementById("tabmail"); + + this.arrowScrollboxWidth = 0; + + this.arrowScrollbox = this.querySelector("arrowscrollbox"); + + this.mCollapseToolbar = document.getElementById( + this.getAttribute("collapsetoolbar") + ); + + // @implements {nsIObserver} + this._prefObserver = (subject, topic, data) => { + if (topic == "nsPref:changed") { + subject.QueryInterface(Ci.nsIPrefBranch); + if (data == "mail.tabs.autoHide") { + this.mAutoHide = subject.getBoolPref("mail.tabs.autoHide"); + } + } + }; + + this._tabDropIndicator = this.querySelector(".tab-drop-indicator"); + + this._dragOverDelay = 350; + + this._dragTime = 0; + + this._mAutoHide = false; + + this.mAllTabsButton = document.getElementById( + this.getAttribute("alltabsbutton") + ); + this.mAllTabsPopup = this.mAllTabsButton.menu; + + this.mDownBoxAnimate = this.arrowScrollbox; + + this._animateTimer = null; + + this._animateStep = -1; + + this._animateDelay = 25; + + this._animatePercents = [ + 1.0, 0.85, 0.8, 0.75, 0.71, 0.68, 0.65, 0.62, 0.59, 0.57, 0.54, 0.52, + 0.5, 0.47, 0.45, 0.44, 0.42, 0.4, 0.38, 0.37, 0.35, 0.34, 0.32, 0.31, + 0.3, 0.29, 0.28, 0.27, 0.26, 0.25, 0.24, 0.23, 0.23, 0.22, 0.22, 0.21, + 0.21, 0.21, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.2, 0.19, 0.19, 0.19, + 0.18, 0.18, 0.17, 0.17, 0.16, 0.15, 0.14, 0.13, 0.11, 0.09, 0.06, + ]; + + this.mTabMinWidth = Services.prefs.getIntPref("mail.tabs.tabMinWidth"); + this.mTabMaxWidth = Services.prefs.getIntPref("mail.tabs.tabMaxWidth"); + this.mTabClipWidth = Services.prefs.getIntPref("mail.tabs.tabClipWidth"); + this.mAutoHide = Services.prefs.getBoolPref("mail.tabs.autoHide"); + + if (this.mAutoHide) { + this.mCollapseToolbar.collapsed = true; + document.documentElement.setAttribute("tabbarhidden", "true"); + } + + this._updateCloseButtons(); + + Services.prefs.addObserver("mail.tabs.", this._prefObserver); + + window.addEventListener("resize", this); + + // Listen to overflow/underflow events on the tabstrip, + // we cannot put these as xbl handlers on the entire binding because + // they would also get called for the all-tabs popup scrollbox. + // Also, we can't rely on event.target because these are all + // anonymous nodes. + this.arrowScrollbox.shadowRoot.addEventListener("overflow", this); + this.arrowScrollbox.shadowRoot.addEventListener("underflow", this); + + this.addEventListener("select", event => { + this._handleTabSelect(); + + if ( + !("updateCurrentTab" in this.tabmail) || + event.target.localName != "tabs" + ) { + return; + } + + this.tabmail.updateCurrentTab(); + }); + + this.addEventListener("TabSelect", event => { + this._handleTabSelect(); + }); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_tabMinWidthPref", + "mail.tabs.tabMinWidth", + null, + (pref, prevValue, newValue) => (this._tabMinWidth = newValue), + newValue => { + const LIMIT = 50; + return Math.max(newValue, LIMIT); + } + ); + this._tabMinWidth = this._tabMinWidthPref; + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_tabMaxWidthPref", + "mail.tabs.tabMaxWidth", + null, + (pref, prevValue, newValue) => (this._tabMaxWidth = newValue) + ); + this._tabMaxWidth = this._tabMaxWidthPref; + } + + get tabbox() { + return document.getElementById("tabmail-tabbox"); + } + + // Accessor for tabs. + get allTabs() { + if (!this.arrowScrollbox) { + return []; + } + + return Array.from(this.arrowScrollbox.children); + } + + appendChild(tab) { + return this.insertBefore(tab, null); + } + + insertBefore(tab, node) { + if (!this.arrowScrollbox) { + return; + } + + if (node == null) { + this.arrowScrollbox.appendChild(tab); + return; + } + + this.arrowScrollbox.insertBefore(tab, node); + } + + set mAutoHide(val) { + if (val != this._mAutoHide) { + if (this.allTabs.length == 1) { + this.mCollapseToolbar.collapsed = val; + } + this._mAutoHide = val; + } + } + + get mAutoHide() { + return this._mAutoHide; + } + + set selectedIndex(val) { + let tab = this.getItemAtIndex(val); + let alreadySelected = tab && tab.selected; + + this.__proto__.__proto__ + .__lookupSetter__("selectedIndex") + .call(this, val); + + if (!alreadySelected) { + // Fire an onselect event for the tabs element. + let event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.dispatchEvent(event); + } + } + + get selectedIndex() { + return this.__proto__.__proto__ + .__lookupGetter__("selectedIndex") + .call(this); + } + + _updateCloseButtons() { + let width = + this.arrowScrollbox.firstElementChild.getBoundingClientRect().width; + // 0 width is an invalid value and indicates + // an item without display, so ignore. + if (width > this.mTabClipWidth || width == 0) { + this.setAttribute("closebuttons", "alltabs"); + } else { + this.setAttribute("closebuttons", "activetab"); + } + } + + _handleTabSelect() { + this.arrowScrollbox.ensureElementIsVisible(this.selectedItem); + } + + handleEvent(aEvent) { + let alltabsButton = document.getElementById("alltabs-button"); + + switch (aEvent.type) { + case "overflow": + this.arrowScrollbox.ensureElementIsVisible(this.selectedItem); + + // filter overflow events which were dispatched on nested scrollboxes + // and ignore vertical events. + if ( + aEvent.target != this.arrowScrollbox.scrollbox || + aEvent.detail == 0 + ) { + return; + } + + this.arrowScrollbox.setAttribute("overflow", "true"); + alltabsButton.removeAttribute("hidden"); + break; + case "underflow": + // filter underflow events which were dispatched on nested scrollboxes + // and ignore vertical events. + if ( + aEvent.target != this.arrowScrollbox.scrollbox || + aEvent.detail == 0 + ) { + return; + } + + this.arrowScrollbox.removeAttribute("overflow"); + alltabsButton.setAttribute("hidden", "true"); + break; + case "resize": + let width = this.arrowScrollbox.getBoundingClientRect().width; + if (width != this.arrowScrollboxWidth) { + this._updateCloseButtons(); + // XXX without this line the tab bar won't budge + this.arrowScrollbox.scrollByPixels(1); + this._handleTabSelect(); + this.arrowScrollboxWidth = width; + } + break; + } + } + + _stopAnimation() { + if (this._animateStep != -1) { + if (this._animateTimer) { + this._animateTimer.cancel(); + } + + this._animateStep = -1; + this.mAllTabsBoxAnimate.style.opacity = 0.0; + this.mDownBoxAnimate.style.opacity = 0.0; + } + } + + _notifyBackgroundTab(aTab) { + let tsbo = this.arrowScrollbox; + let tsboStart = tsbo.screenX; + let tsboEnd = tsboStart + tsbo.getBoundingClientRect().width; + + let ctboStart = aTab.screenX; + let ctboEnd = ctboStart + aTab.getBoundingClientRect().width; + + // only start the flash timer if the new tab (which was loaded in + // the background) is not completely visible + if (tsboStart > ctboStart || ctboEnd > tsboEnd) { + this._animateStep = 0; + + if (!this._animateTimer) { + this._animateTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + } else { + this._animateTimer.cancel(); + } + + this._animateTimer.initWithCallback( + this, + this._animateDelay, + Ci.nsITimer.TYPE_REPEATING_SLACK + ); + } + } + + notify(aTimer) { + if (!document) { + aTimer.cancel(); + } + + let percent = this._animatePercents[this._animateStep]; + this.mAllTabsBoxAnimate.style.opacity = percent; + this.mDownBoxAnimate.style.opacity = percent; + + if (this._animateStep < this._animatePercents.length - 1) { + this._animateStep++; + } else { + this._stopAnimation(); + } + } + + _getDragTargetTab(event) { + let tab = event.target; + while (tab && tab.localName != "tab") { + tab = tab.parentNode; + } + + if (!tab) { + return null; + } + + if (event.type != "drop" && event.type != "dragover") { + return tab; + } + + let tabRect = tab.getBoundingClientRect(); + if (event.screenX < tab.screenX + tabRect.width * 0.25) { + return null; + } + + if (event.screenX > tab.screenX + tabRect.width * 0.75) { + return null; + } + + return tab; + } + + _getDropIndex(event) { + let tabs = this.allTabs; + + if (window.getComputedStyle(this).direction == "ltr") { + for (let i = 0; i < tabs.length; i++) { + if ( + event.screenX < + tabs[i].screenX + tabs[i].getBoundingClientRect().width / 2 + ) { + return i; + } + } + } else { + for (let i = 0; i < tabs.length; i++) { + if ( + event.screenX > + tabs[i].screenX + tabs[i].getBoundingClientRect().width / 2 + ) { + return i; + } + } + } + + return tabs.length; + } + + set _tabMinWidth(val) { + this.arrowScrollbox.style.setProperty("--tab-min-width", `${val}px`); + } + set _tabMaxWidth(val) { + this.arrowScrollbox.style.setProperty("--tab-max-width", `${val}px`); + } + + disconnectedCallback() { + Services.prefs.removeObserver("mail.tabs.", this._prefObserver); + + // Release timer to avoid reference cycles. + if (this._animateTimer) { + this._animateTimer.cancel(); + this._animateTimer = null; + } + + this.arrowScrollbox.shadowRoot.removeEventListener("overflow", this); + this.arrowScrollbox.shadowRoot.removeEventListener("underflow", this); + } + } + + MozXULElement.implementCustomInterface(MozTabmailTabs, [Ci.nsITimerCallback]); + customElements.define("tabmail-tabs", MozTabmailTabs, { extends: "tabs" }); +} diff --git a/comm/mail/base/content/widgets/toolbarContext.inc.xhtml b/comm/mail/base/content/widgets/toolbarContext.inc.xhtml new file mode 100644 index 0000000000..c6a1c415a8 --- /dev/null +++ b/comm/mail/base/content/widgets/toolbarContext.inc.xhtml @@ -0,0 +1,19 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at http://mozilla.org/MPL/2.0/. + +<menupopup id="toolbar-context-menu" + onpopupshowing="calendarOnToolbarsPopupShowing(event); ToolbarContextMenu.updateExtension(this);"> + <menuseparator id="customizeMailToolbarMenuSeparator"/> + <menuitem id="CustomizeMailToolbar" + command="cmd_CustomizeMailToolbar" + label="&customizeToolbar.label;" + accesskey="&customizeToolbar.accesskey;"/> + <menuseparator id="extensionsMailToolbarMenuSeparator"/> + <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-manage-extension" + class="customize-context-manageExtension"/> + <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)" + data-l10n-id="toolbar-context-menu-remove-extension" + class="customize-context-removeExtension"/> +</menupopup> diff --git a/comm/mail/base/content/widgets/toolbarbutton-menu-button.js b/comm/mail/base/content/widgets/toolbarbutton-menu-button.js new file mode 100644 index 0000000000..c514aa7357 --- /dev/null +++ b/comm/mail/base/content/widgets/toolbarbutton-menu-button.js @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* global MozXULElement */ + +// Wrap in a block to prevent leaking to window scope. +{ + /** + * The MozToolbarButtonMenuButton widget is a toolbarbutton with + * type="menu". Place a menupopup element inside the button to create + * the menu popup. When the dropmarker in the toobarbutton is pressed the + * menupopup will open. When clicking the main area of the button it works + * like a normal toolbarbutton. + * + * @augments MozToolbarbutton + */ + class MozToolbarButtonMenuButton extends customElements.get("toolbarbutton") { + static get inheritedAttributes() { + return { + ...super.inheritedAttributes, + ".toolbarbutton-menubutton-button": + "command,hidden,disabled,align,dir,pack,orient,label,wrap,tooltiptext=buttontooltiptext", + ".toolbarbutton-menubutton-dropmarker": "open,disabled", + }; + } + static get menubuttonFragment() { + let frag = document.importNode( + MozXULElement.parseXULToFragment(` + <toolbarbutton class="box-inherit toolbarbutton-menubutton-button" + flex="1" + allowevents="true"></toolbarbutton> + <dropmarker type="menu" + class="toolbarbutton-menubutton-dropmarker"></dropmarker> + `), + true + ); + Object.defineProperty(this, "menubuttonFragment", { value: frag }); + return frag; + } + + /** @override */ + get _hasConnected() { + return ( + this.querySelector(":scope > toolbarbutton > .toolbarbutton-text") != + null + ); + } + + /** @override */ + render() { + this.appendChild(this.constructor.menubuttonFragment.cloneNode(true)); + this.initializeAttributeInheritance(); + } + + connectedCallback() { + if (this.delayConnectedCallback() || this._hasConnected) { + return; + } + + // Defer creating DOM elements for content inside popups. + // These will be added in the popupshown handler above. + let panel = this.closest("panel"); + if (panel && !panel.hasAttribute("hasbeenopened")) { + return; + } + this.setAttribute("is", "toolbarbutton-menu-button"); + this.setAttribute("type", "menu"); + + this.render(); + } + } + customElements.define( + "toolbarbutton-menu-button", + MozToolbarButtonMenuButton, + { extends: "toolbarbutton" } + ); +} diff --git a/comm/mail/base/content/widgets/tree-listbox.js b/comm/mail/base/content/widgets/tree-listbox.js new file mode 100644 index 0000000000..81d42ca72b --- /dev/null +++ b/comm/mail/base/content/widgets/tree-listbox.js @@ -0,0 +1,914 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +{ + // Animation variables for expanding and collapsing child lists. + const ANIMATION_DURATION_MS = 200; + const ANIMATION_EASING = "ease"; + let reducedMotionMedia = matchMedia("(prefers-reduced-motion)"); + + /** + * Provides keyboard and mouse interaction to a (possibly nested) list. + * It is intended for lists with a small number (up to 1000?) of items. + * Only one item can be selected at a time. Maintenance of the items in the + * list is not managed here. Styling of the list is not managed here. + * + * The following class names apply to list items: + * - selected: Indicates the currently selected list item. + * - children: If the list item has descendants. + * - collapsed: If the list item's descendants are hidden. + * + * List items can provide their own twisty element, which will operate when + * clicked on if given the class name "twisty". + * + * This class fires "collapsed", "expanded" and "select" events. + */ + let TreeListboxMixin = Base => + class extends Base { + /** + * The selected and focused item, or null if there is none. + * + * @type {?HTMLLIElement} + */ + _selectedRow = null; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-listbox"); + switch (this.getAttribute("role")) { + case "tree": + this.isTree = true; + break; + case "listbox": + this.isTree = false; + break; + default: + throw new RangeError( + `Unsupported role ${this.getAttribute("role")}` + ); + } + this.tabIndex = 0; + + this.domChanged(); + this._initRows(); + let rows = this.rows; + if (!this.selectedRow && rows.length) { + // TODO: This should only really happen on "focus". + this.selectedRow = rows[0]; + } + + this.addEventListener("click", this); + this.addEventListener("keydown", this); + this._mutationObserver.observe(this, { + subtree: true, + childList: true, + }); + } + + handleEvent(event) { + switch (event.type) { + case "click": + this._onClick(event); + break; + case "keydown": + this._onKeyDown(event); + break; + } + } + + _onClick(event) { + if (event.button !== 0) { + return; + } + + let row = event.target.closest("li:not(.unselectable)"); + if (!row) { + return; + } + + if ( + row.classList.contains("children") && + (event.target.closest(".twisty") || event.detail == 2) + ) { + if (row.classList.contains("collapsed")) { + this.expandRow(row); + } else { + this.collapseRow(row); + } + return; + } + + this.selectedRow = row; + if (document.activeElement != this) { + // Overflowing elements with tabindex=-1 steal focus. Grab it back. + this.focus(); + } + } + + _onKeyDown(event) { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + + switch (event.key) { + case "ArrowUp": + this.selectedIndex = this._clampIndex(this.selectedIndex - 1); + break; + case "ArrowDown": + this.selectedIndex = this._clampIndex(this.selectedIndex + 1); + break; + case "Home": + this.selectedIndex = 0; + break; + case "End": + this.selectedIndex = this.rowCount - 1; + break; + case "PageUp": { + if (!this.selectedRow) { + break; + } + // Get the top of the selected row, and remove the page height. + let selectedBox = this.selectedRow.getBoundingClientRect(); + let y = selectedBox.top - this.clientHeight; + + // Find the last row below there. + let rows = this.rows; + let i = this.selectedIndex - 1; + while (i > 0 && rows[i].getBoundingClientRect().top >= y) { + i--; + } + this.selectedIndex = i; + break; + } + case "PageDown": { + if (!this.selectedRow) { + break; + } + // Get the top of the selected row, and add the page height. + let selectedBox = this.selectedRow.getBoundingClientRect(); + let y = selectedBox.top + this.clientHeight; + + // Find the last row below there. + let rows = this.rows; + let i = rows.length - 1; + while ( + i > this.selectedIndex && + rows[i].getBoundingClientRect().top >= y + ) { + i--; + } + this.selectedIndex = i; + break; + } + case "ArrowLeft": + case "ArrowRight": { + let selected = this.selectedRow; + if (!selected) { + break; + } + + let isArrowRight = event.key == "ArrowRight"; + let isRTL = this.matches(":dir(rtl)"); + if (isArrowRight == isRTL) { + let parent = selected.parentNode.closest( + ".children:not(.unselectable)" + ); + if ( + parent && + (!selected.classList.contains("children") || + selected.classList.contains("collapsed")) + ) { + this.selectedRow = parent; + break; + } + if (selected.classList.contains("children")) { + this.collapseRow(selected); + } + } else if (selected.classList.contains("children")) { + if (selected.classList.contains("collapsed")) { + this.expandRow(selected); + } else { + this.selectedRow = selected.querySelector("li"); + } + } + break; + } + case "Enter": { + const selected = this.selectedRow; + if (!selected?.classList.contains("children")) { + return; + } + if (selected.classList.contains("collapsed")) { + this.expandRow(selected); + } else { + this.collapseRow(selected); + } + break; + } + default: + return; + } + + event.preventDefault(); + } + + /** + * Data for the rows in the DOM. + * + * @typedef {object} TreeRowData + * @property {HTMLLIElement} row - The row item. + * @property {HTMLLIElement[]} ancestors - The ancestors of the row, + * ordered closest to furthest away. + */ + + /** + * Data for all items beneath this node, including collapsed items, + * ordered as they are in the DOM. + * + * @type {TreeRowData[]} + */ + _rowsData = []; + + /** + * Call whenever the tree nodes or ordering changes. This should only be + * called externally if the mutation observer has been dis-connected and + * re-connected. + */ + domChanged() { + this._rowsData = Array.from(this.querySelectorAll("li"), row => { + let ancestors = []; + for ( + let parentRow = row.parentNode.closest("li"); + this.contains(parentRow); + parentRow = parentRow.parentNode.closest("li") + ) { + ancestors.push(parentRow); + } + return { row, ancestors }; + }); + } + + _mutationObserver = new MutationObserver(mutations => { + for (let mutation of mutations) { + for (let node of mutation.addedNodes) { + if (node.nodeType != Node.ELEMENT_NODE || !node.matches("li")) { + continue; + } + // No item can already be selected on addition. + node.classList.remove("selected"); + } + } + let oldRowsData = this._rowsData; + this.domChanged(); + this._initRows(); + let newRows = this.rows; + if (!newRows.length) { + this.selectedRow = null; + return; + } + if (!this.selectedRow) { + // TODO: This should only really happen on "focus". + this.selectedRow = newRows[0]; + return; + } + if (newRows.includes(this.selectedRow)) { + // Selected row is still visible. + return; + } + let oldSelectedIndex = oldRowsData.findIndex( + entry => entry.row == this.selectedRow + ); + if (oldSelectedIndex < 0) { + // Unexpected, the selectedRow was not in our _rowsData list. + this.selectedRow = newRows[0]; + return; + } + // Find the closest ancestor that is still shown. + let existingAncestor = oldRowsData[oldSelectedIndex].ancestors.find( + row => newRows.includes(row) + ); + if (existingAncestor) { + // We search as if the existingAncestor is the full list. This keeps + // the selection within the ancestor, or moves it to the ancestor if + // no child is found. + // NOTE: Includes existingAncestor itself, so should be non-empty. + newRows = newRows.filter(row => existingAncestor.contains(row)); + } + // We have lost the selectedRow, so we select a new row. We want to try + // and find the element that exists both in the new rows and in the old + // rows, that directly preceded the previously selected row. We then + // want to select the next visible row that follows this found element + // in the new rows. + // If rows were replaced with new rows, this will select the first of + // the new rows. + // If rows were simply removed, this will select the next row that was + // not removed. + let beforeIndex = -1; + for (let i = oldSelectedIndex; i >= 0; i--) { + beforeIndex = this._rowsData.findIndex( + entry => entry.row == oldRowsData[i].row + ); + if (beforeIndex >= 0) { + break; + } + } + // Start from just after the found item, or 0 if none were found + // (beforeIndex == -1), find the next visible item. Otherwise we default + // to selecting the last row. + let selectRow = newRows[newRows.length - 1]; + for (let i = beforeIndex + 1; i < this._rowsData.length; i++) { + if (newRows.includes(this._rowsData[i].row)) { + selectRow = this._rowsData[i].row; + break; + } + } + this.selectedRow = selectRow; + }); + + /** + * Set the role attribute and classes for all descendants of the widget. + */ + _initRows() { + let descendantItems = this.querySelectorAll("li"); + let descendantLists = this.querySelectorAll("ol, ul"); + + for (let i = 0; i < descendantItems.length; i++) { + let row = descendantItems[i]; + row.setAttribute("role", this.isTree ? "treeitem" : "option"); + if ( + i + 1 < descendantItems.length && + row.contains(descendantItems[i + 1]) + ) { + row.classList.add("children"); + if (this.isTree) { + row.setAttribute( + "aria-expanded", + !row.classList.contains("collapsed") + ); + } + } else { + row.classList.remove("children"); + row.classList.remove("collapsed"); + row.removeAttribute("aria-expanded"); + } + row.setAttribute("aria-selected", row.classList.contains("selected")); + } + + if (this.isTree) { + for (let list of descendantLists) { + list.setAttribute("role", "group"); + } + } + + for (let childList of this.querySelectorAll( + "li.collapsed > :is(ol, ul)" + )) { + childList.style.height = "0"; + } + } + + /** + * Every visible row. Rows with collapsed ancestors are not included. + * + * @type {HTMLLIElement[]} + */ + get rows() { + return [...this.querySelectorAll("li:not(.unselectable)")].filter( + row => { + let collapsed = row.parentNode.closest("li.collapsed"); + if (collapsed && this.contains(collapsed)) { + return false; + } + return true; + } + ); + } + + /** + * The number of visible rows. + * + * @type {integer} + */ + get rowCount() { + return this.rows.length; + } + + /** + * Clamps `index` to a value between 0 and `rowCount - 1`. + * + * @param {integer} index + * @returns {integer} + */ + _clampIndex(index) { + if (index >= this.rowCount) { + return this.rowCount - 1; + } + if (index < 0) { + return 0; + } + return index; + } + + /** + * Ensures that the row at `index` is on the screen. + * + * @param {integer} index + */ + scrollToIndex(index) { + this.getRowAtIndex(index)?.scrollIntoView({ block: "nearest" }); + } + + /** + * Returns the row element at `index` or null if `index` is out of range. + * + * @param {integer} index + * @returns {HTMLLIElement?} + */ + getRowAtIndex(index) { + return this.rows[index]; + } + + /** + * The index of the selected row. If there are no rows, the value is -1. + * Otherwise, should always have a value between 0 and `rowCount - 1`. + * It is set to 0 in `connectedCallback` if there are rows. + * + * @type {integer} + */ + get selectedIndex() { + return this.rows.findIndex(row => row == this.selectedRow); + } + + set selectedIndex(index) { + index = this._clampIndex(index); + this.selectedRow = this.getRowAtIndex(index); + } + + /** + * The selected and focused item, or null if there is none. + * + * @type {?HTMLLIElement} + */ + get selectedRow() { + return this._selectedRow; + } + + set selectedRow(row) { + if (row == this._selectedRow) { + return; + } + + if (this._selectedRow) { + this._selectedRow.classList.remove("selected"); + this._selectedRow.setAttribute("aria-selected", "false"); + } + + this._selectedRow = row ?? null; + if (row) { + row.classList.add("selected"); + row.setAttribute("aria-selected", "true"); + this.setAttribute("aria-activedescendant", row.id); + row.firstElementChild.scrollIntoView({ block: "nearest" }); + } else { + this.removeAttribute("aria-activedescendant"); + } + + this.dispatchEvent(new CustomEvent("select")); + } + + /** + * Collapses the row at `index` if it can be collapsed. If the selected + * row is a descendant of the collapsing row, selection is moved to the + * collapsing row. + * + * @param {integer} index + */ + collapseRowAtIndex(index) { + this.collapseRow(this.getRowAtIndex(index)); + } + + /** + * Expands the row at `index` if it can be expanded. + * + * @param {integer} index + */ + expandRowAtIndex(index) { + this.expandRow(this.getRowAtIndex(index)); + } + + /** + * Collapses the row if it can be collapsed. If the selected row is a + * descendant of the collapsing row, selection is moved to the collapsing + * row. + * + * @param {HTMLLIElement} row - The row to collapse. + */ + collapseRow(row) { + if ( + row.classList.contains("children") && + !row.classList.contains("collapsed") + ) { + if (row.contains(this.selectedRow)) { + this.selectedRow = row; + } + row.classList.add("collapsed"); + if (this.isTree) { + row.setAttribute("aria-expanded", "false"); + } + row.dispatchEvent(new CustomEvent("collapsed", { bubbles: true })); + this._animateCollapseRow(row); + } + } + + /** + * Expands the row if it can be expanded. + * + * @param {HTMLLIElement} row - The row to expand. + */ + expandRow(row) { + if ( + row.classList.contains("children") && + row.classList.contains("collapsed") + ) { + row.classList.remove("collapsed"); + if (this.isTree) { + row.setAttribute("aria-expanded", "true"); + } + row.dispatchEvent(new CustomEvent("expanded", { bubbles: true })); + this._animateExpandRow(row); + } + } + + /** + * Animate the collapsing of a row containing child items. + * + * @param {HTMLLIElement} row - The parent row element. + */ + _animateCollapseRow(row) { + let childList = row.querySelector("ol, ul"); + + if (reducedMotionMedia.matches) { + if (childList) { + childList.style.height = "0"; + } + return; + } + + let childListHeight = childList.scrollHeight; + + let animation = childList.animate( + [{ height: `${childListHeight}px` }, { height: "0" }], + { + duration: ANIMATION_DURATION_MS, + easing: ANIMATION_EASING, + fill: "both", + } + ); + animation.onfinish = () => { + childList.style.height = "0"; + animation.cancel(); + }; + } + + /** + * Animate the revealing of a row containing child items. + * + * @param {HTMLLIElement} row - The parent row element. + */ + _animateExpandRow(row) { + let childList = row.querySelector("ol, ul"); + + if (reducedMotionMedia.matches) { + if (childList) { + childList.style.height = null; + } + return; + } + + let childListHeight = childList.scrollHeight; + + let animation = childList.animate( + [{ height: "0" }, { height: `${childListHeight}px` }], + { + duration: ANIMATION_DURATION_MS, + easing: ANIMATION_EASING, + fill: "both", + } + ); + animation.onfinish = () => { + childList.style.height = null; + animation.cancel(); + }; + } + }; + + /** + * An unordered list with the functionality of TreeListboxMixin. + */ + class TreeListbox extends TreeListboxMixin(HTMLUListElement) {} + customElements.define("tree-listbox", TreeListbox, { extends: "ul" }); + + /** + * An ordered list with the functionality of TreeListboxMixin, plus the + * ability to re-order the top-level list by drag-and-drop/Alt+Up/Alt+Down. + * + * This class fires an "ordered" event when the list is re-ordered. + * + * @note All children of this element should be HTML. If there are XUL + * elements, you're gonna have a bad time. + */ + class OrderableTreeListbox extends TreeListboxMixin(HTMLOListElement) { + connectedCallback() { + super.connectedCallback(); + this.setAttribute("is", "orderable-tree-listbox"); + + this.addEventListener("dragstart", this); + window.addEventListener("dragover", this); + window.addEventListener("drop", this); + window.addEventListener("dragend", this); + } + + handleEvent(event) { + super.handleEvent(event); + + switch (event.type) { + case "dragstart": + this._onDragStart(event); + break; + case "dragover": + this._onDragOver(event); + break; + case "drop": + this._onDrop(event); + break; + case "dragend": + this._onDragEnd(event); + break; + } + } + + /** + * An array of all top-level rows that can be reordered. Override this + * getter to prevent reordering of one or more rows. + * + * @note So far this has only been used to prevent the last row being + * moved. Any other use is untested. It likely also works for rows at + * the top of the list. + * + * @returns {HTMLLIElement[]} + */ + get _orderableChildren() { + return [...this.children]; + } + + _onKeyDown(event) { + super._onKeyDown(event); + + if ( + !event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + !["ArrowUp", "ArrowDown"].includes(event.key) + ) { + return; + } + + let row = this.selectedRow; + if (!row || row.parentElement != this) { + return; + } + + let otherRow; + if (event.key == "ArrowUp") { + otherRow = row.previousElementSibling; + } else { + otherRow = row.nextElementSibling; + } + if (!otherRow) { + return; + } + + // Check we can move these rows. + let orderable = this._orderableChildren; + if (!orderable.includes(row) || !orderable.includes(otherRow)) { + return; + } + + let reducedMotion = reducedMotionMedia.matches; + + this.scrollToIndex(this.rows.indexOf(otherRow)); + + // Temporarily disconnect the mutation observer to stop it changing things. + this._mutationObserver.disconnect(); + if (event.key == "ArrowUp") { + if (!reducedMotion) { + let { top: otherTop } = otherRow.getBoundingClientRect(); + let { top: rowTop, height: rowHeight } = row.getBoundingClientRect(); + OrderableTreeListbox._animateTranslation(otherRow, 0 - rowHeight); + OrderableTreeListbox._animateTranslation(row, rowTop - otherTop); + } + this.insertBefore(row, otherRow); + } else { + if (!reducedMotion) { + let { top: otherTop, height: otherHeight } = + otherRow.getBoundingClientRect(); + let { top: rowTop, height: rowHeight } = row.getBoundingClientRect(); + OrderableTreeListbox._animateTranslation(otherRow, rowHeight); + OrderableTreeListbox._animateTranslation( + row, + rowTop - otherTop - otherHeight + rowHeight + ); + } + this.insertBefore(row, otherRow.nextElementSibling); + } + this._mutationObserver.observe(this, { subtree: true, childList: true }); + + // Rows moved. + this.domChanged(); + this.dispatchEvent(new CustomEvent("ordered", { detail: row })); + } + + _onDragStart(event) { + if (!event.target.closest("[draggable]")) { + // This shouldn't be necessary, but is?! + event.preventDefault(); + return; + } + + let orderable = this._orderableChildren; + if (orderable.length < 2) { + return; + } + + for (let topLevelRow of orderable) { + if (topLevelRow.contains(event.target)) { + let rect = topLevelRow.getBoundingClientRect(); + this._dragInfo = { + row: topLevelRow, + // How far can we move `topLevelRow` upwards? + min: orderable[0].getBoundingClientRect().top - rect.top, + // How far can we move `topLevelRow` downwards? + max: + orderable[orderable.length - 1].getBoundingClientRect().bottom - + rect.bottom, + // Where is the pointer relative to the scroll box of the list? + // (Not quite, the Y position of `this` is not removed, but we'd + // only have to do the same where this value is used.) + scrollY: event.clientY + this.scrollTop, + // Where is the pointer relative to `topLevelRow`? + offsetY: event.clientY - rect.top, + }; + topLevelRow.classList.add("dragging"); + + // Prevent `topLevelRow` being used as the drag image. We don't + // really want any drag image, but there's no way to not have one. + event.dataTransfer.setDragImage(document.createElement("img"), 0, 0); + return; + } + } + } + + _onDragOver(event) { + if (!this._dragInfo) { + return; + } + + let { row, min, max, scrollY, offsetY } = this._dragInfo; + + // Move `row` with the mouse pointer. + let dragY = Math.min( + max, + Math.max(min, event.clientY + this.scrollTop - scrollY) + ); + row.style.transform = `translateY(${dragY}px)`; + + let thisRect = this.getBoundingClientRect(); + // How much space is there above `row`? We'll see how many rows fit in + // the space and put `row` in after them. + let spaceAbove = Math.max( + 0, + event.clientY + this.scrollTop - offsetY - thisRect.top + ); + // The height of all rows seen in the loop so far. + let totalHeight = 0; + // If we've looped past the row being dragged. + let afterDraggedRow = false; + // The row before where a drop would take place. If null, drop would + // happen at the start of the list. + let targetRow = null; + + for (let topLevelRow of this._orderableChildren) { + if (topLevelRow == row) { + afterDraggedRow = true; + continue; + } + + let rect = topLevelRow.getBoundingClientRect(); + let enoughSpace = spaceAbove > totalHeight + rect.height / 2; + + let multiplier = 0; + if (enoughSpace) { + if (afterDraggedRow) { + multiplier = -1; + } + targetRow = topLevelRow; + } else if (!afterDraggedRow) { + multiplier = 1; + } + OrderableTreeListbox._transitionTranslation( + topLevelRow, + multiplier * row.clientHeight + ); + + totalHeight += rect.height; + } + + this._dragInfo.dropTarget = targetRow; + event.preventDefault(); + } + + _onDrop(event) { + if (!this._dragInfo) { + return; + } + + let { row, dropTarget } = this._dragInfo; + + let targetRow; + if (dropTarget) { + targetRow = dropTarget.nextElementSibling; + } else { + targetRow = this.firstElementChild; + } + + event.preventDefault(); + // Temporarily disconnect the mutation observer to stop it changing things. + this._mutationObserver.disconnect(); + this.insertBefore(row, targetRow); + this._mutationObserver.observe(this, { subtree: true, childList: true }); + // Rows moved. + this.domChanged(); + this.dispatchEvent(new CustomEvent("ordered", { detail: row })); + } + + _onDragEnd(event) { + if (!this._dragInfo) { + return; + } + + this._dragInfo.row.classList.remove("dragging"); + delete this._dragInfo; + + for (let topLevelRow of this.children) { + topLevelRow.style.transition = null; + topLevelRow.style.transform = null; + } + } + + /** + * Used to animate a real change in the order. The element is moved in the + * DOM, then the animation makes it appear to move from the original + * position to the new position + * + * @param {HTMLLIElement} element - The row to animate. + * @param {number} from - Original Y position of the element relative to + * its current position. + */ + static _animateTranslation(element, from) { + let animation = element.animate( + [ + { transform: `translateY(${from}px)` }, + { transform: "translateY(0px)" }, + ], + { + duration: ANIMATION_DURATION_MS, + fill: "both", + } + ); + animation.onfinish = () => animation.cancel(); + } + + /** + * Used to simulate a change in the order. The element remains in the same + * DOM position. + * + * @param {HTMLLIElement} element - The row to animate. + * @param {number} to - The new Y position of the element after animation. + */ + static _transitionTranslation(element, to) { + if (!reducedMotionMedia.matches) { + element.style.transition = `transform ${ANIMATION_DURATION_MS}ms`; + } + element.style.transform = to ? `translateY(${to}px)` : null; + } + } + customElements.define("orderable-tree-listbox", OrderableTreeListbox, { + extends: "ol", + }); +} diff --git a/comm/mail/base/content/widgets/tree-selection.mjs b/comm/mail/base/content/widgets/tree-selection.mjs new file mode 100644 index 0000000000..022af7316e --- /dev/null +++ b/comm/mail/base/content/widgets/tree-selection.mjs @@ -0,0 +1,744 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This implementation attempts to mimic the behavior of nsTreeSelection. In + * a few cases, this leads to potentially confusing actions. I attempt to note + * when we are doing this and why we do it. + * + * Unit test is in mail/base/test/unit/test_treeSelection.js + */ +export class TreeSelection { + QueryInterface = ChromeUtils.generateQI(["nsITreeSelection"]); + + /** + * The current XULTreeElement, appropriately QueryInterfaced. May be null. + */ + _tree; + + /** + * Where the focus rectangle (that little dotted thing) shows up. Just + * because something is focused does not mean it is actually selected. + */ + _currentIndex; + /** + * The view index where the shift is anchored when it is not (conceptually) + * the same as _currentIndex. This only happens when you perform a ranged + * selection. In that case, the start index of the ranged selection becomes + * the shift pivot (and the _currentIndex becomes the end of the ranged + * selection.) + * It gets cleared whenever the selection changes and it's not the result of + * a call to rangedSelect. + */ + _shiftSelectPivot; + /** + * A list of [lowIndexInclusive, highIndexInclusive] non-overlapping, + * non-adjacent 'tuples' sort in ascending order. + */ + _ranges; + /** + * The number of currently selected rows. + */ + _count; + + // In the case of the stand-alone message window, there's no tree, but + // there's a view. + _view; + + /** + * A set of indices we think is invalid. + */ + _invalidIndices; + + constructor(tree) { + this._tree = tree; + + this._currentIndex = null; + this._shiftSelectPivot = null; + this._ranges = []; + this._count = 0; + this._invalidIndices = new Set(); + + this._selectEventsSuppressed = false; + } + + /** + * Mark the currently selected rows as invalid. + */ + _invalidateSelection() { + for (let [low, high] of this._ranges) { + for (let i = low; i <= high; i++) { + this._invalidIndices.add(i); + } + } + } + + /** + * Call `invalidateRow` on the tree for each row we think is invalid. + */ + _doInvalidateRows() { + if (this.selectEventsSuppressed) { + return; + } + if (this._tree) { + for (let i of this._invalidIndices) { + this._tree.invalidateRow(i); + } + } + this._invalidIndices.clear(); + } + + /** + * Call `invalidateRange` on the tree. + * + * @param {number} startIndex - The first index to invalidate. + * @param {number?} endIndex - The last index to invalidate. If not given, + * defaults to the index of the last row. + */ + _doInvalidateRange(startIndex, endIndex) { + let noEndIndex = endIndex === undefined; + if (noEndIndex) { + if (!this._view || this.view.rowCount == 0) { + this._doInvalidateAll(); + return; + } + endIndex = this._view.rowCount - 1; + } + if (this._tree) { + this._tree.invalidateRange(startIndex, endIndex); + } + for (let i of this._invalidIndices) { + if (i >= startIndex && (noEndIndex || i <= endIndex)) { + this._invalidIndices.delete(i); + } + } + } + + /** + * Call `invalidate` on the tree. + */ + _doInvalidateAll() { + if (this._tree) { + this._tree.invalidate(); + } + this._invalidIndices.clear(); + } + + get tree() { + return this._tree; + } + set tree(tree) { + this._tree = tree; + } + + get view() { + return this._view; + } + set view(view) { + this._view = view; + } + /** + * Although the nsITreeSelection documentation doesn't say, what this method + * is supposed to do is check if the seltype attribute on the XUL tree is any + * of the following: "single" (only a single row may be selected at a time, + * "cell" (a single cell may be selected), or "text" (the row gets selected + * but only the primary column shows up as selected.) + * + * @returns false because we don't support single-selection. + */ + get single() { + return false; + } + + _updateCount() { + this._count = 0; + for (let [low, high] of this._ranges) { + this._count += high - low + 1; + } + } + + get count() { + return this._count; + } + + isSelected(viewIndex) { + for (let [low, high] of this._ranges) { + if (viewIndex >= low && viewIndex <= high) { + return true; + } + } + return false; + } + + /** + * Select the given row. It does nothing if that row was already selected. + */ + select(viewIndex) { + this._invalidateSelection(); + // current index will provide our effective shift pivot + this._shiftSelectPivot = null; + this._currentIndex = viewIndex != -1 ? viewIndex : null; + + if (this._count == 1 && this._ranges[0][0] == viewIndex) { + return; + } + + if (viewIndex >= 0) { + this._count = 1; + this._ranges = [[viewIndex, viewIndex]]; + this._invalidIndices.add(viewIndex); + } else { + this._count = 0; + this._ranges = []; + } + + this._doInvalidateRows(); + this._fireSelectionChanged(); + } + + timedSelect(index, delay) { + throw new Error("We do not implement timed selection."); + } + + toggleSelect(index) { + this._currentIndex = index; + // If nothing's selected, select index + if (this._count == 0) { + this._count = 1; + this._ranges = [[index, index]]; + } else { + let added = false; + for (let [iTupe, [low, high]] of this._ranges.entries()) { + // below the range? add it to the existing range or create a new one + if (index < low) { + this._count++; + // is it just below an existing range? (range fusion only happens in the + // high case, not here.) + if (index == low - 1) { + this._ranges[iTupe][0] = index; + added = true; + break; + } + // then it gets its own range + this._ranges.splice(iTupe, 0, [index, index]); + added = true; + break; + } + // in the range? will need to either nuke, shrink, or split the range to + // remove it + if (index >= low && index <= high) { + this._count--; + if (index == low && index == high) { + // nuke + this._ranges.splice(iTupe, 1); + } else if (index == low) { + // lower shrink + this._ranges[iTupe][0] = index + 1; + } else if (index == high) { + // upper shrink + this._ranges[iTupe][1] = index - 1; + } else { + // split + this._ranges.splice(iTupe, 1, [low, index - 1], [index + 1, high]); + } + added = true; + break; + } + // just above the range? fuse into the range, and possibly the next + // range up. + if (index == high + 1) { + this._count++; + // see if there is another range and there was just a gap of one between + // the two ranges. + if ( + iTupe + 1 < this._ranges.length && + this._ranges[iTupe + 1][0] == index + 1 + ) { + // yes, merge the ranges + this._ranges.splice(iTupe, 2, [low, this._ranges[iTupe + 1][1]]); + added = true; + break; + } + // nope, no merge required, just update the range + this._ranges[iTupe][1] = index; + added = true; + break; + } + // otherwise we need to keep going + } + + if (!added) { + this._count++; + this._ranges.push([index, index]); + } + } + + this._invalidIndices.add(index); + this._doInvalidateRows(); + this._fireSelectionChanged(); + } + + /** + * @param rangeStart If omitted, it implies a shift-selection is happening, + * in which case we use _shiftSelectPivot as the start if we have it, + * _currentIndex if we don't, and if we somehow didn't have a + * _currentIndex, we use the range end. + * @param rangeEnd Just the inclusive end of the range. + * @param augment Does this set a new selection or should it be merged with + * the existing selection? + */ + rangedSelect(rangeStart, rangeEnd, augment) { + if (rangeStart == -1) { + if (this._shiftSelectPivot != null) { + rangeStart = this._shiftSelectPivot; + } else if (this._currentIndex != null) { + rangeStart = this._currentIndex; + } else { + rangeStart = rangeEnd; + } + } + + this._shiftSelectPivot = rangeStart; + this._currentIndex = rangeEnd; + + // enforce our ordering constraint for our ranges + if (rangeStart > rangeEnd) { + [rangeStart, rangeEnd] = [rangeEnd, rangeStart]; + } + + // if we're not augmenting, then this is really easy. + if (!augment) { + this._invalidateSelection(); + + this._count = rangeEnd - rangeStart + 1; + this._ranges = [[rangeStart, rangeEnd]]; + + for (let i = rangeStart; i <= rangeEnd; i++) { + this._invalidIndices.add(i); + } + + this._doInvalidateRows(); + this._fireSelectionChanged(); + return; + } + + // Iterate over our existing set of ranges, finding the 'range' of ranges + // that our new range overlaps or simply obviates. + // Overlap variables track blocks we need to keep some part of, Nuke + // variables are for blocks that get spliced out. For our purposes, all + // overlap blocks are also nuke blocks. + let lowOverlap, lowNuke, highNuke, highOverlap; + // in case there is no overlap, also figure an insertionPoint + let insertionPoint = this._ranges.length; // default to the end + for (let [iTupe, [low, high]] of this._ranges.entries()) { + // If it's completely include the range, it should be nuked + if (rangeStart <= low && rangeEnd >= high) { + if (lowNuke == null) { + // only the first one we see is the low one + lowNuke = iTupe; + } + highNuke = iTupe; + } + // If our new range start is inside a range or is adjacent, it's overlap + if ( + rangeStart >= low - 1 && + rangeStart <= high + 1 && + lowOverlap == null + ) { + lowOverlap = lowNuke = highNuke = iTupe; + } + // If our new range ends inside a range or is adjacent, it's overlap + if (rangeEnd >= low - 1 && rangeEnd <= high + 1) { + highOverlap = highNuke = iTupe; + if (lowNuke == null) { + lowNuke = iTupe; + } + } + + // we're done when no more overlap is possible + if (rangeEnd < low) { + insertionPoint = iTupe; + break; + } + } + + if (lowOverlap != null) { + rangeStart = Math.min(rangeStart, this._ranges[lowOverlap][0]); + } + if (highOverlap != null) { + rangeEnd = Math.max(rangeEnd, this._ranges[highOverlap][1]); + } + if (lowNuke != null) { + this._ranges.splice(lowNuke, highNuke - lowNuke + 1, [ + rangeStart, + rangeEnd, + ]); + } else { + this._ranges.splice(insertionPoint, 0, [rangeStart, rangeEnd]); + } + for (let i = rangeStart; i <= rangeEnd; i++) { + this._invalidIndices.add(i); + } + + this._updateCount(); + this._doInvalidateRows(); + this._fireSelectionChanged(); + } + + /** + * This is basically RangedSelect but without insertion of a new range and we + * don't need to worry about adjacency. + * Oddly, nsTreeSelection doesn't fire a selection changed event here... + */ + clearRange(rangeStart, rangeEnd) { + // Iterate over our existing set of ranges, finding the 'range' of ranges + // that our clear range overlaps or simply obviates. + // Overlap variables track blocks we need to keep some part of, Nuke + // variables are for blocks that get spliced out. For our purposes, all + // overlap blocks are also nuke blocks. + let lowOverlap, lowNuke, highNuke, highOverlap; + for (let [iTupe, [low, high]] of this._ranges.entries()) { + // If we completely include the range, it should be nuked + if (rangeStart <= low && rangeEnd >= high) { + if (lowNuke == null) { + // only the first one we see is the low one + lowNuke = iTupe; + } + highNuke = iTupe; + } + // If our new range start is inside a range, it's nuke and maybe overlap + if (rangeStart >= low && rangeStart <= high && lowNuke == null) { + lowNuke = highNuke = iTupe; + // it's only overlap if we don't match at the low end + if (rangeStart > low) { + lowOverlap = iTupe; + } + } + // If our new range ends inside a range, it's nuke and maybe overlap + if (rangeEnd >= low && rangeEnd <= high) { + highNuke = iTupe; + // it's only overlap if we don't match at the high end + if (rangeEnd < high) { + highOverlap = iTupe; + } + if (lowNuke == null) { + lowNuke = iTupe; + } + } + + // we're done when no more overlap is possible + if (rangeEnd < low) { + break; + } + } + // nothing to do since there's nothing to nuke + if (lowNuke == null) { + return; + } + let args = [lowNuke, highNuke - lowNuke + 1]; + if (lowOverlap != null) { + args.push([this._ranges[lowOverlap][0], rangeStart - 1]); + } + if (highOverlap != null) { + args.push([rangeEnd + 1, this._ranges[highOverlap][1]]); + } + this._ranges.splice.apply(this._ranges, args); + + for (let i = rangeStart; i <= rangeEnd; i++) { + this._invalidIndices.add(i); + } + + this._updateCount(); + this._doInvalidateRows(); + // note! nsTreeSelection doesn't fire a selection changed event, so neither + // do we, but it seems like we should + } + + /** + * nsTreeSelection always fires a select notification when the range is + * cleared, even if there is no effective chance in selection. + */ + clearSelection() { + this._invalidateSelection(); + this._shiftSelectPivot = null; + this._count = 0; + this._ranges = []; + + this._doInvalidateRows(); + this._fireSelectionChanged(); + } + + /** + * Select all with no rows is a no-op, otherwise we select all and notify. + */ + selectAll() { + if (!this._view) { + return; + } + + let view = this._view; + let rowCount = view.rowCount; + + // no-ops-ville + if (!rowCount) { + return; + } + + this._count = rowCount; + this._ranges = [[0, rowCount - 1]]; + + this._doInvalidateAll(); + this._fireSelectionChanged(); + } + + getRangeCount() { + return this._ranges.length; + } + getRangeAt(rangeIndex, minObj, maxObj) { + if (rangeIndex < 0 || rangeIndex >= this._ranges.length) { + throw new Error("Try a real range index next time."); + } + [minObj.value, maxObj.value] = this._ranges[rangeIndex]; + } + + /** + * Helper method to adjust points in the face of row additions/removal. + * + * @param point The point, null if there isn't one, or an index otherwise. + * @param deltaAt The row at which the change is happening. + * @param delta The number of rows added if positive, or the (negative) + * number of rows removed. + */ + _adjustPoint(point, deltaAt, delta) { + // if there is no point, no change + if (point == null) { + return point; + } + // if the point is before the change, no change + if (point < deltaAt) { + return point; + } + // if it's a deletion and it includes the point, clear it + if (delta < 0 && point >= deltaAt && point + delta < deltaAt) { + return null; + } + // (else) the point is at/after the change, compensate + return point + delta; + } + /** + * Find the index of the range, if any, that contains the given index, and + * the index at which to insert a range if one does not exist. + * + * @returns A tuple containing: 1) the index if there is one, null otherwise, + * 2) the index at which to insert a range that would contain the point. + */ + _findRangeContainingRow(index) { + for (let [iTupe, [low, high]] of this._ranges.entries()) { + if (index >= low && index <= high) { + return [iTupe, iTupe]; + } + if (index < low) { + return [null, iTupe]; + } + } + return [null, this._ranges.length]; + } + + /** + * When present, a list of calls made to adjustSelection. See + * |logAdjustSelectionForReplay| and |replayAdjustSelectionLog|. + */ + _adjustSelectionLog = null; + /** + * Start logging calls to adjustSelection made against this instance. You + * would do this because you are replacing an existing selection object + * with this instance for the purposes of creating a transient selection. + * Of course, you want the original selection object to be up-to-date when + * you go to put it back, so then you can call replayAdjustSelectionLog + * with that selection object and everything will be peachy. + */ + logAdjustSelectionForReplay() { + this._adjustSelectionLog = []; + } + /** + * Stop logging calls to adjustSelection and replay the existing log against + * selection. + * + * @param selection {nsITreeSelection}. + */ + replayAdjustSelectionLog(selection) { + if (this._adjustSelectionLog.length) { + // Temporarily disable selection events because adjustSelection is going + // to generate an event each time otherwise, and better 1 event than + // many. + selection.selectEventsSuppressed = true; + for (let [index, count] of this._adjustSelectionLog) { + selection.adjustSelection(index, count); + } + selection.selectEventsSuppressed = false; + } + this._adjustSelectionLog = null; + } + + adjustSelection(index, count) { + // nothing to do if there is no actual change + if (!count) { + return; + } + + if (this._adjustSelectionLog) { + this._adjustSelectionLog.push([index, count]); + } + + // adjust our points + this._shiftSelectPivot = this._adjustPoint( + this._shiftSelectPivot, + index, + count + ); + this._currentIndex = this._adjustPoint(this._currentIndex, index, count); + + // If we are adding rows, we want to split any range at index and then + // translate all of the ranges above that point up. + if (count > 0) { + let [iContain, iInsert] = this._findRangeContainingRow(index); + if (iContain != null) { + let [low, high] = this._ranges[iContain]; + // if it is the low value, we just want to shift the range entirely, so + // do nothing (and keep iInsert pointing at it for translation) + // if it is not the low value, then there must be at least two values so + // we should split it and only translate the new/upper block + if (index != low) { + this._ranges.splice(iContain, 1, [low, index - 1], [index, high]); + iInsert++; + } + } + // now translate everything from iInsert on up + for (let iTrans = iInsert; iTrans < this._ranges.length; iTrans++) { + let [low, high] = this._ranges[iTrans]; + this._ranges[iTrans] = [low + count, high + count]; + } + // invalidate and fire selection change notice + this._doInvalidateRange(index); + this._fireSelectionChanged(); + return; + } + + // If we are removing rows, we are basically clearing the range that is + // getting deleted and translating everyone above the remaining point + // downwards. The one trick is we may have to merge the lowest translated + // block. + let saveSuppress = this.selectEventsSuppressed; + this.selectEventsSuppressed = true; + this.clearRange(index, index - count - 1); + // translate + let iTrans = this._findRangeContainingRow(index)[1]; + for (; iTrans < this._ranges.length; iTrans++) { + let [low, high] = this._ranges[iTrans]; + // for the first range, low may be below the index, in which case it + // should not get translated + this._ranges[iTrans] = [low >= index ? low + count : low, high + count]; + } + // we may have to merge the lowest translated block because it may now be + // adjacent to the previous block + if ( + iTrans > 0 && + iTrans < this._ranges.length && + this._ranges[iTrans - 1][1] == this._ranges[iTrans][0] + ) { + this._ranges[iTrans - 1][1] = this._ranges[iTrans][1]; + this._ranges.splice(iTrans, 1); + } + + this._doInvalidateRange(index); + this.selectEventsSuppressed = saveSuppress; + } + + get selectEventsSuppressed() { + return this._selectEventsSuppressed; + } + /** + * Control whether selection events are suppressed. For consistency with + * nsTreeSelection, we always generate a selection event when a value of + * false is assigned, even if the value was already false. + */ + set selectEventsSuppressed(suppress) { + if (this._selectEventsSuppressed == suppress) { + return; + } + + this._selectEventsSuppressed = suppress; + if (!suppress) { + this._fireSelectionChanged(); + } + } + + /** + * Note that we bypass any XUL "onselect" handler that may exist and go + * straight to the view. If you have a tree, you shouldn't be using us, + * so this seems aboot right. + */ + _fireSelectionChanged() { + // don't fire if we are suppressed; we will fire when un-suppressed + if (this.selectEventsSuppressed) { + return; + } + let view = this._tree?.view ?? this._view; + + // We might not have a view if we're in the middle of setting up things + view?.selectionChanged(); + } + + get currentIndex() { + if (this._currentIndex == null) { + return -1; + } + return this._currentIndex; + } + /** + * Sets the current index. Other than updating the variable, this just + * invalidates the tree row if we have a tree. + * The real selection object would send a DOM event we don't care about. + */ + set currentIndex(index) { + if (index == this.currentIndex) { + return; + } + + this._invalidateSelection(); + this._currentIndex = index != -1 ? index : null; + this._invalidIndices.add(index); + this._doInvalidateRows(); + } + + get shiftSelectPivot() { + return this._shiftSelectPivot != null ? this._shiftSelectPivot : -1; + } + + /* + * Functions after this aren't part of the nsITreeSelection interface. + */ + + /** + * Duplicate this selection on another nsITreeSelection. This is useful + * when you would like to discard this selection for a real tree selection. + * We assume that both selections are for the same tree. + * + * @note We don't transfer the correct shiftSelectPivot over. + * @note This will fire a selectionChanged event on the tree view. + * + * @param selection an nsITreeSelection to duplicate this selection onto + */ + duplicateSelection(selection) { + selection.selectEventsSuppressed = true; + selection.clearSelection(); + for (let [iTupe, [low, high]] of this._ranges.entries()) { + selection.rangedSelect(low, high, iTupe > 0); + } + + selection.currentIndex = this.currentIndex; + // This will fire a selectionChanged event + selection.selectEventsSuppressed = false; + } +} diff --git a/comm/mail/base/content/widgets/tree-view.mjs b/comm/mail/base/content/widgets/tree-view.mjs new file mode 100644 index 0000000000..aef622fa27 --- /dev/null +++ b/comm/mail/base/content/widgets/tree-view.mjs @@ -0,0 +1,2633 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +import { TreeSelection } from "chrome://messenger/content/tree-selection.mjs"; + +// Account for the mac OS accelerator key variation. +// Use these strings to check keyboard event properties. +const accelKeyName = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"; +const otherKeyName = AppConstants.platform == "macosx" ? "ctrlKey" : "metaKey"; + +const ANIMATION_DURATION_MS = 200; +const reducedMotionMedia = matchMedia("(prefers-reduced-motion)"); + +/** + * Main tree view container that takes care of generating the main scrollable + * DIV and the tree table. + */ +class TreeView extends HTMLElement { + static observedAttributes = ["rows"]; + + /** + * The number of rows on either side to keep of the visible area to keep in + * memory in order to avoid visible blank spaces while the user scrolls. + * + * This member is visible for testing and should not be used outside of this + * class in production code. + * + * @type {integer} + */ + _toleranceSize = 0; + + /** + * Set the size of the tolerance buffer based on the number of rows which can + * be visible at once. + */ + #calculateToleranceBufferSize() { + this._toleranceSize = this.#calculateVisibleRowCount() * 2; + } + + /** + * Index of the first row that exists in the DOM. Includes rows in the + * tolerance buffer if they have been added. + * + * @type {integer} + */ + #firstBufferRowIndex = 0; + + /** + * Index of the last row that exists in the DOM. Includes rows in the + * tolerance buffer if they have been added. + * + * @type {integer} + */ + #lastBufferRowIndex = 0; + + /** + * Index of the first visible row. + * + * @type {integer} + */ + #firstVisibleRowIndex = 0; + + /** + * Index of the last visible row. + * + * @type {integer} + */ + #lastVisibleRowIndex = 0; + + /** + * Row indices mapped to the row elements that exist in the DOM. + * + * @type {Map<integer, HTMLTableRowElement>} + */ + _rows = new Map(); + + /** + * The current view. + * + * @type {nsITreeView} + */ + _view = null; + + /** + * The current selection. + * + * @type {nsITreeSelection} + */ + _selection = null; + + /** + * The function storing the timeout callback for the delayed select feature in + * order to clear it when not needed. + * + * @type {integer} + */ + _selectTimeout = null; + + /** + * A handle to the callback to fill the buffer when we aren't busy painting. + * + * @type {number} + */ + #bufferFillIdleCallbackHandle = null; + + /** + * The virtualized table containing our rows. + * + * @type {TreeViewTable} + */ + table = null; + + /** + * An event to fire to indicate the work of filling the buffer is complete. + * This will fire once both visible and tolerance rows are ready. It will also + * fire if no change to the buffer is required. + * + * This member is visible in order to provide a reliable indicator to tests + * that all expected rows should be in place. It should not be used in + * production code. + * + * @type {Event} + */ + _rowBufferReadyEvent = null; + + /** + * Fire the provided event, if any, in order to indicate that any necessary + * buffer modification work is complete, including if no work is necessary. + */ + #dispatchRowBufferReadyEvent() { + // Don't fire if we're currently waiting on buffer fills; let the callback + // do that when it's finished. + if (this._rowBufferReadyEvent && !this.#bufferFillIdleCallbackHandle) { + this.dispatchEvent(this._rowBufferReadyEvent); + } + } + + /** + * Determine the height of the visible row area, excluding any chrome which + * covers elements. + * + * WARNING: This may cause synchronous reflow if used after modifying the DOM. + * + * @returns {integer} - The height of the area into which visible rows are + * rendered. + */ + #calculateVisibleHeight() { + // Account for the table header height in a sticky position above the body. + return this.clientHeight - this.table.header.clientHeight; + } + + /** + * Determine how many rows are visible in the client presently. + * + * WARNING: This may cause synchronous reflow if used after modifying the DOM. + * + * @returns {integer} - The number of visible or partly-visible rows. + */ + #calculateVisibleRowCount() { + return Math.ceil( + this.#calculateVisibleHeight() / this._rowElementClass.ROW_HEIGHT + ); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + // Prevent this element from being part of the roving tab focus since we + // handle that independently for the TreeViewTableBody and we don't want any + // interference from this. + this.tabIndex = -1; + this.classList.add("tree-view-scrollable-container"); + + this.table = document.createElement("table", { is: "tree-view-table" }); + this.appendChild(this.table); + + this.placeholder = this.querySelector(`slot[name="placeholders"]`); + + this.addEventListener("scroll", this); + + let lastHeight = 0; + this.resizeObserver = new ResizeObserver(entries => { + // The width of the table isn't important to virtualizing the table. Skip + // updating if the height hasn't changed. + if (this.clientHeight == lastHeight) { + this.#dispatchRowBufferReadyEvent(); + return; + } + + if (!this._rowElementClass) { + this.#dispatchRowBufferReadyEvent(); + return; + } + + // The number of rows in the tolerance buffer is based on the number of + // rows which can be visible. Update it. + this.#calculateToleranceBufferSize(); + + // There's not much point in reducing the number of rows on resize. Scroll + // height remains the same and we can retain the extra rows in the buffer. + if (this.clientHeight > lastHeight) { + this._ensureVisibleRowsAreDisplayed(); + } else { + this.#dispatchRowBufferReadyEvent(); + } + + lastHeight = this.clientHeight; + }); + this.resizeObserver.observe(this); + } + + disconnectedCallback() { + this.#resetRowBuffer(); + this.resizeObserver.disconnect(); + } + + attributeChangedCallback(attrName, oldValue, newValue) { + this._rowElementName = newValue || "tree-view-table-row"; + this._rowElementClass = customElements.get(this._rowElementName); + + this.#calculateToleranceBufferSize(); + + if (this._view) { + this.reset(); + } + } + + handleEvent(event) { + switch (event.type) { + case "keyup": { + if ( + ["Tab", "F6"].includes(event.key) && + this.currentIndex == -1 && + this._view?.rowCount + ) { + let selectionChanged = false; + if (this.selectedIndex == -1) { + this._selection.select(0); + selectionChanged = true; + } + this.currentIndex = this.selectedIndex; + if (selectionChanged) { + this.onSelectionChanged(); + } + } + break; + } + case "click": { + if (event.button !== 0) { + return; + } + + let row = event.target.closest(`tr[is="${this._rowElementName}"]`); + if (!row) { + return; + } + + let index = row.index; + + if (event.target.classList.contains("tree-button-thread")) { + if (this._view.isContainerOpen(index)) { + let children = 0; + for ( + let i = index + 1; + i < this._view.rowCount && this._view.getLevel(i) > 0; + i++ + ) { + children++; + } + this._selectRange(index, index + children, event[accelKeyName]); + } else { + let addedRows = this.expandRowAtIndex(index); + this._selectRange(index, index + addedRows, event[accelKeyName]); + } + this.table.body.focus(); + return; + } + + if (this._view.isContainer(index) && event.target.closest(".twisty")) { + if (this._view.isContainerOpen(index)) { + this.collapseRowAtIndex(index); + } else { + let addedRows = this.expandRowAtIndex(index); + this.scrollToIndex( + index + Math.min(addedRows, this.#calculateVisibleRowCount() - 1) + ); + } + this.table.body.focus(); + return; + } + + // Handle the click as a CTRL extension if it happens on the checkbox + // image inside the selection column. + if (event.target.classList.contains("tree-view-row-select-checkbox")) { + if (event.shiftKey) { + this._selectRange(-1, index, event[accelKeyName]); + } else { + this._toggleSelected(index); + } + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-request-delete")) { + this.table.body.dispatchEvent( + new CustomEvent("request-delete", { + bubbles: true, + detail: { + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-flag")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-flag", { + bubbles: true, + detail: { + isFlagged: row.dataset.properties.includes("flagged"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-unread")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-unread", { + bubbles: true, + detail: { + isUnread: row.dataset.properties.includes("unread"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-spam")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-spam", { + bubbles: true, + detail: { + isJunk: row.dataset.properties.split(" ").includes("junk"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event[accelKeyName] && !event.shiftKey) { + this._toggleSelected(index); + } else if (event.shiftKey) { + this._selectRange(-1, index, event[accelKeyName]); + } else { + this._selectSingle(index); + } + + this.table.body.focus(); + break; + } + case "keydown": { + if (event.altKey || event[otherKeyName]) { + return; + } + + let currentIndex = this.currentIndex == -1 ? 0 : this.currentIndex; + let newIndex; + switch (event.key) { + case "ArrowUp": + newIndex = currentIndex - 1; + break; + case "ArrowDown": + newIndex = currentIndex + 1; + break; + case "ArrowLeft": + case "ArrowRight": { + event.preventDefault(); + if (this.currentIndex == -1) { + return; + } + let isArrowRight = event.key == "ArrowRight"; + let isRTL = this.matches(":dir(rtl)"); + if (isArrowRight == isRTL) { + // Collapse action. + let currentLevel = this._view.getLevel(this.currentIndex); + if (this._view.isContainerOpen(this.currentIndex)) { + this.collapseRowAtIndex(this.currentIndex); + return; + } else if (currentLevel == 0) { + return; + } + + let parentIndex = this._view.getParentIndex(this.currentIndex); + if (parentIndex != -1) { + newIndex = parentIndex; + } + } else if (this._view.isContainer(this.currentIndex)) { + // Expand action. + if (!this._view.isContainerOpen(this.currentIndex)) { + let addedRows = this.expandRowAtIndex(this.currentIndex); + this.scrollToIndex( + this.currentIndex + + Math.min(addedRows, this.#calculateVisibleRowCount() - 1) + ); + } else { + newIndex = this.currentIndex + 1; + } + } + if (newIndex != undefined) { + this._selectSingle(newIndex); + } + return; + } + case "Home": + newIndex = 0; + break; + case "End": + newIndex = this._view.rowCount - 1; + break; + case "PageUp": + newIndex = Math.max( + 0, + currentIndex - this.#calculateVisibleRowCount() + ); + break; + case "PageDown": + newIndex = Math.min( + this._view.rowCount - 1, + currentIndex + this.#calculateVisibleRowCount() + ); + break; + } + + if (newIndex != undefined) { + newIndex = this._clampIndex(newIndex); + if (newIndex != null) { + if (event[accelKeyName] && !event.shiftKey) { + // Change focus, but not selection. + this.currentIndex = newIndex; + } else if (event.shiftKey) { + this._selectRange(-1, newIndex, event[accelKeyName]); + } else { + this._selectSingle(newIndex, true); + } + } + event.preventDefault(); + return; + } + + // Space bar keystroke selection toggling. + if (event.key == " " && this.currentIndex != -1) { + // Don't do anything if we're on macOS and the target row is already + // selected. + if ( + AppConstants.platform == "macosx" && + this._selection.isSelected(this.currentIndex) + ) { + return; + } + + // Handle the macOS exception of toggling the selection with only + // the space bar since CMD+Space is captured by the OS. + if (event[accelKeyName] || AppConstants.platform == "macosx") { + this._toggleSelected(this.currentIndex); + event.preventDefault(); + } else if (!this._selection.isSelected(this.currentIndex)) { + // The target row is not currently selected. + this._selectSingle(this.currentIndex, true); + event.preventDefault(); + } + } + break; + } + case "scroll": + this._ensureVisibleRowsAreDisplayed(); + break; + } + } + + /** + * The current view for this list. + * + * @type {nsITreeView} + */ + get view() { + return this._view; + } + + set view(view) { + this._selection = null; + if (this._view) { + this._view.setTree(null); + this._view.selection = null; + } + if (this._selection) { + this._selection.view = null; + } + + this._view = view; + if (view) { + try { + this._selection = new TreeSelection(); + this._selection.tree = this; + this._selection.view = view; + + view.selection = this._selection; + view.setTree(this); + } catch (ex) { + // This isn't a XULTreeElement, and we can't make it one, so if the + // `setTree` call crosses XPCOM, an exception will be thrown. + if (ex.result != Cr.NS_ERROR_XPC_BAD_CONVERT_JS) { + throw ex; + } + } + } + + // Clear the height of the top spacer to avoid confusing + // `_ensureVisibleRowsAreDisplayed`. + this.table.spacerTop.setHeight(0); + this.reset(); + + this.dispatchEvent(new CustomEvent("viewchange")); + } + + /** + * Set the colspan of the spacer row cells. + * + * @param {int} count - The amount of visible columns. + */ + setSpacersColspan(count) { + // Add an extra column if the table is editable to account for the column + // picker column. + if (this.parentNode.editable) { + count++; + } + this.table.spacerTop.setColspan(count); + this.table.spacerBottom.setColspan(count); + } + + /** + * Clear all rows from the buffer, empty the table body, and reset spacers. + */ + #resetRowBuffer() { + this.#cancelToleranceFillCallback(); + this.table.body.replaceChildren(); + this._rows.clear(); + this.#firstBufferRowIndex = 0; + this.#lastBufferRowIndex = 0; + this.#firstVisibleRowIndex = 0; + + // Set the height of the bottom spacer to account for the now-missing rows. + // We want to ensure that the overall scroll height does not decrease. + // Otherwise, we may lose our scroll position and cause unnecessary + // scrolling. However, we don't always want to change the height of the top + // spacer for the same reason. + let rowCount = this._view?.rowCount ?? 0; + this.table.spacerBottom.setHeight( + rowCount * this._rowElementClass.ROW_HEIGHT + ); + } + + /** + * Clear all rows from the list and create them again. + */ + reset() { + this.#resetRowBuffer(); + this._ensureVisibleRowsAreDisplayed(); + } + + /** + * Updates all existing rows in place, without removing all the rows and + * starting again. This can be used if the row element class hasn't changed + * and its `index` setter is capable of handling any modifications required. + */ + invalidate() { + this.invalidateRange(this.#firstBufferRowIndex, this.#lastBufferRowIndex); + } + + /** + * Perform the actions necessary to invalidate the specified row. Implemented + * separately to allow {@link invalidateRange} to handle testing event fires + * on its own. + * + * @param {integer} index + */ + #doInvalidateRow(index) { + const rowCount = this._view?.rowCount ?? 0; + let row = this.getRowAtIndex(index); + if (row) { + if (index >= rowCount) { + this._removeRowAtIndex(index); + } else { + row.index = index; + row.selected = this._selection.isSelected(index); + } + } else if ( + index >= this.#firstBufferRowIndex && + index <= Math.min(rowCount - 1, this.#lastBufferRowIndex) + ) { + this._addRowAtIndex(index); + } + } + + /** + * Invalidate the rows between `startIndex` and `endIndex`. + * + * @param {integer} startIndex + * @param {integer} endIndex + */ + invalidateRange(startIndex, endIndex) { + for ( + let index = Math.max(startIndex, this.#firstBufferRowIndex), + last = Math.min(endIndex, this.#lastBufferRowIndex); + index <= last; + index++ + ) { + this.#doInvalidateRow(index); + } + this._ensureVisibleRowsAreDisplayed(); + } + + /** + * Invalidate the row at `index` in place. If `index` refers to a row that + * should exist but doesn't (because the row count increased), adds a row. + * If `index` refers to a row that does exist but shouldn't (because the + * row count decreased), removes it. + * + * @param {integer} index + */ + invalidateRow(index) { + this.#doInvalidateRow(index); + this.#dispatchRowBufferReadyEvent(); + } + + /** + * A contiguous range, inclusive of both extremes. + * + * @typedef InclusiveRange + * @property {integer} first - The inclusive start of the range. + * @property {integer} last - The inclusive end of the range. + */ + + /** + * Calculate the range of rows we wish to have in a filled tolerance buffer + * based on a given range of visible rows. + * + * @param {integer} firstVisibleRow - The first visible row in the range. + * @param {integer} lastVisibleRow - The last visible row in the range. + * @param {integer} dataRowCount - The total number of available rows in the + * source data. + * @returns {InclusiveRange} - The full range of the desired buffer. + */ + #calculateDesiredBufferRange(firstVisibleRow, lastVisibleRow, dataRowCount) { + const desiredRowRange = {}; + + desiredRowRange.first = Math.max(firstVisibleRow - this._toleranceSize, 0); + desiredRowRange.last = Math.min( + lastVisibleRow + this._toleranceSize, + dataRowCount - 1 + ); + + return desiredRowRange; + } + + #createToleranceFillCallback() { + // Don't schedule a new buffer fill callback if we already have one. + if (!this.#bufferFillIdleCallbackHandle) { + this.#bufferFillIdleCallbackHandle = requestIdleCallback(deadline => + this.#fillToleranceBuffer(deadline) + ); + } + } + + #cancelToleranceFillCallback() { + cancelIdleCallback(this.#bufferFillIdleCallbackHandle); + this.#bufferFillIdleCallbackHandle = null; + } + + /** + * Fill the buffer with tolerance rows above and below the visible rows. + * + * As fetching data and modifying the DOM is expensive, this is intended to be + * run within an idle callback and includes management of the idle callback + * handle and creation of further callbacks if work is not completed. + * + * @param {IdleDeadline} deadline - A deadline object for fetching the + * remaining time in the idle tick. + */ + #fillToleranceBuffer(deadline) { + this.#bufferFillIdleCallbackHandle = null; + + const rowCount = this._view?.rowCount ?? 0; + if (!rowCount) { + return; + } + + const bufferRange = this.#calculateDesiredBufferRange( + this.#firstVisibleRowIndex, + this.#lastVisibleRowIndex, + rowCount + ); + + // Set the amount of time to leave in the deadline to fill another row. In + // order to cooperatively schedule work, we shouldn't overrun the time + // allotted for the idle tick. This value should be set such that it leaves + // enough time to perform another row fill and adjust the relevant spacer + // while doing the maximal amount of work per callback. + const MS_TO_LEAVE_PER_FILL = 1.25; + + // Fill in the beginning of the buffer. + if (bufferRange.first < this.#firstBufferRowIndex) { + for ( + let i = this.#firstBufferRowIndex - 1; + i >= bufferRange.first && + deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL; + i-- + ) { + this._addRowAtIndex(i, this.table.body.firstElementChild); + + // Update as we go in case we need to wait for the next idle. + this.#firstBufferRowIndex = i; + } + + // Adjust the height of the top spacer to account for the new rows we've + // added. + this.table.spacerTop.setHeight( + this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT + ); + + // If we haven't completed the work of filling the tolerance buffer, + // schedule a new job to do so. + if (this.#firstBufferRowIndex != bufferRange.first) { + this.#createToleranceFillCallback(); + return; + } + } + + // Fill in the end of the buffer. + if (bufferRange.last > this.#lastBufferRowIndex) { + for ( + let i = this.#lastBufferRowIndex + 1; + i <= bufferRange.last && + deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL; + i++ + ) { + this._addRowAtIndex(i); + + // Update as we go in case we need to wait for the next idle. + this.#lastBufferRowIndex = i; + } + + // Adjust the height of the bottom spacer to account for the new rows + // we've added. + this.table.spacerBottom.setHeight( + (rowCount - 1 - this.#lastBufferRowIndex) * + this._rowElementClass.ROW_HEIGHT + ); + + // If we haven't completed the work of filling the tolerance buffer, + // schedule a new job to do so. + if (this.#lastBufferRowIndex != bufferRange.last) { + this.#createToleranceFillCallback(); + return; + } + } + + // Notify tests that we have finished work. + this.#dispatchRowBufferReadyEvent(); + } + + /** + * The calculated ranges which determine the shape of the row buffer at + * various stages of processing. + * + * @typedef RowBufferRanges + * @property {InclusiveRange} visibleRows - The range of rows which should be + * displayed to the user. + * @property {integer?} pruneBefore - The index of the row before which any + * additional rows should be discarded. + * @property {integer?} pruneAfter - The index of the row after which any + * additional rows should be discarded. + * @property {InclusiveRange} finalizedRows - The range of rows which should + * exist in the row buffer after any additions and removals have been made. + */ + + /** + * Calculate the values necessary for building the list of visible rows and + * retaining any rows in the buffer which fall inside the desired tolerance + * and form a contiguous range with the visible rows. + * + * WARNING: This function makes calculations based on existing DOM dimensions. + * Do not use it after you have modified the DOM. + * + * @returns {RowBufferRanges} + */ + #calculateRowBufferRanges(dataRowCount) { + /** @type {RowBufferRanges} */ + const ranges = { + visibleRows: {}, + pruneBefore: null, + pruneAfter: null, + finalizedRows: {}, + }; + + // We adjust the row buffer in several stages. First, we'll use the new + // scroll position to determine the boundaries of the buffer. Then, we'll + // create and add any new rows which are necessary to fit the new + // boundaries. Next, we prune rows added in previous scrolls which now fall + // outside the boundaries. Finally, we recalculate the height of the spacers + // which position the visible rows within the rendered area. + ranges.visibleRows.first = Math.max( + Math.floor(this.scrollTop / this._rowElementClass.ROW_HEIGHT), + 0 + ); + + const lastPossibleVisibleRow = Math.ceil( + (this.scrollTop + this.#calculateVisibleHeight()) / + this._rowElementClass.ROW_HEIGHT + ); + + ranges.visibleRows.last = + Math.min(lastPossibleVisibleRow, dataRowCount) - 1; + + // Determine the number of rows desired in the tolerance buffer in order to + // determine whether there are any that we can save. + const desiredRowRange = this.#calculateDesiredBufferRange( + ranges.visibleRows.first, + ranges.visibleRows.last, + dataRowCount + ); + + // Determine which rows are no longer wanted in the buffer. If we've + // scrolled past the previous visible rows, it's possible that the tolerance + // buffer will still contain some rows we'd like to have in the buffer. Note + // that we insist on a contiguous range of rows in the buffer to simplify + // determining which rows exist and appropriately spacing the viewport. + if (this.#lastBufferRowIndex < ranges.visibleRows.first) { + // There is a discontiguity between the visible rows and anything that's + // in the buffer. Prune everything before the visible rows. + ranges.pruneBefore = ranges.visibleRows.first; + ranges.finalizedRows.first = ranges.visibleRows.first; + } else if (this.#firstBufferRowIndex < desiredRowRange.first) { + // The range of rows in the buffer overlaps the start of the visible rows, + // but there are rows outside of the desired buffer as well. Prune them. + ranges.pruneBefore = desiredRowRange.first; + ranges.finalizedRows.first = desiredRowRange.first; + } else { + // Determine the beginning of the finalized buffer based on whether the + // buffer contains rows before the start of the visible rows. + ranges.finalizedRows.first = Math.min( + ranges.visibleRows.first, + this.#firstBufferRowIndex + ); + } + + if (this.#firstBufferRowIndex > ranges.visibleRows.last) { + // There is a discontiguity between the visible rows and anything that's + // in the buffer. Prune everything after the visible rows. + ranges.pruneAfter = ranges.visibleRows.last; + ranges.finalizedRows.last = ranges.visibleRows.last; + } else if (this.#lastBufferRowIndex > desiredRowRange.last) { + // The range of rows in the buffer overlaps the end of the visible rows, + // but there are rows outside of the desired buffer as well. Prune them. + ranges.pruneAfter = desiredRowRange.last; + ranges.finalizedRows.last = desiredRowRange.last; + } else { + // Determine the end of the finalized buffer based on whether the buffer + // contains rows after the end of the visible rows. + ranges.finalizedRows.last = Math.max( + ranges.visibleRows.last, + this.#lastBufferRowIndex + ); + } + + return ranges; + } + + /** + * Display the table rows which should be shown in the visible area and + * request filling of the tolerance buffer when idle. + */ + _ensureVisibleRowsAreDisplayed() { + this.#cancelToleranceFillCallback(); + + let rowCount = this._view?.rowCount ?? 0; + this.placeholder?.classList.toggle("show", !rowCount); + + if (!rowCount || this.#calculateVisibleRowCount() == 0) { + return; + } + + if (this.scrollTop > rowCount * this._rowElementClass.ROW_HEIGHT) { + // Beyond the end of the list. We're about to scroll anyway, so clear + // everything out and wait for it to happen. Don't call `invalidate` here, + // or you'll end up in an infinite loop. + this.table.spacerTop.setHeight(0); + this.#resetRowBuffer(); + return; + } + + const ranges = this.#calculateRowBufferRanges(rowCount); + + // *WARNING: Do not request any DOM dimensions after this point. Modifying + // the DOM will invalidate existing calculations and any additional requests + // will cause synchronous reflow. + + // Add a row if the table is empty. Either we're initializing or have + // invalidated the tree, and the next two steps pass over row zero if there + // are no rows already in the buffer. + if ( + this.#lastBufferRowIndex == 0 && + this.table.body.childElementCount == 0 && + ranges.visibleRows.first == 0 + ) { + this._addRowAtIndex(0); + } + + // Expand the row buffer to include newly-visible rows which weren't already + // visible or preloaded in the tolerance buffer. + + const earliestMissingEndRowIdx = Math.max( + this.#lastBufferRowIndex + 1, + ranges.visibleRows.first + ); + for (let i = earliestMissingEndRowIdx; i <= ranges.visibleRows.last; i++) { + // We are missing rows at the end of the buffer. Either the last row of + // the existing buffer lies within the range of visible rows and we begin + // there, or the entire range of visible rows occurs after the end of the + // buffer and we fill in from the start. + this._addRowAtIndex(i); + } + + const latestMissingStartRowIdx = Math.min( + this.#firstBufferRowIndex - 1, + ranges.visibleRows.last + ); + for (let i = latestMissingStartRowIdx; i >= ranges.visibleRows.first; i--) { + // We are missing rows at the start of the buffer. We'll add them working + // backwards so that we can prepend. Either the first row of the existing + // buffer lies within the range of visible rows and we begin there, or the + // entire range of visible rows occurs before the end of the buffer and we + // fill in from the end. + this._addRowAtIndex(i, this.table.body.firstElementChild); + } + + // Prune the buffer of any rows outside of our desired buffer range. + if (ranges.pruneBefore !== null) { + const pruneBeforeRow = this.getRowAtIndex(ranges.pruneBefore); + let rowToPrune = pruneBeforeRow.previousElementSibling; + while (rowToPrune) { + this._removeRowAtIndex(rowToPrune.index); + rowToPrune = pruneBeforeRow.previousElementSibling; + } + } + + if (ranges.pruneAfter !== null) { + const pruneAfterRow = this.getRowAtIndex(ranges.pruneAfter); + let rowToPrune = pruneAfterRow.nextElementSibling; + while (rowToPrune) { + this._removeRowAtIndex(rowToPrune.index); + rowToPrune = pruneAfterRow.nextElementSibling; + } + } + + // Set the indices of the new first and last rows in the DOM. They may come + // from the tolerance buffer if we haven't exhausted it. + this.#firstBufferRowIndex = ranges.finalizedRows.first; + this.#lastBufferRowIndex = ranges.finalizedRows.last; + + this.#firstVisibleRowIndex = ranges.visibleRows.first; + this.#lastVisibleRowIndex = ranges.visibleRows.last; + + // Adjust the height of the spacers to ensure that visible rows fall within + // the visible space and the overall scroll height is correct. + this.table.spacerTop.setHeight( + this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT + ); + + this.table.spacerBottom.setHeight( + (rowCount - this.#lastBufferRowIndex - 1) * + this._rowElementClass.ROW_HEIGHT + ); + + // The row buffer ideally contains some tolerance on either end to avoid + // creating rows and fetching data for them during short scrolls. However, + // actually creating those rows can be expensive, and during a long scroll + // we may throw them away very quickly. To save the expense, only fill the + // buffer while idle. + + this.#createToleranceFillCallback(); + } + + /** + * Index of the first visible or partly visible row. + * + * @returns {integer} + */ + getFirstVisibleIndex() { + return this.#firstVisibleRowIndex; + } + + /** + * Index of the last visible or partly visible row. + * + * @returns {integer} + */ + getLastVisibleIndex() { + return this.#lastVisibleRowIndex; + } + + /** + * Ensures that the row at `index` is on the screen. + * + * @param {integer} index + */ + scrollToIndex(index, instant = false) { + const rowCount = this._view.rowCount; + if (rowCount == 0) { + // If there are no rows, make sure we're scrolled to the top. + this.scrollTo({ top: 0, behavior: "instant" }); + return; + } + if (index < 0 || index >= rowCount) { + // Bad index. Report, and do nothing. + console.error( + `<${this.localName} id="${this.id}"> tried to scroll to a row that doesn't exist: ${index}` + ); + return; + } + + const topOfRow = this._rowElementClass.ROW_HEIGHT * index; + let scrollTop = this.scrollTop; + const visibleHeight = this.#calculateVisibleHeight(); + const behavior = instant ? "instant" : "auto"; + + // Scroll up to the row. + if (topOfRow < scrollTop) { + this.scrollTo({ top: topOfRow, behavior }); + return; + } + + // Scroll down to the row. + const bottomOfRow = topOfRow + this._rowElementClass.ROW_HEIGHT; + if (bottomOfRow > scrollTop + visibleHeight) { + this.scrollTo({ top: bottomOfRow - visibleHeight, behavior }); + return; + } + + // Call `scrollTo` even if the row is in view, to stop any earlier smooth + // scrolling that might be happening. + this.scrollTo({ top: this.scrollTop, behavior }); + } + + /** + * Updates the list to reflect added or removed rows. + * + * @param {integer} index - The position in the existing list where rows were + * added or removed. + * @param {integer} delta - The change in number of rows; positive if rows + * were added and negative if rows were removed. + */ + rowCountChanged(index, delta) { + if (!this._selection) { + return; + } + + this._selection.adjustSelection(index, delta); + this._updateCurrentIndexClasses(); + this.dispatchEvent(new CustomEvent("rowcountchange")); + } + + /** + * Clamps `index` to a value between 0 and `rowCount - 1`. + * + * @param {integer} index + * @returns {integer} + */ + _clampIndex(index) { + if (!this._view.rowCount) { + return null; + } + if (index < 0) { + return 0; + } + if (index >= this._view.rowCount) { + return this._view.rowCount - 1; + } + return index; + } + + /** + * Creates a new row element and adds it to the DOM. + * + * @param {integer} index + */ + _addRowAtIndex(index, before = null) { + let row = document.createElement("tr", { is: this._rowElementName }); + row.setAttribute("is", this._rowElementName); + this.table.body.insertBefore(row, before); + row.setAttribute("aria-setsize", this._view.rowCount); + row.style.height = `${this._rowElementClass.ROW_HEIGHT}px`; + row.index = index; + if (this._selection?.isSelected(index)) { + row.selected = true; + } + if (this.currentIndex === index) { + row.classList.add("current"); + this.table.body.setAttribute("aria-activedescendant", row.id); + } + this._rows.set(index, row); + } + + /** + * Removes the row element at `index` from the DOM and map of rows. + * + * @param {integer} index + */ + _removeRowAtIndex(index) { + const row = this._rows.get(index); + row?.remove(); + this._rows.delete(index); + } + + /** + * Returns the row element at `index` or null if `index` is out of range. + * + * @param {integer} index + * @returns {HTMLTableRowElement} + */ + getRowAtIndex(index) { + return this._rows.get(index) ?? null; + } + + /** + * Collapses the row at `index` if it can be collapsed. If the selected + * row is a descendant of the collapsing row, selection is moved to the + * collapsing row. + * + * @param {integer} index + */ + collapseRowAtIndex(index) { + if (!this._view.isContainerOpen(index)) { + return; + } + + // If the selected row is going to be collapsed, move the selection. + // Even if the row to be collapsed is already selected, set + // selectIndex to ensure currentIndex also points to the correct row. + let selectedIndex = this.selectedIndex; + while (selectedIndex >= index) { + if (selectedIndex == index) { + this.selectedIndex = index; + break; + } + selectedIndex = this._view.getParentIndex(selectedIndex); + } + + // Check if the view calls rowCountChanged. If it didn't, we'll have to + // call it. This can happen if the view has no reference to the tree. + let rowCountDidChange = false; + let rowCountChangeListener = () => { + rowCountDidChange = true; + }; + + let countBefore = this._view.rowCount; + this.addEventListener("rowcountchange", rowCountChangeListener); + this._view.toggleOpenState(index); + this.removeEventListener("rowcountchange", rowCountChangeListener); + let countAdded = this._view.rowCount - countBefore; + + // Call rowCountChanged, if it hasn't already happened. + if (countAdded && !rowCountDidChange) { + this.invalidateRow(index); + this.rowCountChanged(index + 1, countAdded); + } + + this.dispatchEvent( + new CustomEvent("collapsed", { bubbles: true, detail: index }) + ); + } + + /** + * Expands the row at `index` if it can be expanded. + * + * @param {integer} index + * @returns {integer} - the number of rows that were added + */ + expandRowAtIndex(index) { + if (!this._view.isContainer(index) || this._view.isContainerOpen(index)) { + return 0; + } + + // Check if the view calls rowCountChanged. If it didn't, we'll have to + // call it. This can happen if the view has no reference to the tree. + let rowCountDidChange = false; + let rowCountChangeListener = () => { + rowCountDidChange = true; + }; + + let countBefore = this._view.rowCount; + this.addEventListener("rowcountchange", rowCountChangeListener); + this._view.toggleOpenState(index); + this.removeEventListener("rowcountchange", rowCountChangeListener); + let countAdded = this._view.rowCount - countBefore; + + // Call rowCountChanged, if it hasn't already happened. + if (countAdded && !rowCountDidChange) { + this.invalidateRow(index); + this.rowCountChanged(index + 1, countAdded); + } + + this.dispatchEvent( + new CustomEvent("expanded", { bubbles: true, detail: index }) + ); + + return countAdded; + } + + /** + * In a selection, index of the most-recently-selected row. + * + * @type {integer} + */ + get currentIndex() { + return this._selection ? this._selection.currentIndex : -1; + } + + set currentIndex(index) { + if (!this._view) { + return; + } + + this._selection.currentIndex = index; + this._updateCurrentIndexClasses(); + if (index >= 0 && index < this._view.rowCount) { + this.scrollToIndex(index); + } + } + + /** + * Set the "current" class on the right row, and remove it from all other rows. + */ + _updateCurrentIndexClasses() { + let index = this.currentIndex; + + for (let row of this.querySelectorAll( + `tr[is="${this._rowElementName}"].current` + )) { + row.classList.remove("current"); + } + + if (!this._view || index < 0 || index > this._view.rowCount - 1) { + this.table.body.removeAttribute("aria-activedescendant"); + return; + } + + let row = this.getRowAtIndex(index); + if (row) { + // We need to clear the attribute in order to let screen readers know that + // a new message has been selected even if the ID is identical. For + // example when we delete the first message with ID 0, the next message + // becomes ID 0 itself. Therefore the attribute wouldn't trigger the screen + // reader to announce the new message without being cleared first. + this.table.body.removeAttribute("aria-activedescendant"); + row.classList.add("current"); + this.table.body.setAttribute("aria-activedescendant", row.id); + } + } + + /** + * Select and focus the given index. + * + * @param {integer} index - The index to select. + * @param {boolean} [delaySelect=false] - If the selection should be delayed. + */ + _selectSingle(index, delaySelect = false) { + let changeSelection = + this._selection.count != 1 || !this._selection.isSelected(index); + // Update the TreeSelection selection to trigger a tree reset(). + if (changeSelection) { + this._selection.select(index); + } + this.currentIndex = index; + if (changeSelection) { + this.onSelectionChanged(delaySelect); + } + } + + /** + * Start or extend a range selection to the given index and focus it. + * + * @param {number} start - Start index of selection. -1 for current index. + * @param {number} end - End index of selection. + * @param {boolean} extend[false] - If the new selection range should extend + * the current selection. + */ + _selectRange(start, end, extend = false) { + this._selection.rangedSelect(start, end, extend); + this.currentIndex = start == -1 ? end : start; + this.onSelectionChanged(); + } + + /** + * Toggle the selection state at the given index and focus it. + * + * @param {integer} index - The index to toggle. + */ + _toggleSelected(index) { + this._selection.toggleSelect(index); + // We hack the internals of the TreeSelection to clear the + // shiftSelectPivot. + this._selection._shiftSelectPivot = null; + this.currentIndex = index; + this.onSelectionChanged(); + } + + /** + * Select all rows. + */ + selectAll() { + this._selection.selectAll(); + this.onSelectionChanged(); + } + + /** + * Toggle between selecting all rows or none, depending on the current + * selection state. + */ + toggleSelectAll() { + if (!this.selectedIndices.length) { + const index = this._view.rowCount - 1; + this._selection.selectAll(); + this.currentIndex = index; + } else { + this._selection.clearSelection(); + } + // Make sure the body is focused when the selection is changed as + // clicking on the "select all" header button steals the focus. + this.focus(); + + this.onSelectionChanged(); + } + + /** + * In a selection, index of the most-recently-selected row. + * + * @type {integer} + */ + get selectedIndex() { + if (!this._selection?.count) { + return -1; + } + + let min = {}; + this._selection.getRangeAt(0, min, {}); + return min.value; + } + + set selectedIndex(index) { + this._selectSingle(index); + } + + /** + * An array of the indices of all selected rows. + * + * @type {integer[]} + */ + get selectedIndices() { + let indices = []; + let rangeCount = this._selection.getRangeCount(); + + for (let range = 0; range < rangeCount; range++) { + let min = {}; + let max = {}; + this._selection.getRangeAt(range, min, max); + + if (min.value == -1) { + continue; + } + + for (let index = min.value; index <= max.value; index++) { + indices.push(index); + } + } + + return indices; + } + + set selectedIndices(indices) { + this.setSelectedIndices(indices); + } + + /** + * An array of the indices of all selected rows. + * + * @param {integer[]} indices + * @param {boolean} suppressEvent - Prevent a "select" event firing. + */ + setSelectedIndices(indices, suppressEvent) { + this._selection.clearSelection(); + for (let index of indices) { + this._selection.toggleSelect(index); + } + this.onSelectionChanged(false, suppressEvent); + } + + /** + * Changes the selection state of the row at `index`. + * + * @param {integer} index + * @param {boolean?} selected - if set, set the selection state to this + * value, otherwise toggle the current state + * @param {boolean?} suppressEvent - prevent a "select" event firing + * @returns {boolean} - if the index is now selected + */ + toggleSelectionAtIndex(index, selected, suppressEvent) { + let wasSelected = this._selection.isSelected(index); + if (selected === undefined) { + selected = !wasSelected; + } + + if (selected != wasSelected) { + this._selection.toggleSelect(index); + this.onSelectionChanged(false, suppressEvent); + } + + return selected; + } + + /** + * Loop through all available child elements of the placeholder slot and + * show those that are needed. + * @param {array} idsToShow - Array of ids to show. + */ + updatePlaceholders(idsToShow) { + for (let element of this.placeholder.children) { + element.hidden = !idsToShow.includes(element.id); + } + } + + /** + * Update the classes on the table element to reflect the current selection + * state, and dispatch an event to allow implementations to handle the + * change in the selection state. + * + * @param {boolean} [delaySelect=false] - If the selection should be delayed. + * @param {boolean} [suppressEvent=false] - Prevent a "select" event firing. + */ + onSelectionChanged(delaySelect = false, suppressEvent = false) { + const selectedCount = this._selection.count; + const allSelected = selectedCount == this._view.rowCount; + + this.table.classList.toggle("all-selected", allSelected); + this.table.classList.toggle("some-selected", !allSelected && selectedCount); + this.table.classList.toggle("multi-selected", selectedCount > 1); + + const selectButton = this.table.querySelector(".tree-view-header-select"); + // Some implementations might not use a select header. + if (selectButton) { + // Only mark the `select` button as "checked" if all rows are selected. + selectButton.toggleAttribute("aria-checked", allSelected); + // The default action for the header button is to deselect all messages + // if even one message is currently selected. + document.l10n.setAttributes( + selectButton, + selectedCount + ? "threadpane-column-header-deselect-all" + : "threadpane-column-header-select-all" + ); + } + + if (suppressEvent) { + return; + } + + // No need to handle a delayed select if not required. + if (!delaySelect) { + // Clear the timeout in case something was still running. + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this.dispatchEvent(new CustomEvent("select", { bubbles: true })); + return; + } + + let delay = this.dataset.selectDelay || 50; + if (delay != -1) { + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this._selectTimeout = window.setTimeout(() => { + this.dispatchEvent(new CustomEvent("select", { bubbles: true })); + this._selectTimeout = null; + }, delay); + } + } +} +customElements.define("tree-view", TreeView); + +/** + * The main <table> element containing the thead and the TreeViewTableBody + * tbody. This class is used to expose all those methods and custom events + * needed at the implementation level. + */ +class TreeViewTable extends HTMLTableElement { + /** + * The array of objects containing the data to generate the needed columns. + * Keep this public so child elements can access it if needed. + * @type {Array} + */ + columns; + + /** + * The header row for the table. + * + * @type {TreeViewTableHeader} + */ + header; + + /** + * Array containing the IDs of templates holding menu items to dynamically add + * to the menupopup of the column picker. + * @type {Array} + */ + popupMenuTemplates = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table"); + this.classList.add("tree-table"); + + // Use a fragment to append child elements to later add them all at once + // to the DOM. Performance is important. + const fragment = new DocumentFragment(); + + this.header = document.createElement("thead", { + is: "tree-view-table-header", + }); + fragment.append(this.header); + + this.spacerTop = document.createElement("tbody", { + is: "tree-view-table-spacer", + }); + fragment.append(this.spacerTop); + + this.body = document.createElement("tbody", { + is: "tree-view-table-body", + }); + fragment.append(this.body); + + this.spacerBottom = document.createElement("tbody", { + is: "tree-view-table-spacer", + }); + fragment.append(this.spacerBottom); + + this.append(fragment); + } + + /** + * If set to TRUE before generating the columns, the table will + * automatically create a column picker in the table header. + * + * @type {boolean} + */ + set editable(val) { + this.dataset.editable = val; + } + + get editable() { + return this.dataset.editable === "true"; + } + + /** + * Set the id attribute of the TreeViewTableBody for selection and styling + * purpose. + * + * @param {string} id - The string ID to set. + */ + setBodyID(id) { + this.body.id = id; + } + + setPopupMenuTemplates(array) { + this.popupMenuTemplates = array; + } + + /** + * Set the columns array of the table. This should only be used during + * initialization and any following change to the columns visibility should + * be handled via the updateColumns() method. + * + * @param {Array} columns - The array of columns to generate. + */ + setColumns(columns) { + this.columns = columns; + this.header.setColumns(); + this.#updateView(); + } + + /** + * Update the currently visible columns. + * + * @param {Array} columns - The array of columns to update. It should match + * the original array set via the setColumn() method since this method will + * only update the column visibility without generating new elements. + */ + updateColumns(columns) { + this.columns = columns; + this.#updateView(); + } + + /** + * Store the newly resized column values in the xul store. + * + * @param {string} url - The document URL used to store the values. + * @param {DOMEvent} event - The dom event bubbling from the resized action. + */ + setColumnsWidths(url, event) { + const width = event.detail.splitter.width; + const column = event.detail.column; + const newValue = `${column}:${width}`; + let newWidths; + + // Check if we already have stored values and update it if so. + let columnsWidths = Services.xulStore.getValue(url, "columns", "widths"); + if (columnsWidths) { + let updated = false; + columnsWidths = columnsWidths.split(","); + for (let index = 0; index < columnsWidths.length; index++) { + const cw = columnsWidths[index].split(":"); + if (cw[0] == column) { + cw[1] = width; + updated = true; + columnsWidths[index] = newValue; + break; + } + } + // Push the new value into the array if we didn't have an existing one. + if (!updated) { + columnsWidths.push(newValue); + } + newWidths = columnsWidths.join(","); + } else { + newWidths = newValue; + } + + // Store the values as a plain string with the current format: + // columnID:width,columnID:width,... + Services.xulStore.setValue(url, "columns", "widths", newWidths); + } + + /** + * Restore the previously saved widths of the various columns if we have + * any. + * + * @param {string} url - The document URL used to store the values. + */ + restoreColumnsWidths(url) { + let columnsWidths = Services.xulStore.getValue(url, "columns", "widths"); + if (!columnsWidths) { + return; + } + + for (let column of columnsWidths.split(",")) { + column = column.split(":"); + this.querySelector(`#${column[0]}`)?.style.setProperty( + `--${column[0]}Splitter-width`, + `${column[1]}px` + ); + } + } + + /** + * Update the visibility of the currently available columns. + */ + #updateView() { + let lastResizableColumn = this.columns.findLast( + c => !c.hidden && (c.resizable ?? true) + ); + + for (let column of this.columns) { + document.getElementById(column.id).hidden = column.hidden; + + // No need to update the splitter visibility if the column is + // specifically not resizable. + if (column.resizable === false) { + continue; + } + + document.getElementById(column.id).resizable = + column != lastResizableColumn; + } + } +} +customElements.define("tree-view-table", TreeViewTable, { extends: "table" }); + +/** + * Class used to generate the thead of the TreeViewTable. This class will take + * care of handling columns sizing and sorting order, with bubbling events to + * allow listening for those changes on the implementation level. + */ +class TreeViewTableHeader extends HTMLTableSectionElement { + /** + * An array of all table header cells that can be reordered. + * + * @returns {HTMLTableCellElement[]} + */ + get #orderableChildren() { + return [...this.querySelectorAll("th[draggable]:not([hidden])")]; + } + + /** + * Used to simulate a change in the order. The element remains in the same + * DOM position. + * + * @param {HTMLTableRowElement} element - The row to animate. + * @param {number} to - The new Y position of the element after animation. + */ + static _transitionTranslation(element, to) { + if (!reducedMotionMedia.matches) { + element.style.transition = `transform ${ANIMATION_DURATION_MS}ms ease`; + } + element.style.transform = to ? `translateX(${to}px)` : null; + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-header"); + this.classList.add("tree-table-header"); + this.row = document.createElement("tr"); + this.appendChild(this.row); + + this.addEventListener("keypress", this); + this.addEventListener("dragstart", this); + this.addEventListener("dragover", this); + this.addEventListener("dragend", this); + this.addEventListener("drop", this); + } + + handleEvent(event) { + switch (event.type) { + case "keypress": + this.#onKeyPress(event); + break; + case "dragstart": + this.#onDragStart(event); + break; + case "dragover": + this.#onDragOver(event); + break; + case "dragend": + this.#onDragEnd(); + break; + case "drop": + this.#onDrop(event); + break; + } + } + + #onKeyPress(event) { + if (!event.altKey || !["ArrowRight", "ArrowLeft"].includes(event.key)) { + this.triggerTableHeaderRovingTab(event); + return; + } + + let column = event.target.closest(`th[is="tree-view-table-header-cell"]`); + if (!column) { + return; + } + + let visibleColumns = this.parentNode.columns.filter(c => !c.hidden); + let forward = + event.key == (document.dir === "rtl" ? "ArrowLeft" : "ArrowRight"); + + // Bail out if the user is trying to shift backward the first column, or + // shift forward the last column. + if ( + (!forward && visibleColumns.at(0)?.id == column.id) || + (forward && visibleColumns.at(-1)?.id == column.id) + ) { + return; + } + + event.preventDefault(); + this.dispatchEvent( + new CustomEvent("shift-column", { + bubbles: true, + detail: { + column: column.id, + forward, + }, + }) + ); + } + + #onDragStart(event) { + if (!event.target.closest("th[draggable]")) { + // This shouldn't be necessary, but is?! + event.preventDefault(); + return; + } + + const orderable = this.#orderableChildren; + if (orderable.length < 2) { + return; + } + + const headerCell = orderable.find(th => th.contains(event.target)); + const rect = headerCell.getBoundingClientRect(); + + this._dragInfo = { + cell: headerCell, + // How far can we move `headerCell` horizontally. + min: orderable.at(0).getBoundingClientRect().left - rect.left, + max: orderable.at(-1).getBoundingClientRect().right - rect.right, + // Where is the drag event starting. + startX: event.clientX, + offsetX: event.clientX - rect.left, + }; + + headerCell.classList.add("column-dragging"); + // Prevent `headerCell` being used as the drag image. We don't + // really want any drag image, but there's no way to not have one. + event.dataTransfer.setDragImage(document.createElement("img"), 0, 0); + } + + #onDragOver(event) { + if (!this._dragInfo) { + return; + } + + const { cell, min, max, startX, offsetX } = this._dragInfo; + // Move `cell` with the mouse pointer. + let dragX = Math.min(max, Math.max(min, event.clientX - startX)); + cell.style.transform = `translateX(${dragX}px)`; + + let thisRect = this.getBoundingClientRect(); + + // How much space is there before the `cell`? We'll see how many cells fit + // in the space and put the `cell` in after them. + let spaceBefore = Math.max( + 0, + event.clientX + this.scrollLeft - offsetX - thisRect.left + ); + // The width of all cells seen in the loop so far. + let totalWidth = 0; + // If we've looped past the cell being dragged. + let afterDraggedTh = false; + // The cell before where a drop would take place. If null, drop would + // happen at the start of the table header. + let header = null; + + for (let headerCell of this.#orderableChildren) { + if (headerCell == cell) { + afterDraggedTh = true; + continue; + } + + let rect = headerCell.getBoundingClientRect(); + let enoughSpace = spaceBefore > totalWidth + rect.width / 2; + + let multiplier = 0; + if (enoughSpace) { + if (afterDraggedTh) { + multiplier = -1; + } + header = headerCell; + } else if (!afterDraggedTh) { + multiplier = 1; + } + TreeViewTableHeader._transitionTranslation( + headerCell, + multiplier * cell.clientWidth + ); + + totalWidth += rect.width; + } + + this._dragInfo.dropTarget = header; + + event.preventDefault(); + } + + #onDragEnd() { + if (!this._dragInfo) { + return; + } + + this._dragInfo.cell.classList.remove("column-dragging"); + delete this._dragInfo; + + for (let headerCell of this.#orderableChildren) { + headerCell.style.transform = null; + headerCell.style.transition = null; + } + } + + #onDrop(event) { + if (!this._dragInfo) { + return; + } + + let { cell, startX, dropTarget } = this._dragInfo; + + let newColumns = this.parentNode.columns.map(column => ({ ...column })); + + const draggedColumn = newColumns.find(c => c.id == cell.id); + const initialPosition = newColumns.indexOf(draggedColumn); + + let targetCell; + let newPosition; + if (!dropTarget) { + // Get the first visible cell. + targetCell = this.querySelector("th:not([hidden])"); + newPosition = newColumns.indexOf( + newColumns.find(c => c.id == targetCell.id) + ); + } else { + // Get the next non hidden sibling. + targetCell = dropTarget.nextElementSibling; + while (targetCell.hidden) { + targetCell = targetCell.nextElementSibling; + } + newPosition = newColumns.indexOf( + newColumns.find(c => c.id == targetCell.id) + ); + } + + // Reduce the new position index if we're moving forward in order to get the + // accurate index position of the column we're taking the position of. + if (event.clientX > startX) { + newPosition -= 1; + } + + newColumns.splice(newPosition, 0, newColumns.splice(initialPosition, 1)[0]); + + // Update the ordinal of the columns to reflect the new positions. + newColumns.forEach((column, index) => { + column.ordinal = index; + }); + + this.querySelector("tr").insertBefore(cell, targetCell); + + this.dispatchEvent( + new CustomEvent("reorder-columns", { + bubbles: true, + detail: { + columns: newColumns, + }, + }) + ); + event.preventDefault(); + } + + /** + * Create all the table header cells based on the currently set columns. + */ + setColumns() { + this.row.replaceChildren(); + + for (let column of this.parentNode.columns) { + /** @type {TreeViewTableHeaderCell} */ + let cell = document.createElement("th", { + is: "tree-view-table-header-cell", + }); + this.row.appendChild(cell); + cell.setColumn(column); + } + + // Create a column picker if the table is editable. + if (this.parentNode.editable) { + const picker = document.createElement("th", { + is: "tree-view-table-column-picker", + }); + this.row.appendChild(picker); + } + + this.updateRovingTab(); + } + + /** + * Get all currently visible columns of the table header. + * + * @returns {Array} An array of buttons. + */ + get headerColumns() { + return this.row.querySelectorAll(`th:not([hidden]) button`); + } + + /** + * Update the `tabindex` attribute of the currently visible columns. + */ + updateRovingTab() { + for (let button of this.headerColumns) { + button.tabIndex = -1; + } + // Allow focus on the first available button. + this.headerColumns[0].tabIndex = 0; + } + + /** + * Handles the keypress event on the table header. + * + * @param {Event} event - The keypress DOMEvent. + */ + triggerTableHeaderRovingTab(event) { + if (!["ArrowRight", "ArrowLeft"].includes(event.key)) { + return; + } + + const headerColumns = [...this.headerColumns]; + let focusableButton = headerColumns.find(b => b.tabIndex != -1); + let elementIndex = headerColumns.indexOf(focusableButton); + + // Find the adjacent focusable element based on the pressed key. + let isRTL = document.dir == "rtl"; + if ( + (isRTL && event.key == "ArrowLeft") || + (!isRTL && event.key == "ArrowRight") + ) { + elementIndex++; + if (elementIndex > headerColumns.length - 1) { + elementIndex = 0; + } + } else if ( + (!isRTL && event.key == "ArrowLeft") || + (isRTL && event.key == "ArrowRight") + ) { + elementIndex--; + if (elementIndex == -1) { + elementIndex = headerColumns.length - 1; + } + } + + // Move the focus to a new column and update the tabindex attribute. + let newFocusableButton = headerColumns[elementIndex]; + if (newFocusableButton) { + focusableButton.tabIndex = -1; + newFocusableButton.tabIndex = 0; + newFocusableButton.focus(); + } + } +} +customElements.define("tree-view-table-header", TreeViewTableHeader, { + extends: "thead", +}); + +/** + * Class to generated the TH elements for the TreeViewTableHeader. + */ +class TreeViewTableHeaderCell extends HTMLTableCellElement { + /** + * The div needed to handle the header button in an absolute position. + * @type {HTMLElement} + */ + #container; + + /** + * The clickable button to change the sorting of the table. + * @type {HTMLButtonElement} + */ + #button; + + /** + * If this cell is resizable. + * @type {boolean} + */ + #resizable = true; + + /** + * If this cell can be clicked to affect the sorting order of the tree. + * @type {boolean} + */ + #sortable = true; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-header-cell"); + this.draggable = true; + + this.#container = document.createElement("div"); + this.#container.classList.add( + "tree-table-cell", + "tree-table-cell-container" + ); + + this.#button = document.createElement("button"); + this.#container.appendChild(this.#button); + this.appendChild(this.#container); + } + + /** + * Set the proper data to the newly generated table header cell and create + * the needed child elements. + * + * @param {object} column - The column object with all the data to generate + * the correct header cell. + */ + setColumn(column) { + // Set a public ID so parent elements can loop through the available + // columns after they're created. + this.id = column.id; + this.#button.id = `${column.id}Button`; + + // Add custom classes if needed. + if (column.classes) { + this.#button.classList.add(...column.classes); + } + + if (column.l10n?.header) { + document.l10n.setAttributes(this.#button, column.l10n.header); + } + + // Add an image if this is a table header that needs to display an icon, + // and set the column as icon. + if (column.icon) { + this.dataset.type = "icon"; + const img = document.createElement("img"); + img.src = ""; + img.alt = ""; + this.#button.appendChild(img); + } + + this.resizable = column.resizable ?? true; + + this.hidden = column.hidden; + + this.#sortable = column.sortable ?? true; + // Make the button clickable if the column can trigger a sorting of rows. + if (this.#sortable) { + this.#button.addEventListener("click", () => { + this.dispatchEvent( + new CustomEvent("sort-changed", { + bubbles: true, + detail: { + column: column.id, + }, + }) + ); + }); + } + + this.#button.addEventListener("contextmenu", event => { + event.stopPropagation(); + const table = this.closest("table"); + if (table.editable) { + table + .querySelector("#columnPickerMenuPopup") + .openPopup(event.target, { triggerEvent: event }); + } + }); + + // This is the column handling the thread toggling. + if (column.thread) { + this.#button.classList.add("tree-view-header-thread"); + this.#button.addEventListener("click", () => { + this.dispatchEvent( + new CustomEvent("thread-changed", { + bubbles: true, + }) + ); + }); + } + + // This is the column handling bulk selection. + if (column.select) { + this.#button.classList.add("tree-view-header-select"); + this.#button.addEventListener("click", () => { + this.closest("tree-view").toggleSelectAll(); + }); + } + + // This is the column handling delete actions. + if (column.delete) { + this.#button.classList.add("tree-view-header-delete"); + } + } + + /** + * Set this table header as responsible for the sorting of rows. + * + * @param {string["ascending"|"descending"]} direction - The new sorting + * direction. + */ + setSorting(direction) { + this.#button.classList.add("sorting", direction); + } + + /** + * If this current column can be resized. + * + * @type {boolean} + */ + set resizable(val) { + this.#resizable = val; + this.dataset.resizable = val; + + let splitter = this.querySelector("hr"); + + // Add a splitter if we don't have one already. + if (!splitter) { + splitter = document.createElement("hr", { is: "pane-splitter" }); + splitter.setAttribute("is", "pane-splitter"); + this.appendChild(splitter); + splitter.resizeDirection = "horizontal"; + splitter.resizeElement = this; + splitter.id = `${this.id}Splitter`; + // Emit a custom event after a resize action. Methods at implementation + // level should listen to this event if the edited column size needs to + // be stored or used. + splitter.addEventListener("splitter-resized", () => { + this.dispatchEvent( + new CustomEvent("column-resized", { + bubbles: true, + detail: { + splitter, + column: this.id, + }, + }) + ); + }); + } + + this.style.setProperty("width", val ? `var(--${splitter.id}-width)` : null); + // Disable the splitter if this is not a resizable column. + splitter.isDisabled = !val; + } + + get resizable() { + return this.#resizable; + } + + /** + * If the current column can trigger a sorting of rows. + * + * @type {boolean} + */ + set sortable(val) { + this.#sortable = val; + this.#button.disabled = !val; + } + + get sortable() { + return this.#sortable; + } +} +customElements.define("tree-view-table-header-cell", TreeViewTableHeaderCell, { + extends: "th", +}); + +/** + * Class used to generate a column picker used for the TreeViewTableHeader in + * case the visibility of the columns of a table can be changed. + * + * Include treeView.ftl for strings. + */ +class TreeViewTableColumnPicker extends HTMLTableCellElement { + /** + * The clickable button triggering the picker context menu. + * @type {HTMLButtonElement} + */ + #button; + + /** + * The menupopup allowing users to show and hide columns. + * @type {XULElement} + */ + #context; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-column-picker"); + this.classList.add("tree-table-cell-container"); + + this.#button = document.createElement("button"); + document.l10n.setAttributes(this.#button, "tree-list-view-column-picker"); + this.#button.classList.add("button-flat", "button-column-picker"); + this.appendChild(this.#button); + + const img = document.createElement("img"); + img.src = ""; + img.alt = ""; + this.#button.appendChild(img); + + this.#context = document.createXULElement("menupopup"); + this.#context.id = "columnPickerMenuPopup"; + this.#context.setAttribute("position", "bottomleft topleft"); + this.appendChild(this.#context); + this.#context.addEventListener("popupshowing", event => { + // Bail out if we're opening a submenu. + if (event.target.id != this.#context.id) { + return; + } + + if (!this.#context.hasChildNodes()) { + this.#initPopup(); + } + + let columns = this.closest("table").columns; + for (let column of columns) { + let item = this.#context.querySelector(`[value="${column.id}"]`); + if (!item) { + continue; + } + + if (!column.hidden) { + item.setAttribute("checked", "true"); + continue; + } + + item.removeAttribute("checked"); + } + }); + + this.#button.addEventListener("click", event => { + this.#context.openPopup(event.target, { triggerEvent: event }); + }); + } + + /** + * Add all toggable columns to the context menu popup of the picker button. + */ + #initPopup() { + let table = this.closest("table"); + let columns = table.columns; + let items = new DocumentFragment(); + for (let column of columns) { + // Skip those columns we don't want to allow hiding. + if (column.picker === false) { + continue; + } + + let menuitem = document.createXULElement("menuitem"); + items.append(menuitem); + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("name", "toggle"); + menuitem.setAttribute("value", column.id); + menuitem.setAttribute("closemenu", "none"); + if (column.l10n?.menuitem) { + document.l10n.setAttributes(menuitem, column.l10n.menuitem); + } + + menuitem.addEventListener("command", () => { + this.dispatchEvent( + new CustomEvent("columns-changed", { + bubbles: true, + detail: { + target: menuitem, + value: column.id, + }, + }) + ); + }); + } + + items.append(document.createXULElement("menuseparator")); + let restoreItem = document.createXULElement("menuitem"); + restoreItem.id = "restoreColumnOrder"; + restoreItem.addEventListener("command", () => { + this.dispatchEvent( + new CustomEvent("restore-columns", { + bubbles: true, + }) + ); + }); + document.l10n.setAttributes( + restoreItem, + "tree-list-view-column-picker-restore" + ); + items.append(restoreItem); + + for (const templateID of table.popupMenuTemplates) { + items.append(document.getElementById(templateID).content.cloneNode(true)); + } + + this.#context.replaceChildren(items); + } +} +customElements.define( + "tree-view-table-column-picker", + TreeViewTableColumnPicker, + { extends: "th" } +); + +/** + * A more powerful list designed to be used with a view (nsITreeView or + * whatever replaces it in time) and be scalable to a very large number of + * items if necessary. Multiple selections are possible and changes in the + * connected view are cause updates to the list (provided `rowCountChanged`/ + * `invalidate` are called as appropriate). + * + * Rows are provided by a custom element that inherits from + * TreeViewTableRow below. Set the name of the custom element as the "rows" + * attribute. + * + * Include tree-listbox.css for appropriate styling. + */ +class TreeViewTableBody extends HTMLTableSectionElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.tabIndex = 0; + this.setAttribute("is", "tree-view-table-body"); + this.setAttribute("role", "tree"); + this.setAttribute("aria-multiselectable", "true"); + + let treeView = this.closest("tree-view"); + this.addEventListener("keyup", treeView); + this.addEventListener("click", treeView); + this.addEventListener("keydown", treeView); + + if (treeView.dataset.labelId) { + this.setAttribute("aria-labelledby", treeView.dataset.labelId); + } + } +} +customElements.define("tree-view-table-body", TreeViewTableBody, { + extends: "tbody", +}); + +/** + * Base class for rows in a TreeViewTableBody. Rows have a fixed height and + * their position on screen is managed by the owning list. + * + * Sub-classes should override ROW_HEIGHT, styles, and fragment to suit the + * intended layout. The index getter/setter should be overridden to fill the + * layout with values. + */ +class TreeViewTableRow extends HTMLTableRowElement { + /** + * Fixed height of this row. Rows in the list will be spaced this far + * apart. This value must not change at runtime. + * + * @type {integer} + */ + static ROW_HEIGHT = 50; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.tabIndex = -1; + this.list = this.closest("tree-view"); + this.view = this.list.view; + this.setAttribute("aria-selected", !!this.selected); + } + + /** + * The 0-based position of this row in the list. Override this setter to + * fill layout based on values from the list's view. Always call back to + * this class's getter/setter when inheriting. + * + * @note Don't short-circuit the setter if the given index is equal to the + * existing index. Rows can be reused to display new data at the same index. + * + * @type {integer} + */ + get index() { + return this._index; + } + + set index(index) { + this.setAttribute( + "role", + this.list.table.body.getAttribute("role") === "tree" + ? "treeitem" + : "option" + ); + this.setAttribute("aria-posinset", index + 1); + this.id = `${this.list.id}-row${index}`; + + const isGroup = this.view.isContainer(index); + this.classList.toggle("children", isGroup); + + const isGroupOpen = this.view.isContainerOpen(index); + if (isGroup) { + this.setAttribute("aria-expanded", isGroupOpen); + } else { + this.removeAttribute("aria-expanded"); + } + this.classList.toggle("collapsed", !isGroupOpen); + this._index = index; + + let table = this.closest("table"); + for (let column of table.columns) { + let cell = this.querySelector(`.${column.id.toLowerCase()}-column`); + // No need to do anything if this cell doesn't exist. This can happen + // for non-table layouts. + if (!cell) { + continue; + } + + // Always clear the colspan when updating the columns. + cell.removeAttribute("colspan"); + + // No need to do anything if this column is hidden. + if (cell.hidden) { + continue; + } + + // Handle the special case for the selectable checkbox column. + if (column.select) { + let img = cell.firstElementChild; + if (!img) { + cell.classList.add("tree-view-row-select"); + img = document.createElement("img"); + img.src = ""; + img.tabIndex = -1; + img.classList.add("tree-view-row-select-checkbox"); + cell.replaceChildren(img); + } + document.l10n.setAttributes( + img, + this.list._selection.isSelected(index) + ? "tree-list-view-row-deselect" + : "tree-list-view-row-select" + ); + continue; + } + + // No need to do anything if an earlier call to this function already + // added the cell contents. + if (cell.firstElementChild) { + continue; + } + } + + // Account for the column picker in the last visible column if the table + // if editable. + if (table.editable) { + let last = table.columns.filter(c => !c.hidden).pop(); + this.querySelector(`.${last.id.toLowerCase()}-column`)?.setAttribute( + "colspan", + "2" + ); + } + } + + /** + * Tracks the selection state of the current row. + * + * @type {boolean} + */ + get selected() { + return this.classList.contains("selected"); + } + + set selected(selected) { + this.setAttribute("aria-selected", !!selected); + this.classList.toggle("selected", !!selected); + } +} +customElements.define("tree-view-table-row", TreeViewTableRow, { + extends: "tr", +}); + +/** + * Simple tbody spacer used above and below the main tbody for space + * allocation and ensuring the correct scrollable height. + */ +class TreeViewTableSpacer extends HTMLTableSectionElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.cell = document.createElement("td"); + const row = document.createElement("tr"); + row.appendChild(this.cell); + this.appendChild(row); + } + + /** + * Set the cell colspan to reflect the number of visible columns in order + * to generate a correct HTML markup. + * + * @param {int} count - The columns count. + */ + setColspan(count) { + this.cell.setAttribute("colspan", count); + } + + /** + * Set the height of the cell in order to occupy the empty area that will + * be filled by new rows on demand when needed. + * + * @param {int} val - The pixel height the row should occupy. + */ + setHeight(val) { + this.cell.style.height = `${val}px`; + } +} +customElements.define("tree-view-table-spacer", TreeViewTableSpacer, { + extends: "tbody", +}); |