diff options
Diffstat (limited to 'browser/components/downloads/content/downloads.js')
-rw-r--r-- | browser/components/downloads/content/downloads.js | 1689 |
1 files changed, 1689 insertions, 0 deletions
diff --git a/browser/components/downloads/content/downloads.js b/browser/components/downloads/content/downloads.js new file mode 100644 index 0000000000..778b995e93 --- /dev/null +++ b/browser/components/downloads/content/downloads.js @@ -0,0 +1,1689 @@ +/* -*- 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", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +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, + + /** The panel is not linked to downloads data yet. */ + _initialized: false, + + /** The panel will be shown as soon as data is available. */ + _waitingDataForOpen: false, + + /** + * 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._initialized) { + DownloadsCommon.log("DownloadsPanel is already initialized."); + return; + } + this._initialized = true; + + window.addEventListener("unload", this.onWindowUnload); + + window.ensureCustomElements("moz-button-group"); + + // 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._initialized) { + 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._initialized = false; + + 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._waitingDataForOpen = true; + }, + + /** + * 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); + DownloadsCommon.log("Downloads panel is now closed."); + }, + + /** + * Indicates whether the panel is showing. + * @note this includes the hiding state. + */ + get isPanelShowing() { + return this._waitingDataForOpen || this.panel.state != "closed"; + }, + + 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."); + + // 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(); + }, + + // 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.panel.state != "open") { + 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._waitingDataForOpen || DownloadsView.loading) { + return; + } + this._waitingDataForOpen = false; + + // 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) { + 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."); + 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 + ).then(() => { + if (!this._openedManually) { + this._delayPopupItems(); + } + }, console.error); + }, 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(); + }, +}; + +ChromeUtils.defineLazyGetter(DownloadsBlockedSubview, "panelMultiView", () => + document.getElementById("downloadsPanel-multiView") +); +ChromeUtils.defineLazyGetter(DownloadsBlockedSubview, "mainView", () => + document.getElementById("downloadsPanel-mainView") +); +ChromeUtils.defineLazyGetter(DownloadsBlockedSubview, "subview", () => + document.getElementById("downloadsPanel-blockedSubview") +); + +XPCOMUtils.defineConstant( + this, + "DownloadsBlockedSubview", + DownloadsBlockedSubview +); |