summaryrefslogtreecommitdiffstats
path: root/browser/components/downloads/content
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/downloads/content')
-rw-r--r--browser/components/downloads/content/allDownloadsView.js949
-rw-r--r--browser/components/downloads/content/contentAreaDownloadsView.css8
-rw-r--r--browser/components/downloads/content/contentAreaDownloadsView.js49
-rw-r--r--browser/components/downloads/content/contentAreaDownloadsView.xhtml48
-rw-r--r--browser/components/downloads/content/downloads.css106
-rw-r--r--browser/components/downloads/content/downloads.js1722
-rw-r--r--browser/components/downloads/content/downloadsCommands.inc.xhtml29
-rw-r--r--browser/components/downloads/content/downloadsCommands.js17
-rw-r--r--browser/components/downloads/content/downloadsContextMenu.inc.xhtml50
-rw-r--r--browser/components/downloads/content/downloadsPanel.inc.xhtml198
-rw-r--r--browser/components/downloads/content/indicator.js670
11 files changed, 3846 insertions, 0 deletions
diff --git a/browser/components/downloads/content/allDownloadsView.js b/browser/components/downloads/content/allDownloadsView.js
new file mode 100644
index 0000000000..9245127b0e
--- /dev/null
+++ b/browser/components/downloads/content/allDownloadsView.js
@@ -0,0 +1,949 @@
+/* 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/. */
+/* eslint-env mozilla/browser-window */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs",
+ DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+/**
+ * A download element shell is responsible for handling the commands and the
+ * displayed data for a single download view element.
+ *
+ * The shell may contain a session download, a history download, or both. When
+ * both a history and a session download are present, the session download gets
+ * priority and its information is displayed.
+ *
+ * On construction, a new richlistitem is created, and can be accessed through
+ * the |element| getter. The shell doesn't insert the item in a richlistbox, the
+ * caller must do it and remove the element when it's no longer needed.
+ *
+ * The caller is also responsible for forwarding status notifications, calling
+ * the onChanged method.
+ *
+ * @param download
+ * The Download object from the DownloadHistoryList.
+ */
+function HistoryDownloadElementShell(download) {
+ this._download = download;
+
+ this.element = document.createXULElement("richlistitem");
+ this.element._shell = this;
+
+ this.element.classList.add("download");
+ this.element.classList.add("download-state");
+}
+
+HistoryDownloadElementShell.prototype = {
+ /**
+ * Overrides the base getter to return the Download or HistoryDownload object
+ * for displaying information and executing commands in the user interface.
+ */
+ get download() {
+ return this._download;
+ },
+
+ onStateChanged() {
+ // Since the state changed, we may need to check the target file again.
+ this._targetFileChecked = false;
+
+ this._updateState();
+
+ if (this.element.selected) {
+ goUpdateDownloadCommands();
+ } else {
+ // If a state change occurs in an item that is not currently selected,
+ // this is the only command that may be affected.
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ onChanged() {
+ // There is nothing to do if the item has always been invisible.
+ if (!this.active) {
+ return;
+ }
+
+ let newState = DownloadsCommon.stateOfDownload(this.download);
+ if (this._downloadState !== newState) {
+ this._downloadState = newState;
+ this.onStateChanged();
+ } else {
+ this._updateStateInner();
+ }
+ },
+ _downloadState: null,
+
+ isCommandEnabled(aCommand) {
+ // The only valid command for inactive elements is cmd_delete.
+ if (!this.active && aCommand != "cmd_delete") {
+ return false;
+ }
+ return DownloadsViewUI.DownloadElementShell.prototype.isCommandEnabled.call(
+ this,
+ aCommand
+ );
+ },
+
+ downloadsCmd_unblock() {
+ this.confirmUnblock(window, "unblock");
+ },
+ downloadsCmd_unblockAndSave() {
+ this.confirmUnblock(window, "unblock");
+ },
+
+ downloadsCmd_chooseUnblock() {
+ this.confirmUnblock(window, "chooseUnblock");
+ },
+
+ downloadsCmd_chooseOpen() {
+ this.confirmUnblock(window, "chooseOpen");
+ },
+
+ // Returns whether or not the download handled by this shell should
+ // show up in the search results for the given term. Both the display
+ // name for the download and the url are searched.
+ matchesSearchTerm(aTerm) {
+ if (!aTerm) {
+ return true;
+ }
+ aTerm = aTerm.toLowerCase();
+ let displayName = DownloadsViewUI.getDisplayName(this.download);
+ return (
+ displayName.toLowerCase().includes(aTerm) ||
+ (this.download.source.originalUrl || this.download.source.url)
+ .toLowerCase()
+ .includes(aTerm)
+ );
+ },
+
+ // Handles double-click and return keypress on the element (the keypress
+ // listener is set in the DownloadsPlacesView object).
+ doDefaultCommand(event) {
+ let command = this.currentDefaultCommandName;
+ if (
+ command == "downloadsCmd_open" &&
+ event &&
+ (event.shiftKey || event.ctrlKey || event.metaKey || event.button == 1)
+ ) {
+ // We adjust the command for supported modifiers to suggest where the download may
+ // be opened.
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ let openWhere = browserWin
+ ? browserWin.whereToOpenLink(event, false, true)
+ : "window";
+ if (["window", "tabshifted", "tab"].includes(openWhere)) {
+ command += ":" + openWhere;
+ }
+ }
+
+ if (command && this.isCommandEnabled(command)) {
+ this.doCommand(command);
+ }
+ },
+
+ /**
+ * This method is called by the outer download view, after the controller
+ * commands have already been updated. In case we did not check for the
+ * existence of the target file already, we can do it now and then update
+ * the commands as needed.
+ */
+ onSelect() {
+ if (!this.active) {
+ return;
+ }
+
+ // If this is a history download for which no target file information is
+ // available, we cannot retrieve information about the target file.
+ if (!this.download.target.path) {
+ return;
+ }
+
+ // Start checking for existence. This may be done twice if onSelect is
+ // called again before the information is collected.
+ if (!this._targetFileChecked) {
+ this.download
+ .refresh()
+ .catch(console.error)
+ .then(() => {
+ // Do not try to check for existence again even if this failed.
+ this._targetFileChecked = true;
+ });
+ }
+ },
+};
+Object.setPrototypeOf(
+ HistoryDownloadElementShell.prototype,
+ DownloadsViewUI.DownloadElementShell.prototype
+);
+
+/**
+ * Relays commands from the download.xml binding to the selected items.
+ */
+var DownloadsView = {
+ onDownloadButton(event) {
+ event.target.closest("richlistitem")._shell.onButton();
+ },
+
+ onDownloadClick() {},
+};
+
+/**
+ * A Downloads Places View is a places view designed to show a places query
+ * for history downloads alongside the session downloads.
+ *
+ * As we don't use the places controller, some methods implemented by other
+ * places views are not implemented by this view.
+ *
+ * A richlistitem in this view can represent either a past download or a session
+ * download, or both. Session downloads are shown first in the view, and as long
+ * as they exist they "collapses" their history "counterpart" (So we don't show two
+ * items for every download).
+ */
+function DownloadsPlacesView(
+ aRichListBox,
+ aActive = true,
+ aSuppressionFlag = DownloadsCommon.SUPPRESS_ALL_DOWNLOADS_OPEN
+) {
+ this._richlistbox = aRichListBox;
+ this._richlistbox._placesView = this;
+ window.controllers.insertControllerAt(0, this);
+
+ // Map downloads to their element shells.
+ this._viewItemsForDownloads = new WeakMap();
+
+ this._searchTerm = "";
+
+ this._active = aActive;
+
+ // Register as a downloads view. The places data will be initialized by
+ // the places setter.
+ this._initiallySelectedElement = null;
+ this._downloadsData = DownloadsCommon.getData(window.opener || window, true);
+ this._waitingForInitialData = true;
+ this._downloadsData.addView(this);
+
+ // Pause the download indicator as user is interacting with downloads. This is
+ // skipped on about:downloads because it handles this by itself.
+ if (aSuppressionFlag === DownloadsCommon.SUPPRESS_ALL_DOWNLOADS_OPEN) {
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed |=
+ aSuppressionFlag;
+ }
+
+ // Make sure to unregister the view if the window is closed.
+ window.addEventListener(
+ "unload",
+ () => {
+ window.controllers.removeController(this);
+ // Unpause the main window's download indicator.
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed &=
+ ~aSuppressionFlag;
+ this._downloadsData.removeView(this);
+ this.result = null;
+ },
+ true
+ );
+ // Resizing the window may change items visibility.
+ window.addEventListener(
+ "resize",
+ () => {
+ this._ensureVisibleElementsAreActive(true);
+ },
+ true
+ );
+}
+
+DownloadsPlacesView.prototype = {
+ get associatedElement() {
+ return this._richlistbox;
+ },
+
+ get active() {
+ return this._active;
+ },
+ set active(val) {
+ this._active = val;
+ if (this._active) {
+ this._ensureVisibleElementsAreActive(true);
+ }
+ },
+
+ /**
+ * Ensure the custom element contents are created and shown for each
+ * visible element in the list.
+ *
+ * @param debounce whether to use a short timeout rather than running
+ * immediately. The default is running immediately. If you
+ * pass `true`, we'll run on a 10ms timeout. This is used to
+ * avoid running this code lots while scrolling or resizing.
+ */
+ _ensureVisibleElementsAreActive(debounce = false) {
+ if (
+ !this.active ||
+ (debounce && this._ensureVisibleTimer) ||
+ !this._richlistbox.firstChild
+ ) {
+ return;
+ }
+
+ if (debounce) {
+ this._ensureVisibleTimer = setTimeout(() => {
+ this._internalEnsureVisibleElementsAreActive();
+ }, 10);
+ } else {
+ this._internalEnsureVisibleElementsAreActive();
+ }
+ },
+
+ _internalEnsureVisibleElementsAreActive() {
+ // If there are no children, we can't do anything so bail out.
+ // However, avoid clearing the timer because there may be children
+ // when the timer fires.
+ if (!this._richlistbox.firstChild) {
+ // If we were called asynchronously (debounced), we need to delete
+ // the timer variable to ensure we are called again if another
+ // debounced call comes in.
+ delete this._ensureVisibleTimer;
+ return;
+ }
+
+ if (this._ensureVisibleTimer) {
+ clearTimeout(this._ensureVisibleTimer);
+ delete this._ensureVisibleTimer;
+ }
+
+ let rlbRect = this._richlistbox.getBoundingClientRect();
+ let winUtils = window.windowUtils;
+ let nodes = winUtils.nodesFromRect(
+ rlbRect.left,
+ rlbRect.top,
+ 0,
+ rlbRect.width,
+ rlbRect.height,
+ 0,
+ true,
+ false,
+ false
+ );
+ // nodesFromRect returns nodes in z-index order, and for the same z-index
+ // sorts them in inverted DOM order, thus starting from the one that would
+ // be on top.
+ let firstVisibleNode, lastVisibleNode;
+ for (let node of nodes) {
+ if (node.localName === "richlistitem" && node._shell) {
+ node._shell.ensureActive();
+ // The first visible node is the last match.
+ firstVisibleNode = node;
+ // While the last visible node is the first match.
+ if (!lastVisibleNode) {
+ lastVisibleNode = node;
+ }
+ }
+ }
+
+ // Also activate the first invisible nodes in both boundaries (that is,
+ // above and below the visible area) to ensure proper keyboard navigation
+ // in both directions.
+ let nodeBelowVisibleArea = lastVisibleNode && lastVisibleNode.nextSibling;
+ if (nodeBelowVisibleArea && nodeBelowVisibleArea._shell) {
+ nodeBelowVisibleArea._shell.ensureActive();
+ }
+
+ let nodeAboveVisibleArea =
+ firstVisibleNode && firstVisibleNode.previousSibling;
+ if (nodeAboveVisibleArea && nodeAboveVisibleArea._shell) {
+ nodeAboveVisibleArea._shell.ensureActive();
+ }
+ },
+
+ _place: "",
+ get place() {
+ return this._place;
+ },
+ set place(val) {
+ if (this._place == val) {
+ // XXXmano: places.js relies on this behavior (see Bug 822203).
+ this.searchTerm = "";
+ } else {
+ this._place = val;
+ }
+ },
+
+ get selectedNodes() {
+ return Array.prototype.filter.call(
+ this._richlistbox.selectedItems,
+ element => element._shell.download.placesNode
+ );
+ },
+
+ get selectedNode() {
+ let selectedNodes = this.selectedNodes;
+ return selectedNodes.length == 1 ? selectedNodes[0] : null;
+ },
+
+ get hasSelection() {
+ return !!this.selectedNodes.length;
+ },
+
+ get controller() {
+ return this._richlistbox.controller;
+ },
+
+ get searchTerm() {
+ return this._searchTerm;
+ },
+ set searchTerm(aValue) {
+ if (this._searchTerm != aValue) {
+ // Always clear selection on a new search, since the user is starting a
+ // different workflow. This also solves the fact we could end up
+ // retaining selection on hidden elements.
+ this._richlistbox.clearSelection();
+ for (let element of this._richlistbox.childNodes) {
+ element.hidden = !element._shell.matchesSearchTerm(aValue);
+ }
+ this._ensureVisibleElementsAreActive();
+ }
+ this._searchTerm = aValue;
+ },
+
+ /**
+ * When the view loads, we want to select the first item.
+ * However, because session downloads, for which the data is loaded
+ * asynchronously, always come first in the list, and because the list
+ * may (or may not) already contain history downloads at that point, it
+ * turns out that by the time we can select the first item, the user may
+ * have already started using the view.
+ * To make things even more complicated, in other cases, the places data
+ * may be loaded after the session downloads data. Thus we cannot rely on
+ * the order in which the data comes in.
+ * We work around this by attempting to select the first element twice,
+ * once after the places data is loaded and once when the session downloads
+ * data is done loading. However, if the selection has changed in-between,
+ * we assume the user has already started using the view and give up.
+ */
+ _ensureInitialSelection() {
+ // Either they're both null, or the selection has not changed in between.
+ if (this._richlistbox.selectedItem == this._initiallySelectedElement) {
+ let firstDownloadElement = this._richlistbox.firstChild;
+ if (firstDownloadElement != this._initiallySelectedElement) {
+ // We may be called before _ensureVisibleElementsAreActive,
+ // therefore, ensure the first item is activated.
+ firstDownloadElement._shell.ensureActive();
+ this._richlistbox.selectedItem = firstDownloadElement;
+ this._richlistbox.currentItem = firstDownloadElement;
+ this._initiallySelectedElement = firstDownloadElement;
+ }
+ }
+ },
+
+ /**
+ * DocumentFragment object that contains all the new elements added during a
+ * batch operation, or null if no batch is in progress.
+ *
+ * Since newest downloads are displayed at the top, elements are normally
+ * prepended to the fragment, and then the fragment is prepended to the list.
+ */
+ batchFragment: null,
+
+ onDownloadBatchStarting() {
+ this.batchFragment = document.createDocumentFragment();
+
+ this.oldSuppressOnSelect = this._richlistbox.suppressOnSelect;
+ this._richlistbox.suppressOnSelect = true;
+ },
+
+ onDownloadBatchEnded() {
+ this._richlistbox.suppressOnSelect = this.oldSuppressOnSelect;
+ delete this.oldSuppressOnSelect;
+
+ if (this.batchFragment.childElementCount) {
+ this._prependBatchFragment();
+ }
+ this.batchFragment = null;
+
+ this._ensureInitialSelection();
+ this._ensureVisibleElementsAreActive();
+ goUpdateDownloadCommands();
+ if (this._waitingForInitialData) {
+ this._waitingForInitialData = false;
+ this._richlistbox.dispatchEvent(
+ new CustomEvent("InitialDownloadsLoaded")
+ );
+ }
+ },
+
+ _prependBatchFragment() {
+ // Workaround multiple reflows hang by removing the richlistbox
+ // and adding it back when we're done.
+
+ // Hack for bug 836283: reset xbl fields to their old values after the
+ // binding is reattached to avoid breaking the selection state
+ let xblFields = new Map();
+ for (let key of Object.getOwnPropertyNames(this._richlistbox)) {
+ let value = this._richlistbox[key];
+ xblFields.set(key, value);
+ }
+
+ let oldActiveElement = document.activeElement;
+ let parentNode = this._richlistbox.parentNode;
+ let nextSibling = this._richlistbox.nextSibling;
+ parentNode.removeChild(this._richlistbox);
+ this._richlistbox.prepend(this.batchFragment);
+ parentNode.insertBefore(this._richlistbox, nextSibling);
+ if (oldActiveElement && oldActiveElement != document.activeElement) {
+ oldActiveElement.focus();
+ }
+
+ for (let [key, value] of xblFields) {
+ this._richlistbox[key] = value;
+ }
+ },
+
+ onDownloadAdded(download, { insertBefore } = {}) {
+ let shell = new HistoryDownloadElementShell(download);
+ this._viewItemsForDownloads.set(download, shell);
+
+ // Since newest downloads are displayed at the top, either prepend the new
+ // element or insert it after the one indicated by the insertBefore option.
+ if (insertBefore) {
+ this._viewItemsForDownloads
+ .get(insertBefore)
+ .element.insertAdjacentElement("afterend", shell.element);
+ } else {
+ (this.batchFragment || this._richlistbox).prepend(shell.element);
+ }
+
+ if (this.searchTerm) {
+ shell.element.hidden = !shell.matchesSearchTerm(this.searchTerm);
+ }
+
+ // Don't update commands and visible elements during a batch change.
+ if (!this.batchFragment) {
+ this._ensureVisibleElementsAreActive();
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ onDownloadChanged(download) {
+ this._viewItemsForDownloads.get(download).onChanged();
+ },
+
+ onDownloadRemoved(download) {
+ let element = this._viewItemsForDownloads.get(download).element;
+
+ // If the element was selected exclusively, select its next
+ // sibling first, if not, try for previous sibling, if any.
+ if (
+ (element.nextSibling || element.previousSibling) &&
+ this._richlistbox.selectedItems &&
+ this._richlistbox.selectedItems.length == 1 &&
+ this._richlistbox.selectedItems[0] == element
+ ) {
+ this._richlistbox.selectItem(
+ element.nextSibling || element.previousSibling
+ );
+ }
+
+ this._richlistbox.removeItemFromSelection(element);
+ element.remove();
+
+ // Don't update commands and visible elements during a batch change.
+ if (!this.batchFragment) {
+ this._ensureVisibleElementsAreActive();
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ }
+ },
+
+ // nsIController
+ supportsCommand(aCommand) {
+ // Firstly, determine if this is a command that we can handle.
+ if (!DownloadsViewUI.isCommandName(aCommand)) {
+ return false;
+ }
+ if (
+ !(aCommand in this) &&
+ !(aCommand in HistoryDownloadElementShell.prototype)
+ ) {
+ return false;
+ }
+ // If this function returns true, other controllers won't get a chance to
+ // process the command even if isCommandEnabled returns false, so it's
+ // important to check if the list is focused here to handle common commands
+ // like copy and paste correctly. The clear downloads command, instead, is
+ // specific to the downloads list but can be invoked from the toolbar, so we
+ // can just return true unconditionally.
+ return (
+ aCommand == "downloadsCmd_clearDownloads" ||
+ document.activeElement == this._richlistbox
+ );
+ },
+
+ // nsIController
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "cmd_copy":
+ return Array.prototype.some.call(
+ this._richlistbox.selectedItems,
+ element => {
+ const { source } = element._shell.download;
+ return !!(source?.originalUrl || source?.url);
+ }
+ );
+ case "downloadsCmd_openReferrer":
+ case "downloadShowMenuItem":
+ return this._richlistbox.selectedItems.length == 1;
+ case "cmd_selectAll":
+ return true;
+ case "cmd_paste":
+ return this._canDownloadClipboardURL();
+ case "downloadsCmd_clearDownloads":
+ return this.canClearDownloads(this._richlistbox);
+ default:
+ return Array.prototype.every.call(
+ this._richlistbox.selectedItems,
+ element => element._shell.isCommandEnabled(aCommand)
+ );
+ }
+ },
+
+ _copySelectedDownloadsToClipboard() {
+ let urls = Array.from(this._richlistbox.selectedItems, element => {
+ const { source } = element._shell.download;
+ return source?.originalUrl || source?.url;
+ }).filter(Boolean);
+
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(urls.join("\n"));
+ },
+
+ _getURLFromClipboardData() {
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ trans.init(null);
+
+ let flavors = ["text/x-moz-url", "text/plain"];
+ flavors.forEach(trans.addDataFlavor);
+
+ Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard);
+
+ // Getting the data or creating the nsIURI might fail.
+ try {
+ let data = {};
+ trans.getAnyTransferData({}, data);
+ let [url, name] = data.value
+ .QueryInterface(Ci.nsISupportsString)
+ .data.split("\n");
+ if (url) {
+ return [NetUtil.newURI(url).spec, name];
+ }
+ } catch (ex) {}
+
+ return ["", ""];
+ },
+
+ _canDownloadClipboardURL() {
+ let [url /* ,name */] = this._getURLFromClipboardData();
+ return url != "";
+ },
+
+ _downloadURLFromClipboard() {
+ let [url, name] = this._getURLFromClipboardData();
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ let initiatingDoc = browserWin ? browserWin.document : document;
+ DownloadURL(url, name, initiatingDoc);
+ },
+
+ // nsIController
+ doCommand(aCommand) {
+ // Commands may be invoked with keyboard shortcuts even if disabled.
+ if (!this.isCommandEnabled(aCommand)) {
+ return;
+ }
+
+ // If this command is not selection-specific, execute it.
+ if (aCommand in this) {
+ this[aCommand]();
+ return;
+ }
+
+ // Cloning the nodelist into an array to get a frozen list of selected items.
+ // Otherwise, the selectedItems nodelist is live and doCommand may alter the
+ // selection while we are trying to do one particular action, like removing
+ // items from history.
+ let selectedElements = [...this._richlistbox.selectedItems];
+ for (let element of selectedElements) {
+ element._shell.doCommand(aCommand);
+ }
+ },
+
+ // nsIController
+ onEvent() {},
+
+ cmd_copy() {
+ this._copySelectedDownloadsToClipboard();
+ },
+
+ cmd_selectAll() {
+ if (!this.searchTerm) {
+ this._richlistbox.selectAll();
+ return;
+ }
+ // If there is a filtering search term, some rows are hidden and should not
+ // be selected.
+ let oldSuppressOnSelect = this._richlistbox.suppressOnSelect;
+ this._richlistbox.suppressOnSelect = true;
+ this._richlistbox.clearSelection();
+ var item = this._richlistbox.getItemAtIndex(0);
+ while (item) {
+ if (!item.hidden) {
+ this._richlistbox.addItemToSelection(item);
+ }
+ item = this._richlistbox.getNextItem(item, 1);
+ }
+ this._richlistbox.suppressOnSelect = oldSuppressOnSelect;
+ },
+
+ cmd_paste() {
+ this._downloadURLFromClipboard();
+ },
+
+ downloadsCmd_clearDownloads() {
+ this._downloadsData.removeFinished();
+ if (this._place) {
+ PlacesUtils.history
+ .removeVisitsByFilter({
+ transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
+ })
+ .catch(console.error);
+ }
+ // There may be no selection or focus change as a result
+ // of these change, and we want the command updated immediately.
+ goUpdateCommand("downloadsCmd_clearDownloads");
+ },
+
+ onContextMenu(aEvent) {
+ let element = this._richlistbox.selectedItem;
+ if (!element || !element._shell) {
+ return false;
+ }
+
+ let contextMenu = document.getElementById("downloadsContextMenu");
+ DownloadsViewUI.updateContextMenuForElement(contextMenu, element);
+ // Hide the copy location item if there is somehow no URL. We have to do
+ // this here instead of in DownloadsViewUI because DownloadsView doesn't
+ // allow selecting multiple downloads, so in that view the menuitem will be
+ // shown according to whether just the selected item has a source URL.
+ contextMenu.querySelector(".downloadCopyLocationMenuItem").hidden =
+ !Array.prototype.some.call(
+ this._richlistbox.selectedItems,
+ el => !!el._shell.download.source?.url
+ );
+
+ let download = element._shell.download;
+ if (!download.stopped) {
+ // The hasPartialData property of a download may change at any time after
+ // it has started, so ensure we update the related command now.
+ goUpdateCommand("downloadsCmd_pauseResume");
+ }
+
+ return true;
+ },
+
+ onKeyPress(aEvent) {
+ let selectedElements = this._richlistbox.selectedItems;
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ // In the content tree, opening bookmarks by pressing return is only
+ // supported when a single item is selected. To be consistent, do the
+ // same here.
+ if (selectedElements.length == 1) {
+ let element = selectedElements[0];
+ if (element._shell) {
+ element._shell.doDefaultCommand(aEvent);
+ }
+ }
+ } else if (aEvent.charCode == " ".charCodeAt(0)) {
+ let atLeastOneDownloadToggled = false;
+ // Pause/Resume every selected download
+ for (let element of selectedElements) {
+ if (element._shell.isCommandEnabled("downloadsCmd_pauseResume")) {
+ element._shell.doCommand("downloadsCmd_pauseResume");
+ atLeastOneDownloadToggled = true;
+ }
+ }
+
+ if (atLeastOneDownloadToggled) {
+ aEvent.preventDefault();
+ }
+ }
+ },
+
+ onDoubleClick(aEvent) {
+ if (aEvent.button != 0) {
+ return;
+ }
+
+ let selectedElements = this._richlistbox.selectedItems;
+ if (selectedElements.length != 1) {
+ return;
+ }
+
+ let element = selectedElements[0];
+ if (element._shell) {
+ element._shell.doDefaultCommand(aEvent);
+ }
+ },
+
+ onScroll() {
+ this._ensureVisibleElementsAreActive(true);
+ },
+
+ onSelect() {
+ goUpdateDownloadCommands();
+
+ let selectedElements = this._richlistbox.selectedItems;
+ for (let elt of selectedElements) {
+ if (elt._shell) {
+ elt._shell.onSelect();
+ }
+ }
+ },
+
+ onDragStart(aEvent) {
+ // TODO Bug 831358: Support d&d for multiple selection.
+ // For now, we just drag the first element.
+ let selectedItem = this._richlistbox.selectedItem;
+ if (!selectedItem) {
+ return;
+ }
+
+ let targetPath = selectedItem._shell.download.target.path;
+ if (!targetPath) {
+ return;
+ }
+
+ // We must check for existence synchronously because this is a DOM event.
+ let file = new FileUtils.File(targetPath);
+ if (!file.exists()) {
+ return;
+ }
+
+ let dt = aEvent.dataTransfer;
+ dt.mozSetDataAt("application/x-moz-file", file, 0);
+ let url = Services.io.newFileURI(file).spec;
+ dt.setData("text/uri-list", url);
+ dt.setData("text/plain", url);
+ dt.effectAllowed = "copyMove";
+ dt.addElement(selectedItem);
+ },
+
+ onDragOver(aEvent) {
+ let types = aEvent.dataTransfer.types;
+ if (
+ types.includes("text/uri-list") ||
+ types.includes("text/x-moz-url") ||
+ types.includes("text/plain")
+ ) {
+ aEvent.preventDefault();
+ }
+ },
+
+ onDrop(aEvent) {
+ let dt = aEvent.dataTransfer;
+ // If dragged item is from our source, do not try to
+ // redownload already downloaded file.
+ if (dt.mozGetDataAt("application/x-moz-file", 0)) {
+ return;
+ }
+
+ let links = Services.droppedLinkHandler.dropLinks(aEvent);
+ if (!links.length) {
+ return;
+ }
+ aEvent.preventDefault();
+ let browserWin = BrowserWindowTracker.getTopWindow();
+ let initiatingDoc = browserWin ? browserWin.document : document;
+ for (let link of links) {
+ if (link.url.startsWith("about:")) {
+ continue;
+ }
+ DownloadURL(link.url, link.name, initiatingDoc);
+ }
+ },
+};
+Object.setPrototypeOf(
+ DownloadsPlacesView.prototype,
+ DownloadsViewUI.BaseView.prototype
+);
+
+for (let methodName of ["load", "applyFilter", "selectNode", "selectItems"]) {
+ DownloadsPlacesView.prototype[methodName] = function () {
+ throw new Error(
+ "|" + methodName + "| is not implemented by the downloads view."
+ );
+ };
+}
+
+function goUpdateDownloadCommands() {
+ function updateCommandsForObject(object) {
+ for (let name in object) {
+ if (DownloadsViewUI.isCommandName(name)) {
+ goUpdateCommand(name);
+ }
+ }
+ }
+ updateCommandsForObject(DownloadsPlacesView.prototype);
+ updateCommandsForObject(HistoryDownloadElementShell.prototype);
+}
+
+document.addEventListener("DOMContentLoaded", function () {
+ let richListBox = document.getElementById("downloadsListBox");
+ richListBox.addEventListener("scroll", function (event) {
+ return this._placesView.onScroll();
+ });
+ richListBox.addEventListener("keypress", function (event) {
+ return this._placesView.onKeyPress(event);
+ });
+ richListBox.addEventListener("dblclick", function (event) {
+ return this._placesView.onDoubleClick(event);
+ });
+ richListBox.addEventListener("contextmenu", function (event) {
+ return this._placesView.onContextMenu(event);
+ });
+ richListBox.addEventListener("dragstart", function (event) {
+ this._placesView.onDragStart(event);
+ });
+ let dropNode = richListBox;
+ // In about:downloads, also allow drops if the list is empty, by
+ // adding the listener to the document, as the richlistbox is
+ // hidden when it is empty.
+ if (document.documentElement.id == "contentAreaDownloadsView") {
+ dropNode = richListBox.parentNode;
+ }
+ dropNode.addEventListener("dragover", function (event) {
+ richListBox._placesView.onDragOver(event);
+ });
+ dropNode.addEventListener("drop", function (event) {
+ richListBox._placesView.onDrop(event);
+ });
+ richListBox.addEventListener("select", function (event) {
+ this._placesView.onSelect();
+ });
+ richListBox.addEventListener("focus", goUpdateDownloadCommands);
+ richListBox.addEventListener("blur", goUpdateDownloadCommands);
+});
diff --git a/browser/components/downloads/content/contentAreaDownloadsView.css b/browser/components/downloads/content/contentAreaDownloadsView.css
new file mode 100644
index 0000000000..805d13251a
--- /dev/null
+++ b/browser/components/downloads/content/contentAreaDownloadsView.css
@@ -0,0 +1,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/. */
+
+#downloadsListBox:not(:empty) + #downloadsListEmptyDescription,
+#downloadsListBox:empty {
+ display: none;
+}
diff --git a/browser/components/downloads/content/contentAreaDownloadsView.js b/browser/components/downloads/content/contentAreaDownloadsView.js
new file mode 100644
index 0000000000..62c81fc147
--- /dev/null
+++ b/browser/components/downloads/content/contentAreaDownloadsView.js
@@ -0,0 +1,49 @@
+/* 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 allDownloadsView.js */
+
+const { PrivateBrowsingUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"
+);
+
+var ContentAreaDownloadsView = {
+ init() {
+ let box = document.getElementById("downloadsListBox");
+ let suppressionFlag = DownloadsCommon.SUPPRESS_CONTENT_AREA_DOWNLOADS_OPEN;
+ box.addEventListener(
+ "InitialDownloadsLoaded",
+ () => {
+ // Set focus to Downloads list once it is created
+ // And prevent it from showing the focus ring around the richlistbox (Bug 1702694)
+ document
+ .getElementById("downloadsListBox")
+ .focus({ focusVisible: false });
+ // Pause the indicator if the browser is active.
+ if (document.visibilityState === "visible") {
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed |=
+ suppressionFlag;
+ }
+ },
+ { once: true }
+ );
+ let view = new DownloadsPlacesView(box, true, suppressionFlag);
+ document.addEventListener("visibilitychange", aEvent => {
+ let indicator = DownloadsCommon.getIndicatorData(window);
+ if (document.visibilityState === "visible") {
+ indicator.attentionSuppressed |= suppressionFlag;
+ } else {
+ indicator.attentionSuppressed &= ~suppressionFlag;
+ }
+ });
+ // Do not display the Places downloads in private windows
+ if (!PrivateBrowsingUtils.isContentWindowPrivate(window)) {
+ view.place = "place:transition=7&sort=4";
+ }
+ },
+};
+
+window.onload = function () {
+ ContentAreaDownloadsView.init();
+};
diff --git a/browser/components/downloads/content/contentAreaDownloadsView.xhtml b/browser/components/downloads/content/contentAreaDownloadsView.xhtml
new file mode 100644
index 0000000000..4db5d79824
--- /dev/null
+++ b/browser/components/downloads/content/contentAreaDownloadsView.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"?>
+<?xml-stylesheet href="chrome://browser/content/downloads/contentAreaDownloadsView.css"?>
+<?xml-stylesheet href="chrome://browser/skin/downloads/contentAreaDownloadsView.css"?>
+<?xml-stylesheet href="chrome://browser/content/downloads/downloads.css"?>
+<?xml-stylesheet href="chrome://browser/skin/downloads/allDownloadsView.css"?>
+
+<!DOCTYPE window>
+
+<window id="contentAreaDownloadsView"
+ data-l10n-id="downloads-window"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ csp="default-src chrome:; img-src chrome: moz-icon:; object-src 'none'">
+
+ <linkset>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="browser/downloads.ftl" />
+ </linkset>
+
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://browser/content/downloads/contentAreaDownloadsView.js"/>
+ <script src="chrome://browser/content/downloads/allDownloadsView.js"/>
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+#include ../../../../toolkit/content/editMenuKeys.inc.xhtml
+#ifdef XP_MACOSX
+ <keyset id="editMenuKeysExtra">
+ <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/>
+ </keyset>
+#endif
+
+ <richlistbox flex="1"
+ seltype="multiple"
+ id="downloadsListBox"
+ class="allDownloadsListBox"
+ context="downloadsContextMenu"/>
+ <description id="downloadsListEmptyDescription"
+ data-l10n-id="downloads-list-empty"/>
+#include downloadsCommands.inc.xhtml
+#include downloadsContextMenu.inc.xhtml
+</window>
diff --git a/browser/components/downloads/content/downloads.css b/browser/components/downloads/content/downloads.css
new file mode 100644
index 0000000000..a29144638c
--- /dev/null
+++ b/browser/components/downloads/content/downloads.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/. */
+
+/*** Downloads Panel ***/
+
+#downloadsListBox > richlistitem:not([selected]) button {
+ /* Only focus buttons in the selected item. */
+ -moz-user-focus: none;
+}
+
+#downloadsSummary:not([inprogress]) > vbox > #downloadsSummaryProgress,
+#downloadsSummary:not([inprogress]) > vbox > #downloadsSummaryDetails,
+#downloadsFooter:not([showingsummary]) #downloadsSummary {
+ display: none;
+}
+
+#downloadsFooter[showingsummary] > stack:hover > #downloadsSummary,
+#downloadsFooter[showingsummary] > stack:not(:hover) > #downloadsFooterButtons {
+ /* If we used "visibility: hidden;" then the mouseenter event of
+ #downloadsHistory wouldn't be triggered immediately, and the hover styling
+ of the button would not apply until the mouse is moved again.
+
+ "-moz-user-focus: ignore;" prevents the elements with "opacity: 0;" from
+ being focused with the keyboard. */
+ opacity: 0;
+ -moz-user-focus: ignore;
+}
+
+/*** Downloads View ***/
+
+#downloadsListBox.allDownloadsListBox > richlistitem button {
+ /* These buttons should never get focus, as that would "disable"
+ the downloads view controller (it's only used when the richlistbox
+ is focused). */
+ -moz-user-focus: none;
+}
+
+/*** Visibility of controls inside download items ***/
+.download-state[buttonhidden] > .downloadButton {
+ display: none;
+}
+
+.download-state:not([state="6"],/* Blocked (parental) */
+ [state="8"],/* Blocked (dirty) */
+ [state="9"] /* Blocked (policy) */)
+ .downloadBlockedBadge,
+
+.download-state:not([state="-1"],/* Starting (initial) */
+ [state="5"], /* Starting (queued) */
+ [state="0"], /* Downloading */
+ [state="4"], /* Paused */
+ [state="7"] /* Scanning */)
+ .downloadProgress {
+ display: none;
+}
+
+/*** Visibility of download button labels ***/
+
+.download-state:not([state="-1"],/* Starting (initial) */
+ [state="5"], /* Starting (queued) */
+ [state="0"], /* Downloading */
+ [state="4"] /* Paused */)
+ .downloadCancel,
+
+.download-state:not([state="2"], /* Failed */
+ [state="3"] /* Canceled */)
+ .downloadRetry,
+
+.download-state:not([state="1"] /* Finished */)
+ .downloadShow {
+ display: none;
+}
+
+/*** Downloads panel ***/
+
+#downloadsPanel[hasdownloads] #emptyDownloads,
+#downloadsPanel:not([hasdownloads]) #downloadsListBox {
+ display: none;
+}
+
+/*** Downloads panel multiview (main view and blocked-downloads subview) ***/
+
+/* Make the panel wide enough to show the download list items without improperly
+ truncating them. */
+#downloadsPanel-multiView > .panel-viewcontainer,
+#downloadsPanel-multiView > .panel-viewcontainer > .panel-viewstack {
+ max-width: unset;
+}
+
+#downloadsPanel-blockedSubview,
+#downloadsPanel-mainView {
+ font: caption;
+ min-width: 37em;
+ padding: 0.62em;
+}
+
+#downloadsHistory,
+#downloadsFooterButtons {
+ margin: 0;
+}
+
+.downloadMainArea,
+.downloadContainer {
+ min-width: 0;
+}
diff --git a/browser/components/downloads/content/downloads.js b/browser/components/downloads/content/downloads.js
new file mode 100644
index 0000000000..5554c7e2ab
--- /dev/null
+++ b/browser/components/downloads/content/downloads.js
@@ -0,0 +1,1722 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=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/. */
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Handles the Downloads panel user interface for each browser window.
+ *
+ * This file includes the following constructors and global objects:
+ *
+ * DownloadsPanel
+ * Main entry point for the downloads panel interface.
+ *
+ * DownloadsView
+ * Builds and updates the downloads list widget, responding to changes in the
+ * download state and real-time data. In addition, handles part of the user
+ * interaction events raised by the downloads list widget.
+ *
+ * DownloadsViewItem
+ * Builds and updates a single item in the downloads list widget, responding to
+ * changes in the download state and real-time data, and handles the user
+ * interaction events related to a single item in the downloads list widgets.
+ *
+ * DownloadsViewController
+ * Handles part of the user interaction events raised by the downloads list
+ * widget, in particular the "commands" that apply to multiple items, and
+ * dispatches the commands that apply to individual items.
+ */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadsViewUI: "resource:///modules/DownloadsViewUI.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+
+const { Integration } = ChromeUtils.importESModule(
+ "resource://gre/modules/Integration.sys.mjs"
+);
+
+/* global DownloadIntegration */
+Integration.downloads.defineESModuleGetter(
+ this,
+ "DownloadIntegration",
+ "resource://gre/modules/DownloadIntegration.sys.mjs"
+);
+
+// DownloadsPanel
+
+/**
+ * Main entry point for the downloads panel interface.
+ */
+var DownloadsPanel = {
+ // Initialization and termination
+
+ /**
+ * Timeout that re-enables previously disabled download items in the downloads panel
+ * after some time has passed.
+ */
+ _delayTimeout: null,
+
+ /**
+ * Internal state of the downloads panel, based on one of the kState
+ * constants. This is not the same state as the XUL panel element.
+ */
+ _state: 0,
+
+ /** The panel is not linked to downloads data yet. */
+ get kStateUninitialized() {
+ return 0;
+ },
+ /** This object is linked to data, but the panel is invisible. */
+ get kStateHidden() {
+ return 1;
+ },
+ /** The panel will be shown as soon as possible. */
+ get kStateWaitingData() {
+ return 2;
+ },
+ /** The panel is open. */
+ get kStateShown() {
+ return 3;
+ },
+
+ /**
+ * Starts loading the download data in background, without opening the panel.
+ * Use showPanel instead to load the data and open the panel at the same time.
+ */
+ initialize() {
+ DownloadsCommon.log(
+ "Attempting to initialize DownloadsPanel for a window."
+ );
+
+ if (DownloadIntegration.downloadSpamProtection) {
+ DownloadIntegration.downloadSpamProtection.register(
+ DownloadsView,
+ window
+ );
+ }
+
+ if (this._state != this.kStateUninitialized) {
+ DownloadsCommon.log("DownloadsPanel is already initialized.");
+ return;
+ }
+ this._state = this.kStateHidden;
+
+ window.addEventListener("unload", this.onWindowUnload);
+
+ // Load and resume active downloads if required. If there are downloads to
+ // be shown in the panel, they will be loaded asynchronously.
+ DownloadsCommon.initializeAllDataLinks();
+
+ // Now that data loading has eventually started, load the required XUL
+ // elements and initialize our views.
+
+ this.panel.hidden = false;
+ DownloadsViewController.initialize();
+ DownloadsCommon.log("Attaching DownloadsView...");
+ DownloadsCommon.getData(window).addView(DownloadsView);
+ DownloadsCommon.getSummary(window, DownloadsView.kItemCountLimit).addView(
+ DownloadsSummary
+ );
+
+ DownloadsCommon.log(
+ "DownloadsView attached - the panel for this window",
+ "should now see download items come in."
+ );
+ DownloadsPanel._attachEventListeners();
+ DownloadsCommon.log("DownloadsPanel initialized.");
+ },
+
+ /**
+ * Closes the downloads panel and frees the internal resources related to the
+ * downloads. The downloads panel can be reopened later, even after this
+ * function has been called.
+ */
+ terminate() {
+ DownloadsCommon.log("Attempting to terminate DownloadsPanel for a window.");
+ if (this._state == this.kStateUninitialized) {
+ DownloadsCommon.log(
+ "DownloadsPanel was never initialized. Nothing to do."
+ );
+ return;
+ }
+
+ window.removeEventListener("unload", this.onWindowUnload);
+
+ // Ensure that the panel is closed before shutting down.
+ this.hidePanel();
+
+ DownloadsViewController.terminate();
+ DownloadsCommon.getData(window).removeView(DownloadsView);
+ DownloadsCommon.getSummary(
+ window,
+ DownloadsView.kItemCountLimit
+ ).removeView(DownloadsSummary);
+ this._unattachEventListeners();
+
+ if (DownloadIntegration.downloadSpamProtection) {
+ DownloadIntegration.downloadSpamProtection.unregister(window);
+ }
+
+ this._state = this.kStateUninitialized;
+
+ DownloadsSummary.active = false;
+ DownloadsCommon.log("DownloadsPanel terminated.");
+ },
+
+ // Panel interface
+
+ /**
+ * Main panel element in the browser window.
+ */
+ get panel() {
+ delete this.panel;
+ return (this.panel = document.getElementById("downloadsPanel"));
+ },
+
+ /**
+ * Starts opening the downloads panel interface, anchored to the downloads
+ * button of the browser window. The list of downloads to display is
+ * initialized the first time this method is called, and the panel is shown
+ * only when data is ready.
+ */
+ showPanel(openedManually = false, isKeyPress = false) {
+ Services.telemetry.scalarAdd("downloads.panel_shown", 1);
+ DownloadsCommon.log("Opening the downloads panel.");
+
+ this._openedManually = openedManually;
+ this._preventFocusRing = !openedManually || !isKeyPress;
+
+ if (this.isPanelShowing) {
+ DownloadsCommon.log("Panel is already showing - focusing instead.");
+ this._focusPanel();
+ return;
+ }
+
+ // As a belt-and-suspenders check, ensure the button is not hidden.
+ DownloadsButton.unhide();
+
+ this.initialize();
+ // Delay displaying the panel because this function will sometimes be
+ // called while another window is closing (like the window for selecting
+ // whether to save or open the file), and that would cause the panel to
+ // close immediately.
+ setTimeout(() => this._openPopupIfDataReady(), 0);
+
+ DownloadsCommon.log("Waiting for the downloads panel to appear.");
+ this._state = this.kStateWaitingData;
+ },
+
+ /**
+ * Hides the downloads panel, if visible, but keeps the internal state so that
+ * the panel can be reopened quickly if required.
+ */
+ hidePanel() {
+ DownloadsCommon.log("Closing the downloads panel.");
+
+ if (!this.isPanelShowing) {
+ DownloadsCommon.log("Downloads panel is not showing - nothing to do.");
+ return;
+ }
+
+ PanelMultiView.hidePopup(this.panel);
+
+ // Ensure that we allow the panel to be reopened. Note that, if the popup
+ // was open, then the onPopupHidden event handler has already updated the
+ // current state, otherwise we must update the state ourselves.
+ this._state = this.kStateHidden;
+ DownloadsCommon.log("Downloads panel is now closed.");
+ },
+
+ /**
+ * Indicates whether the panel is shown or will be shown.
+ */
+ get isPanelShowing() {
+ return (
+ this._state == this.kStateWaitingData || this._state == this.kStateShown
+ );
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "mousemove":
+ if (
+ !DownloadsView.contextMenuOpen &&
+ !DownloadsView.subViewOpen &&
+ this.panel.contains(document.activeElement)
+ ) {
+ // Let mouse movement remove focus rings and reset focus in the panel.
+ // This behavior is copied from PanelMultiView.
+ document.activeElement.blur();
+ DownloadsView.richListBox.removeAttribute("force-focus-visible");
+ this._preventFocusRing = true;
+ this._focusPanel();
+ }
+ break;
+ case "keydown":
+ this._onKeyDown(aEvent);
+ break;
+ case "keypress":
+ this._onKeyPress(aEvent);
+ break;
+ case "focus":
+ case "select":
+ this._onSelect(aEvent);
+ break;
+ }
+ },
+
+ // Callback functions from DownloadsView
+
+ /**
+ * Called after data loading finished.
+ */
+ onViewLoadCompleted() {
+ this._openPopupIfDataReady();
+ },
+
+ // User interface event functions
+
+ onWindowUnload() {
+ // This function is registered as an event listener, we can't use "this".
+ DownloadsPanel.terminate();
+ },
+
+ onPopupShown(aEvent) {
+ // Ignore events raised by nested popups.
+ if (aEvent.target != aEvent.currentTarget) {
+ return;
+ }
+
+ DownloadsCommon.log("Downloads panel has shown.");
+ this._state = this.kStateShown;
+
+ // Since at most one popup is open at any given time, we can set globally.
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed |=
+ DownloadsCommon.SUPPRESS_PANEL_OPEN;
+
+ // Ensure that the first item is selected when the panel is focused.
+ if (DownloadsView.richListBox.itemCount > 0) {
+ DownloadsView.richListBox.selectedIndex = 0;
+ }
+
+ this._focusPanel();
+ },
+
+ onPopupHidden(aEvent) {
+ // Ignore events raised by nested popups.
+ if (aEvent.target != aEvent.currentTarget) {
+ return;
+ }
+
+ DownloadsCommon.log("Downloads panel has hidden.");
+
+ if (this._delayTimeout) {
+ DownloadsView.richListBox.removeAttribute("disabled");
+ clearTimeout(this._delayTimeout);
+ this._stopWatchingForSpammyDownloadActivation();
+ this._delayTimeout = null;
+ }
+
+ DownloadsView.richListBox.removeAttribute("force-focus-visible");
+
+ // Since at most one popup is open at any given time, we can set globally.
+ DownloadsCommon.getIndicatorData(window).attentionSuppressed &=
+ ~DownloadsCommon.SUPPRESS_PANEL_OPEN;
+
+ // Allow the anchor to be hidden.
+ DownloadsButton.releaseAnchor();
+
+ // Allow the panel to be reopened.
+ this._state = this.kStateHidden;
+ },
+
+ // Related operations
+
+ /**
+ * Shows or focuses the user interface dedicated to downloads history.
+ */
+ showDownloadsHistory() {
+ DownloadsCommon.log("Showing download history.");
+ // Hide the panel before showing another window, otherwise focus will return
+ // to the browser window when the panel closes automatically.
+ this.hidePanel();
+
+ BrowserDownloadsUI();
+ },
+
+ // Internal functions
+
+ /**
+ * Attach event listeners to a panel element. These listeners should be
+ * removed in _unattachEventListeners. This is called automatically after the
+ * panel has successfully loaded.
+ */
+ _attachEventListeners() {
+ // Handle keydown to support accel-V.
+ this.panel.addEventListener("keydown", this);
+ // Handle keypress to be able to preventDefault() events before they reach
+ // the richlistbox, for keyboard navigation.
+ this.panel.addEventListener("keypress", this);
+ this.panel.addEventListener("mousemove", this);
+ DownloadsView.richListBox.addEventListener("focus", this);
+ DownloadsView.richListBox.addEventListener("select", this);
+ },
+
+ /**
+ * Unattach event listeners that were added in _attachEventListeners. This
+ * is called automatically on panel termination.
+ */
+ _unattachEventListeners() {
+ this.panel.removeEventListener("keydown", this);
+ this.panel.removeEventListener("keypress", this);
+ this.panel.removeEventListener("mousemove", this);
+ DownloadsView.richListBox.removeEventListener("focus", this);
+ DownloadsView.richListBox.removeEventListener("select", this);
+ },
+
+ _onKeyPress(aEvent) {
+ // Handle unmodified keys only.
+ if (aEvent.altKey || aEvent.ctrlKey || aEvent.shiftKey || aEvent.metaKey) {
+ return;
+ }
+
+ // Pass keypress events to the richlistbox view when it's focused.
+ if (document.activeElement === DownloadsView.richListBox) {
+ DownloadsView.onDownloadKeyPress(aEvent);
+ }
+ },
+
+ /**
+ * Keydown listener that listens for the keys to start key focusing, as well
+ * as the the accel-V "paste" event, which initiates a file download if the
+ * pasted item can be resolved to a URI.
+ */
+ _onKeyDown(aEvent) {
+ if (DownloadsView.richListBox.hasAttribute("disabled")) {
+ this._handlePotentiallySpammyDownloadActivation(aEvent);
+ return;
+ }
+
+ let richListBox = DownloadsView.richListBox;
+
+ // If the user has pressed the up or down cursor key, force-enable focus
+ // indicators for the richlistbox. :focus-visible doesn't work in this case
+ // because the the focused element may not change here if the richlistbox
+ // already had focus. The force-focus-visible attribute will be removed
+ // again if the user moves the mouse on the panel or if the panel is closed.
+ if (
+ aEvent.keyCode == aEvent.DOM_VK_UP ||
+ aEvent.keyCode == aEvent.DOM_VK_DOWN
+ ) {
+ richListBox.setAttribute("force-focus-visible", "true");
+ }
+
+ // If the footer is focused and the downloads list has at least 1 element
+ // in it, focus the last element in the list when going up.
+ if (aEvent.keyCode == aEvent.DOM_VK_UP && richListBox.firstElementChild) {
+ if (
+ document
+ .getElementById("downloadsFooter")
+ .contains(document.activeElement)
+ ) {
+ richListBox.selectedItem = richListBox.lastElementChild;
+ richListBox.focus();
+ aEvent.preventDefault();
+ return;
+ }
+ }
+
+ if (aEvent.keyCode == aEvent.DOM_VK_DOWN) {
+ // If the last element in the list is selected, or the footer is already
+ // focused, focus the footer.
+ if (
+ DownloadsView.canChangeSelectedItem &&
+ (richListBox.selectedItem === richListBox.lastElementChild ||
+ document
+ .getElementById("downloadsFooter")
+ .contains(document.activeElement))
+ ) {
+ richListBox.selectedIndex = -1;
+ DownloadsFooter.focus();
+ aEvent.preventDefault();
+ return;
+ }
+ }
+
+ let pasting =
+ aEvent.keyCode == aEvent.DOM_VK_V && aEvent.getModifierState("Accel");
+
+ if (!pasting) {
+ return;
+ }
+
+ DownloadsCommon.log("Received a paste event.");
+
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ trans.init(null);
+ let flavors = ["text/x-moz-url", "text/plain"];
+ flavors.forEach(trans.addDataFlavor);
+ Services.clipboard.getData(trans, Services.clipboard.kGlobalClipboard);
+ // Getting the data or creating the nsIURI might fail
+ try {
+ let data = {};
+ trans.getAnyTransferData({}, data);
+ let [url, name] = data.value
+ .QueryInterface(Ci.nsISupportsString)
+ .data.split("\n");
+ if (!url) {
+ return;
+ }
+
+ let uri = NetUtil.newURI(url);
+ DownloadsCommon.log("Pasted URL seems valid. Starting download.");
+ DownloadURL(uri.spec, name, document);
+ } catch (ex) {}
+ },
+
+ _onSelect() {
+ let richlistbox = DownloadsView.richListBox;
+ richlistbox.itemChildren.forEach(item => {
+ let button = item.querySelector("button");
+ if (item.selected) {
+ button.removeAttribute("tabindex");
+ } else {
+ button.setAttribute("tabindex", -1);
+ }
+ });
+ },
+
+ /**
+ * Move focus to the main element in the downloads panel, unless another
+ * element in the panel is already focused.
+ */
+ _focusPanel() {
+ // We may be invoked while the panel is still waiting to be shown.
+ if (this._state != this.kStateShown) {
+ return;
+ }
+
+ if (
+ document.activeElement &&
+ (this.panel.contains(document.activeElement) ||
+ this.panel.shadowRoot.contains(document.activeElement))
+ ) {
+ return;
+ }
+ let focusOptions = {};
+ if (this._preventFocusRing) {
+ focusOptions.focusVisible = false;
+ }
+ if (DownloadsView.richListBox.itemCount > 0) {
+ if (DownloadsView.canChangeSelectedItem) {
+ DownloadsView.richListBox.selectedIndex = 0;
+ }
+ DownloadsView.richListBox.focus(focusOptions);
+ } else {
+ DownloadsFooter.focus(focusOptions);
+ }
+ },
+
+ _delayPopupItems() {
+ DownloadsView.richListBox.setAttribute("disabled", true);
+ this._startWatchingForSpammyDownloadActivation();
+
+ this._refreshDelayTimer();
+ },
+
+ _refreshDelayTimer() {
+ // If timeout already exists, overwrite it to avoid multiple timeouts.
+ if (this._delayTimeout) {
+ clearTimeout(this._delayTimeout);
+ }
+
+ let delay = Services.prefs.getIntPref("security.dialog_enable_delay");
+ this._delayTimeout = setTimeout(() => {
+ DownloadsView.richListBox.removeAttribute("disabled");
+ this._stopWatchingForSpammyDownloadActivation();
+ this._focusPanel();
+ this._delayTimeout = null;
+ }, delay);
+ },
+
+ _startWatchingForSpammyDownloadActivation() {
+ Services.els.addSystemEventListener(window, "keydown", this, true);
+ },
+
+ _lastBeepTime: 0,
+ _handlePotentiallySpammyDownloadActivation(aEvent) {
+ if (aEvent.key == "Enter" || aEvent.key == " ") {
+ // Throttle our beeping to a maximum of once per second, otherwise it
+ // appears on Win10 that beeps never make it through at all.
+ if (Date.now() - this._lastBeepTime > 1000) {
+ Cc["@mozilla.org/sound;1"].getService(Ci.nsISound).beep();
+ this._lastBeepTime = Date.now();
+ }
+
+ this._refreshDelayTimer();
+ }
+ },
+
+ _stopWatchingForSpammyDownloadActivation() {
+ Services.els.removeSystemEventListener(window, "keydown", this, true);
+ },
+
+ /**
+ * Opens the downloads panel when data is ready to be displayed.
+ */
+ _openPopupIfDataReady() {
+ // We don't want to open the popup if we already displayed it, or if we are
+ // still loading data.
+ if (this._state != this.kStateWaitingData || DownloadsView.loading) {
+ return;
+ }
+
+ // At this point, if the window is minimized, opening the panel could fail
+ // without any notification, and there would be no way to either open or
+ // close the panel any more. To prevent this, check if the window is
+ // minimized and in that case force the panel to the closed state.
+ if (window.windowState == window.STATE_MINIMIZED) {
+ this._state = this.kStateHidden;
+ return;
+ }
+
+ // Ensure the anchor is visible. If that is not possible, show the panel
+ // anchored to the top area of the window, near the default anchor position.
+ let anchor = DownloadsButton.getAnchor();
+
+ if (!anchor) {
+ DownloadsCommon.error("Downloads button cannot be found.");
+ this._state = this.kStateHidden;
+ return;
+ }
+
+ let onBookmarksToolbar = !!anchor.closest("#PersonalToolbar");
+ this.panel.classList.toggle("bookmarks-toolbar", onBookmarksToolbar);
+
+ // When the panel is opened, we check if the target files of visible items
+ // still exist, and update the allowed items interactions accordingly. We
+ // do these checks on a background thread, and don't prevent the panel to
+ // be displayed while these checks are being performed.
+ for (let viewItem of DownloadsView._visibleViewItems.values()) {
+ viewItem.download.refresh().catch(console.error);
+ }
+
+ DownloadsCommon.log("Opening downloads panel popup.");
+
+ // Delay displaying the panel because this function will sometimes be
+ // called while another window is closing (like the window for selecting
+ // whether to save or open the file), and that would cause the panel to
+ // close immediately.
+ setTimeout(() => {
+ PanelMultiView.openPopup(
+ this.panel,
+ anchor,
+ "bottomright topright",
+ 0,
+ 0,
+ false,
+ null
+ ).catch(e => {
+ console.error(e);
+ this._state = this.kStateHidden;
+ });
+
+ if (!this._openedManually) {
+ this._delayPopupItems();
+ }
+ }, 0);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "DownloadsPanel", DownloadsPanel);
+
+// DownloadsView
+
+/**
+ * Builds and updates the downloads list widget, responding to changes in the
+ * download state and real-time data. In addition, handles part of the user
+ * interaction events raised by the downloads list widget.
+ */
+var DownloadsView = {
+ // Functions handling download items in the list
+
+ /**
+ * Maximum number of items shown by the list at any given time.
+ */
+ kItemCountLimit: 5,
+
+ /**
+ * Indicates whether there is a DownloadsBlockedSubview open.
+ */
+ subViewOpen: false,
+
+ /**
+ * Indicates whether we are still loading downloads data asynchronously.
+ */
+ loading: false,
+
+ /**
+ * Ordered array of all Download objects. We need to keep this array because
+ * only a limited number of items are shown at once, and if an item that is
+ * currently visible is removed from the list, we might need to take another
+ * item from the array and make it appear at the bottom.
+ */
+ _downloads: [],
+
+ /**
+ * Associates the visible Download objects with their corresponding
+ * DownloadsViewItem object. There is a limited number of view items in the
+ * panel at any given time.
+ */
+ _visibleViewItems: new Map(),
+
+ /**
+ * Called when the number of items in the list changes.
+ */
+ _itemCountChanged() {
+ DownloadsCommon.log(
+ "The downloads item count has changed - we are tracking",
+ this._downloads.length,
+ "downloads in total."
+ );
+ let count = this._downloads.length;
+ let hiddenCount = count - this.kItemCountLimit;
+
+ if (count > 0) {
+ DownloadsCommon.log(
+ "Setting the panel's hasdownloads attribute to true."
+ );
+ DownloadsPanel.panel.setAttribute("hasdownloads", "true");
+ } else {
+ DownloadsCommon.log("Removing the panel's hasdownloads attribute.");
+ DownloadsPanel.panel.removeAttribute("hasdownloads");
+ }
+
+ // If we've got some hidden downloads, we should activate the
+ // DownloadsSummary. The DownloadsSummary will determine whether or not
+ // it's appropriate to actually display the summary.
+ DownloadsSummary.active = hiddenCount > 0;
+ },
+
+ /**
+ * Element corresponding to the list of downloads.
+ */
+ get richListBox() {
+ delete this.richListBox;
+ return (this.richListBox = document.getElementById("downloadsListBox"));
+ },
+
+ /**
+ * Element corresponding to the button for showing more downloads.
+ */
+ get downloadsHistory() {
+ delete this.downloadsHistory;
+ return (this.downloadsHistory =
+ document.getElementById("downloadsHistory"));
+ },
+
+ // Callback functions from DownloadsData
+
+ /**
+ * Called before multiple downloads are about to be loaded.
+ */
+ onDownloadBatchStarting() {
+ DownloadsCommon.log("onDownloadBatchStarting called for DownloadsView.");
+ this.loading = true;
+ },
+
+ /**
+ * Called after data loading finished.
+ */
+ onDownloadBatchEnded() {
+ DownloadsCommon.log("onDownloadBatchEnded called for DownloadsView.");
+
+ this.loading = false;
+
+ // We suppressed item count change notifications during the batch load, at
+ // this point we should just call the function once.
+ this._itemCountChanged();
+
+ // Notify the panel that all the initially available downloads have been
+ // loaded. This ensures that the interface is visible, if still required.
+ DownloadsPanel.onViewLoadCompleted();
+ },
+
+ /**
+ * Called when a new download data item is available, either during the
+ * asynchronous data load or when a new download is started.
+ *
+ * @param aDownload
+ * Download object that was just added.
+ */
+ onDownloadAdded(download) {
+ DownloadsCommon.log("A new download data item was added");
+
+ this._downloads.unshift(download);
+
+ // The newly added item is visible in the panel and we must add the
+ // corresponding element. If the list overflows, remove the last item from
+ // the panel to make room for the new one that we just added at the top.
+ this._addViewItem(download, true);
+ if (this._downloads.length > this.kItemCountLimit) {
+ this._removeViewItem(this._downloads[this.kItemCountLimit]);
+ }
+
+ // For better performance during batch loads, don't update the count for
+ // every item, because the interface won't be visible until load finishes.
+ if (!this.loading) {
+ this._itemCountChanged();
+ }
+ },
+
+ onDownloadChanged(download) {
+ let viewItem = this._visibleViewItems.get(download);
+ if (viewItem) {
+ viewItem.onChanged();
+ }
+ },
+
+ /**
+ * Called when a data item is removed. Ensures that the widget associated
+ * with the view item is removed from the user interface.
+ *
+ * @param download
+ * Download object that is being removed.
+ */
+ onDownloadRemoved(download) {
+ DownloadsCommon.log("A download data item was removed.");
+
+ let itemIndex = this._downloads.indexOf(download);
+ this._downloads.splice(itemIndex, 1);
+
+ if (itemIndex < this.kItemCountLimit) {
+ // The item to remove is visible in the panel.
+ this._removeViewItem(download);
+ if (this._downloads.length >= this.kItemCountLimit) {
+ // Reinsert the next item into the panel.
+ this._addViewItem(this._downloads[this.kItemCountLimit - 1], false);
+ }
+ }
+
+ this._itemCountChanged();
+ },
+
+ /**
+ * Associates each richlistitem for a download with its corresponding
+ * DownloadsViewItem object.
+ */
+ _itemsForElements: new Map(),
+
+ itemForElement(element) {
+ return this._itemsForElements.get(element);
+ },
+
+ /**
+ * Creates a new view item associated with the specified data item, and adds
+ * it to the top or the bottom of the list.
+ */
+ _addViewItem(download, aNewest) {
+ DownloadsCommon.log(
+ "Adding a new DownloadsViewItem to the downloads list.",
+ "aNewest =",
+ aNewest
+ );
+
+ let element = document.createXULElement("richlistitem");
+ element.setAttribute("align", "center");
+
+ let viewItem = new DownloadsViewItem(download, element);
+ this._visibleViewItems.set(download, viewItem);
+ this._itemsForElements.set(element, viewItem);
+ if (aNewest) {
+ this.richListBox.insertBefore(
+ element,
+ this.richListBox.firstElementChild
+ );
+ } else {
+ this.richListBox.appendChild(element);
+ }
+ viewItem.ensureActive();
+ },
+
+ /**
+ * Removes the view item associated with the specified data item.
+ */
+ _removeViewItem(download) {
+ DownloadsCommon.log(
+ "Removing a DownloadsViewItem from the downloads list."
+ );
+ let element = this._visibleViewItems.get(download).element;
+ let previousSelectedIndex = this.richListBox.selectedIndex;
+ this.richListBox.removeChild(element);
+ if (previousSelectedIndex != -1) {
+ this.richListBox.selectedIndex = Math.min(
+ previousSelectedIndex,
+ this.richListBox.itemCount - 1
+ );
+ }
+ this._visibleViewItems.delete(download);
+ this._itemsForElements.delete(element);
+ },
+
+ // User interface event functions
+
+ onDownloadClick(aEvent) {
+ // Handle primary clicks in the main area only:
+ if (aEvent.button == 0 && aEvent.target.closest(".downloadMainArea")) {
+ let target = aEvent.target;
+ while (target.nodeName != "richlistitem") {
+ target = target.parentNode;
+ }
+ let download = DownloadsView.itemForElement(target).download;
+ if (download.succeeded) {
+ download._launchedFromPanel = true;
+ }
+ let command = "downloadsCmd_open";
+ if (download.hasBlockedData) {
+ command = "downloadsCmd_showBlockedInfo";
+ } else if (aEvent.shiftKey || aEvent.ctrlKey || aEvent.metaKey) {
+ // We adjust the command for supported modifiers to suggest where the download
+ // may be opened
+ let openWhere = target.ownerGlobal.whereToOpenLink(aEvent, false, true);
+ if (["tab", "window", "tabshifted"].includes(openWhere)) {
+ command += ":" + openWhere;
+ }
+ }
+ // Toggle opening the file after the download has completed
+ if (!download.stopped && command.startsWith("downloadsCmd_open")) {
+ download.launchWhenSucceeded = !download.launchWhenSucceeded;
+ download._launchedFromPanel = download.launchWhenSucceeded;
+ }
+
+ DownloadsCommon.log("onDownloadClick, resolved command: ", command);
+ goDoCommand(command);
+ }
+ },
+
+ onDownloadButton(event) {
+ let target = event.target.closest("richlistitem");
+ DownloadsView.itemForElement(target).onButton();
+ },
+
+ /**
+ * Handles keypress events on a download item.
+ */
+ onDownloadKeyPress(aEvent) {
+ // Pressing the key on buttons should not invoke the action because the
+ // event has already been handled by the button itself.
+ if (
+ aEvent.originalTarget.hasAttribute("command") ||
+ aEvent.originalTarget.hasAttribute("oncommand")
+ ) {
+ return;
+ }
+
+ if (aEvent.charCode == " ".charCodeAt(0)) {
+ aEvent.preventDefault();
+ goDoCommand("downloadsCmd_pauseResume");
+ return;
+ }
+
+ if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) {
+ let readyToDownload = !DownloadsView.richListBox.disabled;
+ if (readyToDownload) {
+ goDoCommand("downloadsCmd_doDefault");
+ }
+ }
+ },
+
+ get contextMenu() {
+ let menu = document.getElementById("downloadsContextMenu");
+ if (menu) {
+ delete this.contextMenu;
+ this.contextMenu = menu;
+ }
+ return menu;
+ },
+
+ /**
+ * Indicates whether there is an open contextMenu for a download item.
+ */
+ get contextMenuOpen() {
+ return this.contextMenu.state != "closed";
+ },
+
+ /**
+ * Whether it's possible to change the currently selected item.
+ */
+ get canChangeSelectedItem() {
+ // When the context menu or a subview are open, the selected item should
+ // not change.
+ return !this.contextMenuOpen && !this.subViewOpen;
+ },
+
+ /**
+ * Mouse listeners to handle selection on hover.
+ */
+ onDownloadMouseOver(aEvent) {
+ let item = aEvent.target.closest("richlistitem,richlistbox");
+ if (item.localName != "richlistitem") {
+ return;
+ }
+
+ if (aEvent.target.classList.contains("downloadButton")) {
+ item.classList.add("downloadHoveringButton");
+ }
+
+ item.classList.toggle(
+ "hoveringMainArea",
+ aEvent.target.closest(".downloadMainArea")
+ );
+
+ if (this.canChangeSelectedItem) {
+ this.richListBox.selectedItem = item;
+ }
+ },
+
+ onDownloadMouseOut(aEvent) {
+ let item = aEvent.target.closest("richlistitem,richlistbox");
+ if (item.localName != "richlistitem") {
+ return;
+ }
+
+ if (aEvent.target.classList.contains("downloadButton")) {
+ item.classList.remove("downloadHoveringButton");
+ }
+
+ // If the destination element is outside of the richlistitem, clear the
+ // selection.
+ if (this.canChangeSelectedItem && !item.contains(aEvent.relatedTarget)) {
+ this.richListBox.selectedIndex = -1;
+ }
+ },
+
+ onDownloadContextMenu(aEvent) {
+ let element = aEvent.originalTarget.closest("richlistitem");
+ if (!element) {
+ aEvent.preventDefault();
+ return;
+ }
+ // Ensure the selected item is the expected one, so commands and the
+ // context menu are updated appropriately.
+ this.richListBox.selectedItem = element;
+ DownloadsViewController.updateCommands();
+
+ DownloadsViewUI.updateContextMenuForElement(this.contextMenu, element);
+ // Hide the copy location item if there is somehow no URL. We have to do
+ // this here instead of in DownloadsViewUI because DownloadsPlacesView
+ // allows selecting multiple downloads, so in that view the menuitem will be
+ // shown according to whether at least one of the selected items has a URL.
+ this.contextMenu.querySelector(".downloadCopyLocationMenuItem").hidden =
+ !element._shell.download.source?.url;
+ },
+
+ onDownloadDragStart(aEvent) {
+ let element = aEvent.target.closest("richlistitem");
+ if (!element) {
+ return;
+ }
+
+ // We must check for existence synchronously because this is a DOM event.
+ let file = new FileUtils.File(
+ DownloadsView.itemForElement(element).download.target.path
+ );
+ if (!file.exists()) {
+ return;
+ }
+
+ let dataTransfer = aEvent.dataTransfer;
+ dataTransfer.mozSetDataAt("application/x-moz-file", file, 0);
+ dataTransfer.effectAllowed = "copyMove";
+ let spec = NetUtil.newURI(file).spec;
+ dataTransfer.setData("text/uri-list", spec);
+ dataTransfer.setData("text/plain", spec);
+ dataTransfer.addElement(element);
+
+ aEvent.stopPropagation();
+ },
+};
+
+XPCOMUtils.defineConstant(this, "DownloadsView", DownloadsView);
+
+// DownloadsViewItem
+
+/**
+ * Builds and updates a single item in the downloads list widget, responding to
+ * changes in the download state and real-time data, and handles the user
+ * interaction events related to a single item in the downloads list widgets.
+ *
+ * @param download
+ * Download object to be associated with the view item.
+ * @param aElement
+ * XUL element corresponding to the single download item in the view.
+ */
+
+class DownloadsViewItem extends DownloadsViewUI.DownloadElementShell {
+ constructor(download, aElement) {
+ super();
+
+ this.download = download;
+ this.element = aElement;
+ this.element._shell = this;
+
+ this.element.setAttribute("type", "download");
+ this.element.classList.add("download-state");
+
+ this.isPanel = true;
+ }
+
+ onChanged() {
+ let newState = DownloadsCommon.stateOfDownload(this.download);
+ if (this.downloadState !== newState) {
+ this.downloadState = newState;
+ this._updateState();
+ } else {
+ this._updateStateInner();
+ }
+ }
+
+ isCommandEnabled(aCommand) {
+ switch (aCommand) {
+ case "downloadsCmd_open":
+ case "downloadsCmd_open:current":
+ case "downloadsCmd_open:tab":
+ case "downloadsCmd_open:tabshifted":
+ case "downloadsCmd_open:window":
+ case "downloadsCmd_alwaysOpenSimilarFiles": {
+ if (!this.download.succeeded) {
+ return false;
+ }
+
+ let file = new FileUtils.File(this.download.target.path);
+ return file.exists();
+ }
+ case "downloadsCmd_show": {
+ let file = new FileUtils.File(this.download.target.path);
+ if (file.exists()) {
+ return true;
+ }
+
+ if (!this.download.target.partFilePath) {
+ return false;
+ }
+
+ let partFile = new FileUtils.File(this.download.target.partFilePath);
+ return partFile.exists();
+ }
+ case "downloadsCmd_copyLocation":
+ return !!this.download.source?.url;
+ case "cmd_delete":
+ case "downloadsCmd_doDefault":
+ return true;
+ case "downloadsCmd_showBlockedInfo":
+ return this.download.hasBlockedData;
+ }
+ return DownloadsViewUI.DownloadElementShell.prototype.isCommandEnabled.call(
+ this,
+ aCommand
+ );
+ }
+
+ doCommand(aCommand) {
+ if (this.isCommandEnabled(aCommand)) {
+ let [command, modifier] = aCommand.split(":");
+ // split off an optional command "modifier" into an argument,
+ // e.g. "downloadsCmd_open:window"
+ this[command](modifier);
+ }
+ }
+
+ // Item commands
+
+ downloadsCmd_unblock() {
+ DownloadsPanel.hidePanel();
+ this.confirmUnblock(window, "unblock");
+ }
+
+ downloadsCmd_chooseUnblock() {
+ DownloadsPanel.hidePanel();
+ this.confirmUnblock(window, "chooseUnblock");
+ }
+
+ downloadsCmd_unblockAndOpen() {
+ DownloadsPanel.hidePanel();
+ this.unblockAndOpenDownload().catch(console.error);
+ }
+ downloadsCmd_unblockAndSave() {
+ DownloadsPanel.hidePanel();
+ this.unblockAndSave();
+ }
+
+ downloadsCmd_open(openWhere) {
+ super.downloadsCmd_open(openWhere);
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ // Otherwise, we'd have to wait for the file-type handler to execute
+ // before the panel would close. This also helps to prevent the user from
+ // accidentally opening a file several times.
+ DownloadsPanel.hidePanel();
+ }
+
+ downloadsCmd_openInSystemViewer() {
+ super.downloadsCmd_openInSystemViewer();
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ DownloadsPanel.hidePanel();
+ }
+
+ downloadsCmd_alwaysOpenInSystemViewer() {
+ super.downloadsCmd_alwaysOpenInSystemViewer();
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ DownloadsPanel.hidePanel();
+ }
+
+ downloadsCmd_alwaysOpenSimilarFiles() {
+ super.downloadsCmd_alwaysOpenSimilarFiles();
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ DownloadsPanel.hidePanel();
+ }
+
+ downloadsCmd_show() {
+ let file = new FileUtils.File(this.download.target.path);
+ DownloadsCommon.showDownloadedFile(file);
+
+ // We explicitly close the panel here to give the user the feedback that
+ // their click has been received, and we're handling the action.
+ // Otherwise, we'd have to wait for the operating system file manager
+ // window to open before the panel closed. This also helps to prevent the
+ // user from opening the containing folder several times.
+ DownloadsPanel.hidePanel();
+ }
+
+ async downloadsCmd_deleteFile() {
+ await super.downloadsCmd_deleteFile();
+ // Protects against an unusual edge case where the user:
+ // 1) downloads a file with Firefox; 2) deletes the file from outside of Firefox, e.g., a file manager;
+ // 3) downloads the same file from the same source; 4) opens the downloads panel and uses the menuitem to delete one of those 2 files;
+ // Under those conditions, Firefox will make 2 view items even though there's only 1 file.
+ // Using this method will only delete the view item it was called on, because this instance is not aware of other view items with identical targets.
+ // So the remaining view item needs to be refreshed to hide the "Delete" option.
+ // That example only concerns 2 duplicate view items but you can have an arbitrary number, so iterate over all items...
+ for (let viewItem of DownloadsView._visibleViewItems.values()) {
+ viewItem.download.refresh().catch(console.error);
+ }
+ // Don't use DownloadsPanel.hidePanel for this method because it will remove
+ // the view item from the list, which is already sufficient feedback.
+ }
+
+ downloadsCmd_showBlockedInfo() {
+ DownloadsBlockedSubview.toggle(
+ this.element,
+ ...this.rawBlockedTitleAndDetails
+ );
+ }
+
+ downloadsCmd_openReferrer() {
+ openURL(this.download.source.referrerInfo.originalReferrer);
+ }
+
+ downloadsCmd_copyLocation() {
+ DownloadsCommon.copyDownloadLink(this.download);
+ }
+
+ downloadsCmd_doDefault() {
+ let defaultCommand = this.currentDefaultCommandName;
+ if (defaultCommand && this.isCommandEnabled(defaultCommand)) {
+ this.doCommand(defaultCommand);
+ }
+ }
+}
+
+// DownloadsViewController
+
+/**
+ * Handles part of the user interaction events raised by the downloads list
+ * widget, in particular the "commands" that apply to multiple items, and
+ * dispatches the commands that apply to individual items.
+ */
+var DownloadsViewController = {
+ // Initialization and termination
+
+ initialize() {
+ window.controllers.insertControllerAt(0, this);
+ },
+
+ terminate() {
+ window.controllers.removeController(this);
+ },
+
+ // nsIController
+
+ supportsCommand(aCommand) {
+ if (aCommand === "downloadsCmd_clearList") {
+ return true;
+ }
+ // Firstly, determine if this is a command that we can handle.
+ if (!DownloadsViewUI.isCommandName(aCommand)) {
+ return false;
+ }
+ // Strip off any :modifier suffix before checking if the command name is
+ // a method on our view
+ let [command] = aCommand.split(":");
+ if (!(command in this) && !(command in DownloadsViewItem.prototype)) {
+ return false;
+ }
+ // The currently supported commands depend on whether the blocked subview is
+ // showing. If it is, then take the following path.
+ if (DownloadsView.subViewOpen) {
+ let blockedSubviewCmds = [
+ "downloadsCmd_unblockAndOpen",
+ "cmd_delete",
+ "downloadsCmd_unblockAndSave",
+ ];
+ return blockedSubviewCmds.includes(aCommand);
+ }
+ // If the blocked subview is not showing, then determine if focus is on a
+ // control in the downloads list.
+ let element = document.commandDispatcher.focusedElement;
+ while (element && element != DownloadsView.richListBox) {
+ element = element.parentNode;
+ }
+ // We should handle the command only if the downloads list is among the
+ // ancestors of the focused element.
+ return !!element;
+ },
+
+ isCommandEnabled(aCommand) {
+ // Handle commands that are not selection-specific.
+ if (aCommand == "downloadsCmd_clearList") {
+ return DownloadsCommon.getData(window).canRemoveFinished;
+ }
+
+ // Other commands are selection-specific.
+ let element = DownloadsView.richListBox.selectedItem;
+ return (
+ element &&
+ DownloadsView.itemForElement(element).isCommandEnabled(aCommand)
+ );
+ },
+
+ doCommand(aCommand) {
+ // If this command is not selection-specific, execute it.
+ if (aCommand in this) {
+ this[aCommand]();
+ return;
+ }
+
+ // Other commands are selection-specific.
+ let element = DownloadsView.richListBox.selectedItem;
+ if (element) {
+ // The doCommand function also checks if the command is enabled.
+ DownloadsView.itemForElement(element).doCommand(aCommand);
+ }
+ },
+
+ onEvent() {},
+
+ // Other functions
+
+ updateCommands() {
+ function updateCommandsForObject(object) {
+ for (let name in object) {
+ if (DownloadsViewUI.isCommandName(name)) {
+ goUpdateCommand(name);
+ }
+ }
+ }
+ updateCommandsForObject(this);
+ updateCommandsForObject(DownloadsViewItem.prototype);
+ },
+
+ // Selection-independent commands
+
+ downloadsCmd_clearList() {
+ DownloadsCommon.getData(window).removeFinished();
+ },
+};
+
+XPCOMUtils.defineConstant(
+ this,
+ "DownloadsViewController",
+ DownloadsViewController
+);
+
+// DownloadsSummary
+
+/**
+ * Manages the summary at the bottom of the downloads panel list if the number
+ * of items in the list exceeds the panels limit.
+ */
+var DownloadsSummary = {
+ /**
+ * Sets the active state of the summary. When active, the summary subscribes
+ * to the DownloadsCommon DownloadsSummaryData singleton.
+ *
+ * @param aActive
+ * Set to true to activate the summary.
+ */
+ set active(aActive) {
+ if (aActive == this._active || !this._summaryNode) {
+ return;
+ }
+ if (aActive) {
+ DownloadsCommon.getSummary(
+ window,
+ DownloadsView.kItemCountLimit
+ ).refreshView(this);
+ } else {
+ DownloadsFooter.showingSummary = false;
+ }
+
+ this._active = aActive;
+ },
+
+ /**
+ * Returns the active state of the downloads summary.
+ */
+ get active() {
+ return this._active;
+ },
+
+ _active: false,
+
+ /**
+ * Sets whether or not we show the progress bar.
+ *
+ * @param aShowingProgress
+ * True if we should show the progress bar.
+ */
+ set showingProgress(aShowingProgress) {
+ if (aShowingProgress) {
+ this._summaryNode.setAttribute("inprogress", "true");
+ } else {
+ this._summaryNode.removeAttribute("inprogress");
+ }
+ // If progress isn't being shown, then we simply do not show the summary.
+ DownloadsFooter.showingSummary = aShowingProgress;
+ },
+
+ /**
+ * Sets the amount of progress that is visible in the progress bar.
+ *
+ * @param aValue
+ * A value between 0 and 100 to represent the progress of the
+ * summarized downloads.
+ */
+ set percentComplete(aValue) {
+ if (this._progressNode) {
+ this._progressNode.setAttribute("value", aValue);
+ }
+ },
+
+ /**
+ * Sets the description for the download summary.
+ *
+ * @param aValue
+ * A string representing the description of the summarized
+ * downloads.
+ */
+ set description(aValue) {
+ if (this._descriptionNode) {
+ this._descriptionNode.setAttribute("value", aValue);
+ this._descriptionNode.setAttribute("tooltiptext", aValue);
+ }
+ },
+
+ /**
+ * Sets the details for the download summary, such as the time remaining,
+ * the amount of bytes transferred, etc.
+ *
+ * @param aValue
+ * A string representing the details of the summarized
+ * downloads.
+ */
+ set details(aValue) {
+ if (this._detailsNode) {
+ this._detailsNode.setAttribute("value", aValue);
+ this._detailsNode.setAttribute("tooltiptext", aValue);
+ }
+ },
+
+ /**
+ * Focuses the root element of the summary.
+ */
+ focus(focusOptions) {
+ if (this._summaryNode) {
+ this._summaryNode.focus(focusOptions);
+ }
+ },
+
+ /**
+ * Respond to keydown events on the Downloads Summary node.
+ *
+ * @param aEvent
+ * The keydown event being handled.
+ */
+ onKeyDown(aEvent) {
+ if (
+ aEvent.charCode == " ".charCodeAt(0) ||
+ aEvent.keyCode == KeyEvent.DOM_VK_RETURN
+ ) {
+ DownloadsPanel.showDownloadsHistory();
+ }
+ },
+
+ /**
+ * Respond to click events on the Downloads Summary node.
+ *
+ * @param aEvent
+ * The click event being handled.
+ */
+ onClick(aEvent) {
+ DownloadsPanel.showDownloadsHistory();
+ },
+
+ /**
+ * Element corresponding to the root of the downloads summary.
+ */
+ get _summaryNode() {
+ let node = document.getElementById("downloadsSummary");
+ if (!node) {
+ return null;
+ }
+ delete this._summaryNode;
+ return (this._summaryNode = node);
+ },
+
+ /**
+ * Element corresponding to the progress bar in the downloads summary.
+ */
+ get _progressNode() {
+ let node = document.getElementById("downloadsSummaryProgress");
+ if (!node) {
+ return null;
+ }
+ delete this._progressNode;
+ return (this._progressNode = node);
+ },
+
+ /**
+ * Element corresponding to the main description of the downloads
+ * summary.
+ */
+ get _descriptionNode() {
+ let node = document.getElementById("downloadsSummaryDescription");
+ if (!node) {
+ return null;
+ }
+ delete this._descriptionNode;
+ return (this._descriptionNode = node);
+ },
+
+ /**
+ * Element corresponding to the secondary description of the downloads
+ * summary.
+ */
+ get _detailsNode() {
+ let node = document.getElementById("downloadsSummaryDetails");
+ if (!node) {
+ return null;
+ }
+ delete this._detailsNode;
+ return (this._detailsNode = node);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "DownloadsSummary", DownloadsSummary);
+
+// DownloadsFooter
+
+/**
+ * Manages events sent to to the footer vbox, which contains both the
+ * DownloadsSummary as well as the "Show all downloads" button.
+ */
+var DownloadsFooter = {
+ /**
+ * Focuses the appropriate element within the footer. If the summary
+ * is visible, focus it. If not, focus the "Show all downloads"
+ * button.
+ */
+ focus(focusOptions) {
+ if (this._showingSummary) {
+ DownloadsSummary.focus(focusOptions);
+ } else {
+ DownloadsView.downloadsHistory.focus(focusOptions);
+ }
+ },
+
+ _showingSummary: false,
+
+ /**
+ * Sets whether or not the Downloads Summary should be displayed in the
+ * footer. If not, the "Show all downloads" button is shown instead.
+ */
+ set showingSummary(aValue) {
+ if (this._footerNode) {
+ if (aValue) {
+ this._footerNode.setAttribute("showingsummary", "true");
+ } else {
+ this._footerNode.removeAttribute("showingsummary");
+ }
+ this._showingSummary = aValue;
+ }
+ },
+
+ /**
+ * Element corresponding to the footer of the downloads panel.
+ */
+ get _footerNode() {
+ let node = document.getElementById("downloadsFooter");
+ if (!node) {
+ return null;
+ }
+ delete this._footerNode;
+ return (this._footerNode = node);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "DownloadsFooter", DownloadsFooter);
+
+// DownloadsBlockedSubview
+
+/**
+ * Manages the blocked subview that slides in when you click a blocked download.
+ */
+var DownloadsBlockedSubview = {
+ /**
+ * Elements in the subview.
+ */
+ get elements() {
+ let idSuffixes = [
+ "title",
+ "details1",
+ "details2",
+ "unblockButton",
+ "deleteButton",
+ ];
+ let elements = idSuffixes.reduce((memo, s) => {
+ memo[s] = document.getElementById("downloadsPanel-blockedSubview-" + s);
+ return memo;
+ }, {});
+ delete this.elements;
+ return (this.elements = elements);
+ },
+
+ /**
+ * The blocked-download richlistitem element that was clicked to show the
+ * subview. If the subview is not showing, this is undefined.
+ */
+ element: undefined,
+
+ /**
+ * Slides in the blocked subview.
+ *
+ * @param element
+ * The blocked-download richlistitem element that was clicked.
+ * @param title
+ * The title to show in the subview.
+ * @param details
+ * An array of strings with information about the block.
+ */
+ toggle(element, title, details) {
+ DownloadsView.subViewOpen = true;
+ DownloadsViewController.updateCommands();
+ const { download } = DownloadsView.itemForElement(element);
+
+ let e = this.elements;
+ let s = DownloadsCommon.strings;
+
+ title.l10n
+ ? document.l10n.setAttributes(e.title, title.l10n.id, title.l10n.args)
+ : (e.title.textContent = title);
+
+ details[0].l10n
+ ? document.l10n.setAttributes(
+ e.details1,
+ details[0].l10n.id,
+ details[0].l10n.args
+ )
+ : (e.details1.textContent = details[0]);
+
+ e.details2.textContent = details[1];
+
+ if (download.launchWhenSucceeded) {
+ e.unblockButton.label = s.unblockButtonOpen;
+ e.unblockButton.command = "downloadsCmd_unblockAndOpen";
+ } else {
+ e.unblockButton.label = s.unblockButtonUnblock;
+ e.unblockButton.command = "downloadsCmd_unblockAndSave";
+ }
+
+ e.deleteButton.label = s.unblockButtonConfirmBlock;
+
+ let verdict = element.getAttribute("verdict");
+ this.subview.setAttribute("verdict", verdict);
+
+ this.mainView.addEventListener("ViewShown", this);
+ DownloadsPanel.panel.addEventListener("popuphidden", this);
+ this.panelMultiView.showSubView(this.subview);
+
+ // Without this, the mainView is more narrow than the panel once all
+ // downloads are removed from the panel.
+ this.mainView.style.minWidth = window.getComputedStyle(this.subview).width;
+ },
+
+ handleEvent(event) {
+ // This is called when the main view is shown or the panel is hidden.
+ DownloadsView.subViewOpen = false;
+ this.mainView.removeEventListener("ViewShown", this);
+ DownloadsPanel.panel.removeEventListener("popuphidden", this);
+ // Focus the proper element if we're going back to the main panel.
+ if (event.type == "ViewShown") {
+ DownloadsPanel.showPanel();
+ }
+ },
+
+ /**
+ * Deletes the download and hides the entire panel.
+ */
+ confirmBlock() {
+ goDoCommand("cmd_delete");
+ DownloadsPanel.hidePanel();
+ },
+};
+
+XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "panelMultiView", () =>
+ document.getElementById("downloadsPanel-multiView")
+);
+XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "mainView", () =>
+ document.getElementById("downloadsPanel-mainView")
+);
+XPCOMUtils.defineLazyGetter(DownloadsBlockedSubview, "subview", () =>
+ document.getElementById("downloadsPanel-blockedSubview")
+);
+
+XPCOMUtils.defineConstant(
+ this,
+ "DownloadsBlockedSubview",
+ DownloadsBlockedSubview
+);
diff --git a/browser/components/downloads/content/downloadsCommands.inc.xhtml b/browser/components/downloads/content/downloadsCommands.inc.xhtml
new file mode 100644
index 0000000000..2b144f319e
--- /dev/null
+++ b/browser/components/downloads/content/downloadsCommands.inc.xhtml
@@ -0,0 +1,29 @@
+# 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/.
+
+<script src="chrome://browser/content/downloads/downloadsCommands.js"/>
+
+<commandset id="downloadCommands"
+ commandupdater="true"
+ events="focus,select,contextmenu">
+ <command id="downloadsCmd_pauseResume"/>
+ <command id="downloadsCmd_cancel"/>
+ <command id="downloadsCmd_unblock"/>
+ <command id="downloadsCmd_chooseUnblock"/>
+ <command id="downloadsCmd_chooseOpen"/>
+ <command id="downloadsCmd_confirmBlock"/>
+ <command id="downloadsCmd_open"/>
+ <command id="downloadsCmd_open:current"/>
+ <command id="downloadsCmd_open:tab"/>
+ <command id="downloadsCmd_open:tabshifted"/>
+ <command id="downloadsCmd_open:window"/>
+ <command id="downloadsCmd_show"/>
+ <command id="downloadsCmd_retry"/>
+ <command id="downloadsCmd_openReferrer"/>
+ <command id="downloadsCmd_clearDownloads"/>
+ <command id="downloadsCmd_openInSystemViewer"/>
+ <command id="downloadsCmd_alwaysOpenInSystemViewer"/>
+ <command id="downloadsCmd_alwaysOpenSimilarFiles"/>
+ <command id="downloadsCmd_deleteFile"/>
+</commandset>
diff --git a/browser/components/downloads/content/downloadsCommands.js b/browser/components/downloads/content/downloadsCommands.js
new file mode 100644
index 0000000000..fd7dfce351
--- /dev/null
+++ b/browser/components/downloads/content/downloadsCommands.js
@@ -0,0 +1,17 @@
+/* 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 allDownloadsView.js */
+/* import-globals-from /toolkit/content/globalOverlay.js */
+
+document.addEventListener("DOMContentLoaded", function () {
+ let downloadCommands = document.getElementById("downloadCommands");
+ downloadCommands.addEventListener("commandupdate", function () {
+ goUpdateDownloadCommands();
+ });
+ downloadCommands.addEventListener("command", function (event) {
+ let { id } = event.target;
+ goDoCommand(id);
+ });
+});
diff --git a/browser/components/downloads/content/downloadsContextMenu.inc.xhtml b/browser/components/downloads/content/downloadsContextMenu.inc.xhtml
new file mode 100644
index 0000000000..61d730c9d9
--- /dev/null
+++ b/browser/components/downloads/content/downloadsContextMenu.inc.xhtml
@@ -0,0 +1,50 @@
+# 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="downloadsContextMenu" class="download-state">
+
+ <menuitem command="downloadsCmd_pauseResume"
+ class="downloadPauseMenuItem"
+ data-l10n-id="downloads-cmd-pause"/>
+ <menuitem command="downloadsCmd_pauseResume"
+ class="downloadResumeMenuItem"
+ data-l10n-id="downloads-cmd-resume"/>
+ <menuitem command="downloadsCmd_unblock"
+ class="downloadUnblockMenuItem"
+ data-l10n-id="downloads-cmd-unblock"/>
+ <menuitem command="downloadsCmd_openInSystemViewer"
+ class="downloadUseSystemDefaultMenuItem"
+ data-l10n-id="downloads-cmd-use-system-default"/>
+ <menuitem command="downloadsCmd_alwaysOpenInSystemViewer"
+ type="checkbox"
+ class="downloadAlwaysUseSystemDefaultMenuItem"
+ data-l10n-id="downloads-cmd-always-use-system-default"/>
+ <menuitem command="downloadsCmd_alwaysOpenSimilarFiles"
+ type="checkbox"
+ class="downloadAlwaysOpenSimilarFilesMenuItem"
+ data-l10n-id="downloads-cmd-always-open-similar-files"/>
+ <menuitem command="downloadsCmd_show"
+ class="downloadShowMenuItem"
+ data-l10n-id="downloads-cmd-show-menuitem-2"/>
+
+ <menuseparator class="downloadCommandsSeparator"/>
+
+ <menuitem command="downloadsCmd_openReferrer"
+ class="downloadOpenReferrerMenuItem"
+ data-l10n-id="downloads-cmd-go-to-download-page"/>
+ <menuitem command="cmd_copy"
+ class="downloadCopyLocationMenuItem"
+ data-l10n-id="downloads-cmd-copy-download-link"/>
+
+ <menuseparator/>
+
+ <menuitem command="downloadsCmd_deleteFile"
+ class="downloadDeleteFileMenuItem"
+ data-l10n-id="downloads-cmd-delete-file"/>
+ <menuitem command="cmd_delete"
+ class="downloadRemoveFromHistoryMenuItem"
+ data-l10n-id="downloads-cmd-remove-from-history"/>
+ <menuitem command="downloadsCmd_clearDownloads"
+ data-l10n-id="downloads-cmd-clear-downloads"/>
+</menupopup>
diff --git a/browser/components/downloads/content/downloadsPanel.inc.xhtml b/browser/components/downloads/content/downloadsPanel.inc.xhtml
new file mode 100644
index 0000000000..e358b4bf6d
--- /dev/null
+++ b/browser/components/downloads/content/downloadsPanel.inc.xhtml
@@ -0,0 +1,198 @@
+<!-- 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/. -->
+
+<commandset commandupdater="true" events="richlistbox-select"
+ oncommandupdate="goUpdateCommand('cmd_delete');">
+ <command id="downloadsCmd_doDefault"
+ oncommand="goDoCommand('downloadsCmd_doDefault')"/>
+ <command id="downloadsCmd_pauseResume"
+ oncommand="goDoCommand('downloadsCmd_pauseResume')"/>
+ <command id="downloadsCmd_cancel"
+ oncommand="goDoCommand('downloadsCmd_cancel')"/>
+ <command id="downloadsCmd_unblock"
+ oncommand="goDoCommand('downloadsCmd_unblock')"/>
+ <command id="downloadsCmd_chooseUnblock"
+ oncommand="goDoCommand('downloadsCmd_chooseUnblock')"/>
+ <command id="downloadsCmd_unblockAndOpen"
+ oncommand="goDoCommand('downloadsCmd_unblockAndOpen')"/>
+ <command id="downloadsCmd_unblockAndSave"
+ oncommand="goDoCommand('downloadsCmd_unblockAndSave')"/>
+ <command id="downloadsCmd_confirmBlock"
+ oncommand="goDoCommand('downloadsCmd_confirmBlock')"/>
+ <command id="downloadsCmd_open"
+ oncommand="goDoCommand('downloadsCmd_open')"/>
+ <command id="downloadsCmd_open:current"
+ oncommand="goDoCommand('downloadsCmd_open:current')"/>
+ <command id="downloadsCmd_open:tab"
+ oncommand="goDoCommand('downloadsCmd_open:tab')"/>
+ <command id="downloadsCmd_open:tabshifted"
+ oncommand="goDoCommand('downloadsCmd_open:tabshifted')"/>
+ <command id="downloadsCmd_open:window"
+ oncommand="goDoCommand('downloadsCmd_open:window')"/>
+ <command id="downloadsCmd_show"
+ oncommand="goDoCommand('downloadsCmd_show')"/>
+ <command id="downloadsCmd_retry"
+ oncommand="goDoCommand('downloadsCmd_retry')"/>
+ <command id="downloadsCmd_openReferrer"
+ oncommand="goDoCommand('downloadsCmd_openReferrer')"/>
+ <command id="downloadsCmd_copyLocation"
+ oncommand="goDoCommand('downloadsCmd_copyLocation')"/>
+ <command id="downloadsCmd_clearList"
+ oncommand="goDoCommand('downloadsCmd_clearList')"/>
+ <command id="downloadsCmd_openInSystemViewer"
+ oncommand="goDoCommand('downloadsCmd_openInSystemViewer')"/>
+ <command id="downloadsCmd_alwaysOpenInSystemViewer"
+ oncommand="goDoCommand('downloadsCmd_alwaysOpenInSystemViewer')"/>
+ <command id="downloadsCmd_alwaysOpenSimilarFiles"
+ oncommand="goDoCommand('downloadsCmd_alwaysOpenSimilarFiles')"/>
+ <command id="downloadsCmd_deleteFile"
+ oncommand="goDoCommand('downloadsCmd_deleteFile')"/>
+</commandset>
+
+<!-- For accessibility to screen readers, we use a label on the panel instead
+ of the anchor because the panel can also be displayed without an anchor. -->
+<panel id="downloadsPanel"
+ data-l10n-id="downloads-panel"
+ class="panel-no-padding"
+ role="group"
+ type="arrow"
+ orient="vertical"
+ onpopupshown="DownloadsPanel.onPopupShown(event);"
+ onpopuphidden="DownloadsPanel.onPopupHidden(event);"
+ hidden="true">
+
+ <linkset>
+ <html:link rel="localization" href="browser/downloads.ftl" />
+ </linkset>
+
+ <!-- The following popup menu should be a child of the panel element,
+ otherwise flickering may occur when the cursor is moved over the area
+ of a disabled menu item that overlaps the panel. See bug 492960. -->
+ <menupopup id="downloadsContextMenu"
+ class="download-state">
+ <menuitem command="downloadsCmd_pauseResume"
+ class="downloadPauseMenuItem"
+ data-l10n-id="downloads-cmd-pause"/>
+ <menuitem command="downloadsCmd_pauseResume"
+ class="downloadResumeMenuItem"
+ data-l10n-id="downloads-cmd-resume"/>
+ <menuitem command="downloadsCmd_unblock"
+ class="downloadUnblockMenuItem"
+ data-l10n-id="downloads-cmd-unblock"/>
+ <menuitem command="downloadsCmd_openInSystemViewer"
+ class="downloadUseSystemDefaultMenuItem"
+ data-l10n-id="downloads-cmd-use-system-default"/>
+ <menuitem command="downloadsCmd_alwaysOpenInSystemViewer"
+ type="checkbox"
+ class="downloadAlwaysUseSystemDefaultMenuItem"
+ data-l10n-id="downloads-cmd-always-use-system-default"/>
+ <menuitem command="downloadsCmd_alwaysOpenSimilarFiles"
+ type="checkbox"
+ class="downloadAlwaysOpenSimilarFilesMenuItem"
+ data-l10n-id="downloads-cmd-always-open-similar-files"/>
+ <menuitem command="downloadsCmd_show"
+ class="downloadShowMenuItem"
+ data-l10n-id="downloads-cmd-show-menuitem-2"/>
+
+ <menuseparator class="downloadCommandsSeparator"/>
+
+ <menuitem command="downloadsCmd_openReferrer"
+ class="downloadOpenReferrerMenuItem"
+ data-l10n-id="downloads-cmd-go-to-download-page"/>
+ <menuitem command="downloadsCmd_copyLocation"
+ class="downloadCopyLocationMenuItem"
+ data-l10n-id="downloads-cmd-copy-download-link"/>
+
+ <menuseparator/>
+
+ <menuitem command="downloadsCmd_deleteFile"
+ class="downloadDeleteFileMenuItem"
+ data-l10n-id="downloads-cmd-delete-file"/>
+ <menuitem command="cmd_delete"
+ class="downloadRemoveFromHistoryMenuItem"
+ data-l10n-id="downloads-cmd-remove-from-history"/>
+ <menuitem command="downloadsCmd_clearList"
+ data-l10n-id="downloads-cmd-clear-list"/>
+ <menuitem command="downloadsCmd_clearDownloads"
+ hidden="true"
+ data-l10n-id="downloads-cmd-clear-downloads"/>
+ </menupopup>
+
+ <panelmultiview id="downloadsPanel-multiView"
+ mainViewId="downloadsPanel-mainView"
+ disablekeynav="true">
+
+ <panelview id="downloadsPanel-mainView">
+ <vbox class="panel-view-body-unscrollable">
+ <richlistbox id="downloadsListBox"
+ data-l10n-id="downloads-panel-items"
+ data-l10n-attrs="style"
+ context="downloadsContextMenu"
+ onmouseover="DownloadsView.onDownloadMouseOver(event);"
+ onmouseout="DownloadsView.onDownloadMouseOut(event);"
+ oncontextmenu="DownloadsView.onDownloadContextMenu(event);"
+ ondragstart="DownloadsView.onDownloadDragStart(event);"/>
+ <description id="emptyDownloads"
+ data-l10n-id="downloads-panel-empty"/>
+ </vbox>
+ <vbox id="downloadsFooter">
+ <stack>
+ <hbox id="downloadsSummary"
+ align="center"
+ orient="horizontal"
+ onkeydown="DownloadsSummary.onKeyDown(event);"
+ onclick="DownloadsSummary.onClick(event);">
+ <image class="downloadTypeIcon" />
+ <vbox pack="center"
+ flex="1"
+ class="downloadContainer">
+ <description id="downloadsSummaryDescription"/>
+ <html:progress id="downloadsSummaryProgress"
+ class="downloadProgress"
+ max="100"/>
+ <description id="downloadsSummaryDetails"
+ crop="end"/>
+ </vbox>
+ </hbox>
+ <vbox id="downloadsFooterButtons">
+ <toolbarseparator />
+ <button id="downloadsHistory"
+ data-l10n-id="downloads-history"
+ class="downloadsPanelFooterButton subviewbutton panel-subview-footer-button toolbarbutton-1"
+ flex="1"
+ oncommand="DownloadsPanel.showDownloadsHistory();"
+ pack="start"/>
+ </vbox>
+ </stack>
+ </vbox>
+ </panelview>
+
+ <panelview id="downloadsPanel-blockedSubview"
+ data-l10n-id="downloads-details"
+ class="PanelUI-subView">
+ <vbox class="panel-view-body-unscrollable">
+ <hbox class="downloadsPanel-blockedSubview-title-box">
+ <description id="downloadsPanel-blockedSubview-title"/>
+ <image class="downloadsPanel-blockedSubview-image"/>
+ </hbox>
+ <description id="downloadsPanel-blockedSubview-details1"/>
+ <description id="downloadsPanel-blockedSubview-details2"/>
+ </vbox>
+ <hbox id="downloadsPanel-blockedSubview-buttons"
+ class="panel-footer"
+ align="stretch">
+ <button id="downloadsPanel-blockedSubview-unblockButton"
+ class="downloadsPanelFooterButton"
+ command="downloadsCmd_unblockAndOpen"
+ flex="1"/>
+ <button id="downloadsPanel-blockedSubview-deleteButton"
+ class="downloadsPanelFooterButton"
+ oncommand="DownloadsBlockedSubview.confirmBlock();"
+ default="true"
+ flex="1"/>
+ </hbox>
+ </panelview>
+ </panelmultiview>
+
+</panel>
diff --git a/browser/components/downloads/content/indicator.js b/browser/components/downloads/content/indicator.js
new file mode 100644
index 0000000000..d0c4dc4163
--- /dev/null
+++ b/browser/components/downloads/content/indicator.js
@@ -0,0 +1,670 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=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/. */
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Handles the indicator that displays the progress of ongoing downloads, which
+ * is also used as the anchor for the downloads panel.
+ *
+ * This module includes the following constructors and global objects:
+ *
+ * DownloadsButton
+ * Main entry point for the downloads indicator. Depending on how the toolbars
+ * have been customized, this object determines if we should show a fully
+ * functional indicator, a placeholder used during customization and in the
+ * customization palette, or a neutral view as a temporary anchor for the
+ * downloads panel.
+ *
+ * DownloadsIndicatorView
+ * Builds and updates the actual downloads status widget, responding to changes
+ * in the global status data, or provides a neutral view if the indicator is
+ * removed from the toolbars and only used as a temporary anchor. In addition,
+ * handles the user interaction events raised by the widget.
+ */
+
+"use strict";
+
+// DownloadsButton
+
+/**
+ * Main entry point for the downloads indicator. Depending on how the toolbars
+ * have been customized, this object determines if we should show a fully
+ * functional indicator, a placeholder used during customization and in the
+ * customization palette, or a neutral view as a temporary anchor for the
+ * downloads panel.
+ */
+const DownloadsButton = {
+ /**
+ * Returns a reference to the downloads button position placeholder, or null
+ * if not available because it has been removed from the toolbars.
+ */
+ get _placeholder() {
+ return document.getElementById("downloads-button");
+ },
+
+ /**
+ * Indicates whether toolbar customization is in progress.
+ */
+ _customizing: false,
+
+ /**
+ * This function is called asynchronously just after window initialization.
+ *
+ * NOTE: This function should limit the input/output it performs to improve
+ * startup time.
+ */
+ initializeIndicator() {
+ DownloadsIndicatorView.ensureInitialized();
+ },
+
+ /**
+ * Determines the position where the indicator should appear, and moves its
+ * associated element to the new position.
+ *
+ * @return Anchor element, or null if the indicator is not visible.
+ */
+ _getAnchorInternal() {
+ let indicator = DownloadsIndicatorView.indicator;
+ if (!indicator) {
+ // Exit now if the button is not in the document.
+ return null;
+ }
+
+ indicator.open = this._anchorRequested;
+
+ let widget = CustomizableUI.getWidget("downloads-button");
+ // Determine if the indicator is located on an invisible toolbar.
+ if (
+ !isElementVisible(indicator.parentNode) &&
+ widget.areaType == CustomizableUI.TYPE_TOOLBAR
+ ) {
+ return null;
+ }
+
+ return DownloadsIndicatorView.indicatorAnchor;
+ },
+
+ /**
+ * Indicates whether we should try and show the indicator temporarily as an
+ * anchor for the panel, even if the indicator would be hidden by default.
+ */
+ _anchorRequested: false,
+
+ /**
+ * Ensures that there is an anchor available for the panel.
+ *
+ * @return Anchor element where the panel should be anchored, or null if an
+ * anchor is not available (for example because both the tab bar and
+ * the navigation bar are hidden).
+ */
+ getAnchor() {
+ // Do not allow anchoring the panel to the element while customizing.
+ if (this._customizing) {
+ return null;
+ }
+
+ this._anchorRequested = true;
+ return this._getAnchorInternal();
+ },
+
+ /**
+ * Allows the temporary anchor to be hidden.
+ */
+ releaseAnchor() {
+ this._anchorRequested = false;
+ this._getAnchorInternal();
+ },
+
+ /**
+ * Unhide the button. Generally, this only needs to use the placeholder.
+ * However, when starting customize mode, if the button is in the palette,
+ * we need to unhide it before customize mode is entered, otherwise it
+ * gets ignored by customize mode. To do this, we pass true for
+ * `includePalette`. We don't always look in the palette because it's
+ * inefficient (compared to getElementById), shouldn't be necessary, and
+ * if _placeholder returned the node even if in the palette, other checks
+ * would break.
+ *
+ * @param includePalette whether to search the palette, too. Defaults to false.
+ */
+ unhide(includePalette = false) {
+ let button = this._placeholder;
+ let wasHidden = false;
+ if (!button && includePalette) {
+ button = gNavToolbox.palette.querySelector("#downloads-button");
+ }
+ if (button && button.hasAttribute("hidden")) {
+ button.removeAttribute("hidden");
+ if (this._navBar.contains(button)) {
+ this._navBar.setAttribute("downloadsbuttonshown", "true");
+ }
+ wasHidden = true;
+ }
+ return wasHidden;
+ },
+
+ hide() {
+ let button = this._placeholder;
+ if (this.autoHideDownloadsButton && button && button.closest("toolbar")) {
+ DownloadsPanel.hidePanel();
+ button.hidden = true;
+ this._navBar.removeAttribute("downloadsbuttonshown");
+ }
+ },
+
+ startAutoHide() {
+ if (DownloadsIndicatorView.hasDownloads) {
+ this.unhide();
+ } else {
+ this.hide();
+ }
+ },
+
+ checkForAutoHide() {
+ let button = this._placeholder;
+ if (
+ !this._customizing &&
+ this.autoHideDownloadsButton &&
+ button &&
+ button.closest("toolbar")
+ ) {
+ this.startAutoHide();
+ } else {
+ this.unhide();
+ }
+ },
+
+ // Callback from CustomizableUI when nodes get moved around.
+ // We use this to track whether our node has moved somewhere
+ // where we should (not) autohide it.
+ onWidgetAfterDOMChange(node) {
+ if (node == this._placeholder) {
+ this.checkForAutoHide();
+ }
+ },
+
+ /**
+ * This function is called when toolbar customization starts.
+ *
+ * During customization, we never show the actual download progress indication
+ * or the event notifications, but we show a neutral placeholder. The neutral
+ * placeholder is an ordinary button defined in the browser window that can be
+ * moved freely between the toolbars and the customization palette.
+ */
+ onCustomizeStart(win) {
+ if (win == window) {
+ // Prevent the indicator from being displayed as a temporary anchor
+ // during customization, even if requested using the getAnchor method.
+ this._customizing = true;
+ this._anchorRequested = false;
+ this.unhide(true);
+ }
+ },
+
+ onCustomizeEnd(win) {
+ if (win == window) {
+ this._customizing = false;
+ this.checkForAutoHide();
+ DownloadsIndicatorView.afterCustomize();
+ }
+ },
+
+ init() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "autoHideDownloadsButton",
+ "browser.download.autohideButton",
+ true,
+ this.checkForAutoHide.bind(this)
+ );
+
+ CustomizableUI.addListener(this);
+ this.checkForAutoHide();
+ },
+
+ uninit() {
+ CustomizableUI.removeListener(this);
+ },
+
+ get _tabsToolbar() {
+ delete this._tabsToolbar;
+ return (this._tabsToolbar = document.getElementById("TabsToolbar"));
+ },
+
+ get _navBar() {
+ delete this._navBar;
+ return (this._navBar = document.getElementById("nav-bar"));
+ },
+};
+
+Object.defineProperty(this, "DownloadsButton", {
+ value: DownloadsButton,
+ enumerable: true,
+ writable: false,
+});
+
+// DownloadsIndicatorView
+
+/**
+ * Builds and updates the actual downloads status widget, responding to changes
+ * in the global status data, or provides a neutral view if the indicator is
+ * removed from the toolbars and only used as a temporary anchor. In addition,
+ * handles the user interaction events raised by the widget.
+ */
+const DownloadsIndicatorView = {
+ /**
+ * True when the view is connected with the underlying downloads data.
+ */
+ _initialized: false,
+
+ /**
+ * True when the user interface elements required to display the indicator
+ * have finished loading in the browser window, and can be referenced.
+ */
+ _operational: false,
+
+ /**
+ * Prepares the downloads indicator to be displayed.
+ */
+ ensureInitialized() {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+
+ window.addEventListener("unload", this);
+ window.addEventListener("visibilitychange", this);
+ DownloadsCommon.getIndicatorData(window).addView(this);
+ },
+
+ /**
+ * Frees the internal resources related to the indicator.
+ */
+ ensureTerminated() {
+ if (!this._initialized) {
+ return;
+ }
+ this._initialized = false;
+
+ window.removeEventListener("unload", this);
+ window.removeEventListener("visibilitychange", this);
+ DownloadsCommon.getIndicatorData(window).removeView(this);
+
+ // Reset the view properties, so that a neutral indicator is displayed if we
+ // are visible only temporarily as an anchor.
+ this.percentComplete = 0;
+ this.attention = DownloadsCommon.ATTENTION_NONE;
+ },
+
+ /**
+ * Ensures that the user interface elements required to display the indicator
+ * are loaded.
+ */
+ _ensureOperational() {
+ if (this._operational) {
+ return;
+ }
+
+ // If we don't have a _placeholder, there's no chance that everything
+ // will load correctly: bail (and don't set _operational to true!)
+ if (!DownloadsButton._placeholder) {
+ return;
+ }
+
+ this._operational = true;
+
+ // If the view is initialized, we need to update the elements now that
+ // they are finally available in the document.
+ if (this._initialized) {
+ DownloadsCommon.getIndicatorData(window).refreshView(this);
+ }
+ },
+
+ // Direct control functions
+
+ /**
+ * Set to the type ("start" or "finish") when display of a notification is in-progress
+ */
+ _currentNotificationType: null,
+
+ /**
+ * Set to the type ("start" or "finish") when a notification arrives while we
+ * are waiting for the timeout of the previous notification
+ */
+ _nextNotificationType: null,
+
+ /**
+ * Check if the panel containing aNode is open.
+ * @param aNode
+ * the node whose panel we're interested in.
+ */
+ _isAncestorPanelOpen(aNode) {
+ while (aNode && aNode.localName != "panel") {
+ aNode = aNode.parentNode;
+ }
+ return aNode && aNode.state == "open";
+ },
+
+ /**
+ * Display or enqueue a visual notification of a relevant event, like a new download.
+ *
+ * @param aType
+ * Set to "start" for new downloads, "finish" for completed downloads.
+ */
+ showEventNotification(aType) {
+ if (!this._initialized) {
+ return;
+ }
+
+ // enqueue this notification while the current one is being displayed
+ if (this._currentNotificationType) {
+ // only queue up the notification if it is different to the current one
+ if (this._currentNotificationType != aType) {
+ this._nextNotificationType = aType;
+ }
+ } else {
+ this._showNotification(aType);
+ }
+ },
+
+ /**
+ * If the status indicator is visible in its assigned position, shows for a
+ * brief time a visual notification of a relevant event, like a new download.
+ *
+ * @param aType
+ * Set to "start" for new downloads, "finish" for completed downloads.
+ */
+ _showNotification(aType) {
+ let anchor = DownloadsButton._placeholder;
+ if (!anchor || !isElementVisible(anchor.parentNode)) {
+ // Our container isn't visible, so can't show the animation:
+ return;
+ }
+
+ if (anchor.ownerGlobal.matchMedia("(prefers-reduced-motion)").matches) {
+ // User has prefers-reduced-motion enabled, so we shouldn't show the animation.
+ return;
+ }
+
+ anchor.setAttribute("notification", aType);
+ anchor.setAttribute("animate", "");
+
+ // are we animating from an initially-hidden state?
+ anchor.toggleAttribute("washidden", !!this._wasHidden);
+ delete this._wasHidden;
+
+ this._currentNotificationType = aType;
+
+ const onNotificationAnimEnd = event => {
+ if (
+ event.animationName !== "downloadsButtonNotification" &&
+ event.animationName !== "downloadsButtonFinishedNotification"
+ ) {
+ return;
+ }
+ anchor.removeEventListener("animationend", onNotificationAnimEnd);
+
+ requestAnimationFrame(() => {
+ anchor.removeAttribute("notification");
+ anchor.removeAttribute("animate");
+
+ requestAnimationFrame(() => {
+ let nextType = this._nextNotificationType;
+ this._currentNotificationType = null;
+ this._nextNotificationType = null;
+ if (nextType && isElementVisible(anchor.parentNode)) {
+ this._showNotification(nextType);
+ }
+ });
+ });
+ };
+ anchor.addEventListener("animationend", onNotificationAnimEnd);
+ },
+
+ // Callback functions from DownloadsIndicatorData
+
+ /**
+ * Indicates whether the indicator should be shown because there are some
+ * downloads to be displayed.
+ */
+ set hasDownloads(aValue) {
+ if (this._hasDownloads != aValue || (!this._operational && aValue)) {
+ this._hasDownloads = aValue;
+
+ // If there is at least one download, ensure that the view elements are
+ // operational
+ if (aValue) {
+ this._wasHidden = DownloadsButton.unhide();
+ this._ensureOperational();
+ } else {
+ DownloadsButton.checkForAutoHide();
+ }
+ }
+ },
+ get hasDownloads() {
+ return this._hasDownloads;
+ },
+ _hasDownloads: false,
+
+ /**
+ * Progress indication to display, from 0 to 100, or -1 if unknown.
+ * Progress is not visible if the current progress is unknown.
+ */
+ set percentComplete(aValue) {
+ if (!this._operational) {
+ return;
+ }
+ aValue = Math.min(100, aValue);
+ if (this._percentComplete !== aValue) {
+ // Initial progress may fire before the start event gets to us.
+ // To avoid flashing, trip the start event first.
+ if (this._percentComplete < 0 && aValue >= 0) {
+ this.showEventNotification("start");
+ }
+ this._percentComplete = aValue;
+ this._refreshAttention();
+ this._maybeScheduleProgressUpdate();
+ }
+ },
+
+ _maybeScheduleProgressUpdate() {
+ if (
+ this.indicator &&
+ !this._progressRaf &&
+ document.visibilityState == "visible"
+ ) {
+ this._progressRaf = requestAnimationFrame(() => {
+ // indeterminate downloads (unknown content-length) will show up as aValue = 0
+ if (this._percentComplete >= 0) {
+ if (!this.indicator.hasAttribute("progress")) {
+ this.indicator.setAttribute("progress", "true");
+ }
+ // For arrow type only: Set the % complete on the pie-chart.
+ // We use a minimum of 10% to ensure something is always visible
+ this._progressIcon.style.setProperty(
+ "--download-progress-pcent",
+ `${Math.max(10, this._percentComplete)}%`
+ );
+ } else {
+ this.indicator.removeAttribute("progress");
+ this._progressIcon.style.setProperty(
+ "--download-progress-pcent",
+ "0%"
+ );
+ }
+ this._progressRaf = null;
+ });
+ }
+ },
+ _percentComplete: -1,
+
+ /**
+ * Set when the indicator should draw user attention to itself.
+ */
+ set attention(aValue) {
+ if (!this._operational) {
+ return;
+ }
+ if (this._attention != aValue) {
+ this._attention = aValue;
+ this._refreshAttention();
+ }
+ },
+
+ _refreshAttention() {
+ // Check if the downloads button is in the menu panel, to determine which
+ // button needs to get a badge.
+ let widgetGroup = CustomizableUI.getWidget("downloads-button");
+ let inMenu = widgetGroup.areaType == CustomizableUI.TYPE_PANEL;
+
+ // For arrow-Styled indicator, suppress success attention if we have
+ // progress in toolbar
+ let suppressAttention =
+ !inMenu &&
+ this._attention == DownloadsCommon.ATTENTION_SUCCESS &&
+ this._percentComplete >= 0;
+
+ if (
+ suppressAttention ||
+ this._attention == DownloadsCommon.ATTENTION_NONE
+ ) {
+ this.indicator.removeAttribute("attention");
+ } else {
+ this.indicator.setAttribute("attention", this._attention);
+ }
+ },
+ _attention: DownloadsCommon.ATTENTION_NONE,
+
+ // User interface event functions
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "unload":
+ this.ensureTerminated();
+ break;
+
+ case "visibilitychange":
+ this._maybeScheduleProgressUpdate();
+ break;
+ }
+ },
+
+ onCommand(aEvent) {
+ if (
+ // On Mac, ctrl-click will send a context menu event from the widget, so
+ // we don't want to bring up the panel when ctrl key is pressed.
+ (aEvent.type == "mousedown" &&
+ (aEvent.button != 0 ||
+ (AppConstants.platform == "macosx" && aEvent.ctrlKey))) ||
+ (aEvent.type == "keypress" && aEvent.key != " " && aEvent.key != "Enter")
+ ) {
+ return;
+ }
+
+ DownloadsPanel.showPanel(
+ /* openedManually */ true,
+ aEvent.type.startsWith("key")
+ );
+ aEvent.stopPropagation();
+ },
+
+ onDragOver(aEvent) {
+ browserDragAndDrop.dragOver(aEvent);
+ },
+
+ onDrop(aEvent) {
+ let dt = aEvent.dataTransfer;
+ // If dragged item is from our source, do not try to
+ // redownload already downloaded file.
+ if (dt.mozGetDataAt("application/x-moz-file", 0)) {
+ return;
+ }
+
+ let links = browserDragAndDrop.dropLinks(aEvent);
+ if (!links.length) {
+ return;
+ }
+ let sourceDoc = dt.mozSourceNode
+ ? dt.mozSourceNode.ownerDocument
+ : document;
+ let handled = false;
+ for (let link of links) {
+ if (link.url.startsWith("about:")) {
+ continue;
+ }
+ saveURL(
+ link.url,
+ null,
+ link.name,
+ null,
+ true,
+ true,
+ null,
+ null,
+ sourceDoc
+ );
+ handled = true;
+ }
+ if (handled) {
+ aEvent.preventDefault();
+ }
+ },
+
+ _indicator: null,
+ __progressIcon: null,
+
+ /**
+ * Returns a reference to the main indicator element, or null if the element
+ * is not present in the browser window yet.
+ */
+ get indicator() {
+ if (!this._indicator) {
+ this._indicator = document.getElementById("downloads-button");
+ }
+
+ return this._indicator;
+ },
+
+ get indicatorAnchor() {
+ let widgetGroup = CustomizableUI.getWidget("downloads-button");
+ if (widgetGroup.areaType == CustomizableUI.TYPE_PANEL) {
+ let overflowIcon = widgetGroup.forWindow(window).anchor;
+ return overflowIcon.icon;
+ }
+
+ return this.indicator.badgeStack;
+ },
+
+ get _progressIcon() {
+ return (
+ this.__progressIcon ||
+ (this.__progressIcon = document.getElementById(
+ "downloads-indicator-progress-inner"
+ ))
+ );
+ },
+
+ _onCustomizedAway() {
+ this._indicator = null;
+ this.__progressIcon = null;
+ },
+
+ afterCustomize() {
+ // If the cached indicator is not the one currently in the document,
+ // invalidate our references
+ if (this._indicator != document.getElementById("downloads-button")) {
+ this._onCustomizedAway();
+ this._operational = false;
+ this.ensureTerminated();
+ this.ensureInitialized();
+ }
+ },
+};
+
+Object.defineProperty(this, "DownloadsIndicatorView", {
+ value: DownloadsIndicatorView,
+ enumerable: true,
+ writable: false,
+});