diff options
Diffstat (limited to 'browser/components/downloads/content')
11 files changed, 3851 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..f2f6e7fa96 --- /dev/null +++ b/browser/components/downloads/content/allDownloadsView.js @@ -0,0 +1,952 @@ +/* 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.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + Downloads: "resource://gre/modules/Downloads.jsm", + DownloadsCommon: "resource:///modules/DownloadsCommon.jsm", + DownloadsViewUI: "resource:///modules/DownloadsViewUI.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", + OS: "resource://gre/modules/osfile.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.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 = { + __proto__: DownloadsViewUI.DownloadElementShell.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.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(Cu.reportError) + .then(() => { + // Do not try to check for existence again even if this failed. + this._targetFileChecked = true; + }); + } + }, +}; + +/** + * 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) { + 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); + + // Get the Download button out of the attention state since we're about to + // view all downloads. + DownloadsCommon.getIndicatorData(window).attention = + DownloadsCommon.ATTENTION_NONE; + + // Make sure to unregister the view if the window is closed. + window.addEventListener( + "unload", + () => { + window.controllers.removeController(this); + this._downloadsData.removeView(this); + this.result = null; + }, + true + ); + // Resizing the window may change items visibility. + window.addEventListener( + "resize", + () => { + this._ensureVisibleElementsAreActive(true); + }, + true + ); +} + +DownloadsPlacesView.prototype = { + __proto__: DownloadsViewUI.BaseView.prototype, + + get associatedElement() { + return this._richlistbox; + }, + + get active() { + return this._active; + }, + set active(val) { + this._active = val; + if (this._active) { + this._ensureVisibleElementsAreActive(true); + } + return this._active; + }, + + /** + * 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(); + } + return (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": + 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 => element._shell.download.source.url + ); + + 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/unicode"]; + 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(Cu.reportError); + } + // 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; + } + + // Set the state attribute so that only the appropriate items are displayed. + let contextMenu = document.getElementById("downloadsContextMenu"); + let download = element._shell.download; + let mimeInfo = DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault } = mimeInfo ? mimeInfo : {}; + + contextMenu.setAttribute( + "state", + DownloadsCommon.stateOfDownload(download) + ); + contextMenu.setAttribute("exists", "true"); + contextMenu.classList.toggle("temporary-block", !!download.hasBlockedData); + + if (element.hasAttribute("viewable-internally")) { + contextMenu.setAttribute("viewable-internally", "true"); + let alwaysUseSystemViewerItem = contextMenu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + } + alwaysUseSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.alwaysOpenInSystemViewerItemEnabled + ); + let useSystemViewerItem = contextMenu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + useSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.openInSystemViewerItemEnabled + ); + } else { + contextMenu.removeAttribute("viewable-internally"); + } + + 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); + } + }, +}; + +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("downloadsRichListBox"); + 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..f42e577503 --- /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/. */ + +#downloadsRichListBox:not(:empty) + #downloadsListEmptyDescription, +#downloadsRichListBox: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..9637e7988f --- /dev/null +++ b/browser/components/downloads/content/contentAreaDownloadsView.js @@ -0,0 +1,32 @@ +/* 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.import( + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); + +var ContentAreaDownloadsView = { + init() { + let box = document.getElementById("downloadsRichListBox"); + box.addEventListener( + "InitialDownloadsLoaded", + () => { + // Set focus to Downloads list once it is created + document.getElementById("downloadsRichListBox").focus(); + }, + { once: true } + ); + let view = new DownloadsPlacesView(box); + // 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..f6e88122a7 --- /dev/null +++ b/browser/components/downloads/content/contentAreaDownloadsView.xhtml @@ -0,0 +1,54 @@ +<?xml version="1.0"?> + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +<?xml-stylesheet href="chrome://global/skin/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 [ +<!ENTITY % editMenuDTD SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuDTD; +]> + +<!-- @CSP: We have to whitelist the 'oncommand' handler for all the cmd_* fields within + - editMenuOverlay.js until Bug 371900 is fixed using + - sha512-4o5Uf4E4EG+90Mb820FH2YFDf4IuX4bfUwQC7reK1ZhgcXWJBKMK2330XIELaFJJ8HiPffS9mP60MPjuXMIrHA== + --> +<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:; script-src chrome: 'sha512-4o5Uf4E4EG+90Mb820FH2YFDf4IuX4bfUwQC7reK1ZhgcXWJBKMK2330XIELaFJJ8HiPffS9mP60MPjuXMIrHA=='; 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="downloadsRichListBox" + 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..1819ffbda4 --- /dev/null +++ b/browser/components/downloads/content/downloads.css @@ -0,0 +1,172 @@ +/* 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 ***/ + +#downloadsRichListBox > 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] > toolbarseparator, +.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, + +.download-state:not([state="0"] /* Downloading */) + .downloadPauseMenuItem, + +.download-state:not([state="4"] /* Paused */) + .downloadResumeMenuItem, + +/* Blocked (dirty) downloads that have not been confirmed and + have temporary data. */ +.download-state:not([state="8"] /* Blocked (dirty) */) + .downloadUnblockMenuItem, + +.download-state[state="8"]:not(.temporary-block) .downloadUnblockMenuItem, + +.download-state:not([state="1"], /* Finished */ + [state="2"], /* Failed */ + [state="3"], /* Canceled */ + [state="6"], /* Blocked (parental) */ + [state="8"], /* Blocked (dirty) */ + [state="9"] /* Blocked (policy) */) + .downloadRemoveFromHistoryMenuItem, + +.download-state:not([state="-1"],/* Starting (initial) */ + [state="0"], /* Downloading */ + [state="1"], /* Finished */ + [state="4"], /* Paused */ + [state="5"] /* Starting (queued) */) + .downloadShowMenuItem, + +.download-state[state="1"]:not([exists]) .downloadShowMenuItem, + +.download-state:not([state="-1"],/* Starting (initial) */ + [state="0"], /* Downloading */ + [state="1"], /* Finished */ + [state="4"], /* Paused */ + [state="5"], /* Starting (queued) */ + [state="8"] /* Blocked (dirty) */) + .downloadCommandsSeparator, + +.download-state[state="1"]:not([exists]) .downloadCommandsSeparator, + +.download-state[state="8"]:not(.temporary-block) .downloadCommandsSeparator, + +/* the system-viewer context menu items are only shown for certain mime-types + and can be individually enabled via prefs */ +.download-state:not([viewable-internally]) .downloadUseSystemDefaultMenuItem, +.download-state .downloadUseSystemDefaultMenuItem:not([enabled]), +.download-state .downloadAlwaysUseSystemDefaultMenuItem:not([enabled]), +.download-state:not([viewable-internally]) .downloadAlwaysUseSystemDefaultMenuItem { + 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; +} + +/* DownloadsSubview styles: */ + +/* Hide all status labels by default and selectively display one at a time, + depending on the state of the Download. */ +.subviewbutton.download > .toolbarbutton-text > .status-text, +/* When a Download is not hovered at all, hide the secondary action button. */ +.subviewbutton.download:not(:hover) > .action-button, +/* Always hide the label of the secondary action button. */ +.subviewbutton.download > .action-button > .toolbarbutton-text { + display: none; +} + +/* When a Download is _not_ hovered, display the full status message. */ +.subviewbutton.download:not(:hover) > .toolbarbutton-text > .status-full, +/* When a Download is hovered when the file doesn't exist and cannot be retried, + keep showing the full status message. */ +.subviewbutton.download:hover:is(:not([canShow]),:not([exists])):not([canRetry]) > .toolbarbutton-text > .status-full, +/* When a Download is hovered and the it can be retried, but the action button + is _not_ hovered, keep showing the full status message. */ +.subviewbutton.download:hover[canRetry]:not(.downloadHoveringButton) > .toolbarbutton-text > .status-full, +/* When a Download is hovered and the file can be opened, but the action button + is _not_ hovered, show the 'Open File' status label. */ +.subviewbutton.download:hover[canShow][exists]:not(.downloadHoveringButton) > .toolbarbutton-text > .status-open, +/* When a Download is hovered - its action button explicitly - and it can be + retried, show the 'Retry Download' label. */ +.subviewbutton.download:hover[canRetry].downloadHoveringButton > .toolbarbutton-text > .status-retry, +/* When a Download is hovered - its action button explicitly - and the file can + be shown in the OS's shell, show the 'Open Containing Folder' label. */ +.subviewbutton.download:hover[canShow][exists].downloadHoveringButton > .toolbarbutton-text > .status-show { + display: inline; +} diff --git a/browser/components/downloads/content/downloads.js b/browser/components/downloads/content/downloads.js new file mode 100644 index 0000000000..c8b354bda1 --- /dev/null +++ b/browser/components/downloads/content/downloads.js @@ -0,0 +1,1641 @@ +/* -*- 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. + */ + +/** + * A few words on focus and focusrings + * + * We do quite a few hacks in the Downloads Panel for focusrings. In fact, we + * basically suppress most if not all XUL-level focusrings, and style/draw + * them ourselves (using :focus instead of -moz-focusring). There are a few + * reasons for this: + * + * 1) Richlists on OSX don't have focusrings; instead, they are shown as + * selected. This makes for some ambiguity when we have a focused/selected + * item in the list, and the mouse is hovering a completed download (which + * highlights). + * 2) Windows doesn't show focusrings until after the first time that tab is + * pressed (and by then you're focusing the second item in the panel). + * 3) Richlistbox sets -moz-focusring even when we select it with a mouse. + * + * In general, the desired behaviour is to focus the first item after pressing + * tab/down, and show that focus with a ring. Then, if the mouse moves over + * the panel, to hide that focus ring; essentially resetting us to the state + * before pressing the key. + * + * We end up capturing the tab/down key events, and preventing their default + * behaviour. We then set a "keyfocus" attribute on the panel, which allows + * us to draw a ring around the currently focused element. If the panel is + * closed or the mouse moves over the panel, we remove the attribute. + */ + +"use strict"; + +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "DownloadsViewUI", + "resource:///modules/DownloadsViewUI.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "DownloadsSubview", + "resource:///modules/DownloadsSubview.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); + +// DownloadsPanel + +/** + * Main entry point for the downloads panel interface. + */ +var DownloadsPanel = { + // Initialization and termination + + /** + * 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 (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(); + + 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() { + Services.telemetry.scalarAdd("downloads.panel_shown", 1); + DownloadsCommon.log("Opening the downloads panel."); + + 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 + ); + }, + + /** + * Returns whether the user has started keyboard navigation. + */ + get keyFocusing() { + return this.panel.hasAttribute("keyfocus"); + }, + + /** + * Set to true if the user has started keyboard navigation, and we should be + * showing focusrings in the panel. Also adds a mousemove event handler to + * the panel which disables keyFocusing. + */ + set keyFocusing(aValue) { + if (aValue) { + this.panel.setAttribute("keyfocus", "true"); + this.panel.addEventListener("mousemove", this); + } else { + this.panel.removeAttribute("keyfocus"); + this.panel.removeEventListener("mousemove", this); + } + return aValue; + }, + + /** + * Handles the mousemove event for the panel, which disables focusring + * visualization. + */ + handleEvent(aEvent) { + switch (aEvent.type) { + case "mousemove": + this.keyFocusing = false; + break; + case "keydown": + this._onKeyDown(aEvent); + break; + case "keypress": + this._onKeyPress(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 = true; + + // Ensure that the first item is selected when the panel is focused. + if ( + DownloadsView.richListBox.itemCount > 0 && + DownloadsView.richListBox.selectedIndex == -1 + ) { + 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."); + + // Removes the keyfocus attribute so that we stop handling keyboard + // navigation. + this.keyFocusing = false; + + // Since at most one popup is open at any given time, we can set globally. + DownloadsCommon.getIndicatorData(window).attentionSuppressed = false; + + // 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); + }, + + /** + * 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); + }, + + _onKeyPress(aEvent) { + // Handle unmodified keys only. + if (aEvent.altKey || aEvent.ctrlKey || aEvent.shiftKey || aEvent.metaKey) { + return; + } + + let richListBox = DownloadsView.richListBox; + + // If the user has pressed the tab, up, or down cursor key, start keyboard + // navigation, thus enabling focusrings in the panel. Keyboard navigation + // is automatically disabled if the user moves the mouse on the panel, or + // if the panel is closed. + if ( + (aEvent.keyCode == aEvent.DOM_VK_TAB || + aEvent.keyCode == aEvent.DOM_VK_UP || + aEvent.keyCode == aEvent.DOM_VK_DOWN) && + !this.keyFocusing + ) { + this.keyFocusing = true; + // Ensure there's a selection, we will show the focus ring around it and + // prevent the richlistbox from changing the selection. + if (DownloadsView.richListBox.selectedIndex == -1) { + DownloadsView.richListBox.selectedIndex = 0; + } + 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 ( + richListBox.selectedItem === richListBox.lastElementChild || + document.activeElement.parentNode.id === "downloadsFooter" + ) { + DownloadsFooter.focus(); + aEvent.preventDefault(); + return; + } + } + + // Pass keypress events to the richlistbox view when it's focused. + if (document.activeElement === 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 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 && + document.activeElement.parentNode.id === "downloadsFooter" && + DownloadsView.richListBox.firstElementChild + ) { + DownloadsView.richListBox.focus(); + DownloadsView.richListBox.selectedItem = + DownloadsView.richListBox.lastElementChild; + 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/unicode"]; + 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) {} + }, + + /** + * 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; + } + + let element = document.commandDispatcher.focusedElement; + while (element && element != this.panel) { + element = element.parentNode; + } + if (!element) { + if (DownloadsView.richListBox.itemCount > 0) { + DownloadsView.richListBox.focus(); + } else { + DownloadsFooter.focus(); + } + } + }, + + /** + * 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."); + 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(Cu.reportError); + } + + DownloadsCommon.log("Opening downloads panel popup."); + PanelMultiView.openPopup( + this.panel, + anchor, + "bottomcenter topright", + 0, + 0, + false, + null + ).catch(Cu.reportError); + }, +}; + +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 an open contextMenu for a download item. + */ + contextMenuOpen: false, + + /** + * 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"); + 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 only, and exclude the action button. + if (aEvent.button == 0 && aEvent.originalTarget.localName != "button") { + let target = aEvent.target; + while (target.nodeName != "richlistitem") { + target = target.parentNode; + } + Services.telemetry.scalarAdd("downloads.file_opened", 1); + let download = DownloadsView.itemForElement(target).download; + 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; + } + } + 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) { + goDoCommand("downloadsCmd_doDefault"); + } + }, + + /** + * Event handlers to keep track of context menu state (open/closed) for + * download items. + */ + onContextPopupShown(aEvent) { + // Ignore events raised by nested popups. + if (aEvent.target != aEvent.currentTarget) { + return; + } + + DownloadsCommon.log("Context menu has shown."); + this.contextMenuOpen = true; + }, + + onContextPopupHidden(aEvent) { + // Ignore events raised by nested popups. + if (aEvent.target != aEvent.currentTarget) { + return; + } + + DownloadsCommon.log("Context menu has hidden."); + this.contextMenuOpen = false; + }, + + /** + * 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"); + } + + if (!this.contextMenuOpen && !this.subViewOpen) { + 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.contextMenuOpen && + !this.subViewOpen && + !item.contains(aEvent.relatedTarget) + ) { + this.richListBox.selectedIndex = -1; + } + }, + + onDownloadContextMenu(aEvent) { + let element = this.richListBox.selectedItem; + if (!element) { + return; + } + + DownloadsViewController.updateCommands(); + + let download = element._shell.download; + let mimeInfo = DownloadsCommon.getMimeInfo(download); + let { preferredAction, useSystemDefault } = mimeInfo ? mimeInfo : {}; + + // Set the state attribute so that only the appropriate items are displayed. + let contextMenu = document.getElementById("downloadsContextMenu"); + contextMenu.setAttribute("state", element.getAttribute("state")); + if (element.hasAttribute("exists")) { + contextMenu.setAttribute("exists", "true"); + } else { + contextMenu.removeAttribute("exists"); + } + contextMenu.classList.toggle( + "temporary-block", + element.classList.contains("temporary-block") + ); + if (element.hasAttribute("viewable-internally")) { + contextMenu.setAttribute("viewable-internally", "true"); + let alwaysUseSystemViewerItem = contextMenu.querySelector( + ".downloadAlwaysUseSystemDefaultMenuItem" + ); + if (preferredAction === useSystemDefault) { + alwaysUseSystemViewerItem.setAttribute("checked", "true"); + } else { + alwaysUseSystemViewerItem.removeAttribute("checked"); + } + alwaysUseSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.alwaysOpenInSystemViewerItemEnabled + ); + let useSystemViewerItem = contextMenu.querySelector( + ".downloadUseSystemDefaultMenuItem" + ); + useSystemViewerItem.toggleAttribute( + "enabled", + DownloadsCommon.openInSystemViewerItemEnabled + ); + } else { + contextMenu.removeAttribute("viewable-internally"); + } + }, + + onDownloadDragStart(aEvent) { + let element = this.richListBox.selectedItem; + 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": { + 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 "cmd_delete": + case "downloadsCmd_copyLocation": + 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(Cu.reportError); + } + 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_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(); + } + + 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 this._active; + } + if (aActive) { + DownloadsCommon.getSummary( + window, + DownloadsView.kItemCountLimit + ).refreshView(this); + } else { + DownloadsFooter.showingSummary = false; + } + + return (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. + return (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); + } + return 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); + } + return 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); + } + return aValue; + }, + + /** + * Focuses the root element of the summary. + */ + focus() { + if (this._summaryNode) { + this._summaryNode.focus(); + } + }, + + /** + * 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() { + if (this._showingSummary) { + DownloadsSummary.focus(); + } else { + DownloadsView.downloadsHistory.focus(); + } + }, + + _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; + } + return 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; + e.title.textContent = title; + 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..ebcae25a05 --- /dev/null +++ b/browser/components/downloads/content/downloadsCommands.inc.xhtml @@ -0,0 +1,27 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at http://mozilla.org/MPL/2.0/. + +<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"/> +</commandset> diff --git a/browser/components/downloads/content/downloadsCommands.js b/browser/components/downloads/content/downloadsCommands.js new file mode 100644 index 0000000000..53f5fac0f9 --- /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..fc34dadf7f --- /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"> + + <linkset> + <html:link rel="localization" href="browser/downloads.ftl" /> + </linkset> + + <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_show" + class="downloadShowMenuItem" +#ifdef XP_MACOSX + data-l10n-id="downloads-cmd-show-menuitem-mac" +#else + data-l10n-id="downloads-cmd-show-menuitem" +#endif + /> + + <menuseparator class="downloadCommandsSeparator"/> + + <menuitem command="downloadsCmd_openReferrer" + data-l10n-id="downloads-cmd-go-to-download-page"/> + <menuitem command="cmd_copy" + data-l10n-id="downloads-cmd-copy-download-link"/> + + <menuseparator/> + + <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..688bf75415 --- /dev/null +++ b/browser/components/downloads/content/downloadsPanel.inc.xhtml @@ -0,0 +1,212 @@ +<!-- 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> + <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')"/> +</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" + onpopupshown="DownloadsView.onContextPopupShown(event);" + onpopuphidden="DownloadsView.onContextPopupHidden(event);" + 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_show" + class="downloadShowMenuItem" + #ifdef XP_MACOSX + data-l10n-id="downloads-cmd-show-menuitem-mac" + #else + data-l10n-id="downloads-cmd-show-menuitem" + #endif + /> + + <menuseparator class="downloadCommandsSeparator"/> + + <menuitem command="downloadsCmd_openReferrer" + data-l10n-id="downloads-cmd-go-to-download-page"/> + <menuitem command="downloadsCmd_copyLocation" + data-l10n-id="downloads-cmd-copy-download-link"/> + + <menuseparator/> + + <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"> + + <panelview id="downloadsPanel-mainView"> + <vbox class="panel-view-body-unscrollable"> + <richlistbox id="downloadsListBox" + data-l10n-id="downloads-panel-list" + 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> + <hbox id="downloadsFooterButtons" + class="panel-footer"> + <button id="downloadsHistory" + data-l10n-id="downloads-history" + class="downloadsPanelFooterButton" + flex="1" + oncommand="DownloadsPanel.showDownloadsHistory();" + pack="start"/> + </hbox> + </stack> + </vbox> + </panelview> + + <panelview id="downloadsPanel-blockedSubview" + data-l10n-id="downloads-details" + descriptionheightworkaround="true" + class="PanelUI-subView"> + <vbox class="panel-view-body-unscrollable"> + <description id="downloadsPanel-blockedSubview-title"/> + <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> + + <panelview id="PanelUI-downloads" class="PanelUI-subView"> + <vbox class="panel-subview-body"> + <toolbarbutton id="appMenu-library-downloads-show-button" + data-l10n-id="downloads-cmd-show-downloads" + class="subviewbutton subviewbutton-iconic" + closemenu="none" + oncommand="DownloadsSubview.onShowDownloads(this);"/> + <toolbarseparator/> + <toolbaritem id="panelMenu_downloadsMenu" + orient="vertical" + smoothscroll="false" + flatList="true" + tooltip="bhTooltip"> + <!-- downloads menu items will go here --> + </toolbaritem> + </vbox> + <toolbarbutton id="PanelUI-downloadsMore" + data-l10n-id="downloads-history" + class="panel-subview-footer subviewbutton" + oncommand="BrowserDownloadsUI(); CustomizableUI.hidePanelForNode(this);"/> + </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..da28a485d2 --- /dev/null +++ b/browser/components/downloads/content/indicator.js @@ -0,0 +1,686 @@ +/* -*- 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, + + /** + * Indicates whether the button has been torn down. + * TODO: This is used for a temporary workaround for bug 1543537 and should be + * removed when fixed. + */ + _uninitialized: 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; + 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"); + } + } + }, + + hide() { + let button = this._placeholder; + if (this.autoHideDownloadsButton && button && button.closest("toolbar")) { + DownloadsPanel.hidePanel(); + button.setAttribute("hidden", "true"); + this._navBar.removeAttribute("downloadsbuttonshown"); + } + }, + + startAutoHide() { + if (DownloadsIndicatorView.hasDownloads) { + this.unhide(); + } else { + this.hide(); + } + }, + + checkForAutoHide() { + if (this._uninitialized) { + return; + } + 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() { + this._uninitialized = true; + 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.onWindowUnload); + 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.onWindowUnload); + 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; + } + + if (!DownloadsCommon.animateNotifications) { + 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) { + // No need to show visual notification if the panel is visible. + if (DownloadsPanel.isPanelShowing) { + return; + } + + let anchor = DownloadsButton._placeholder; + let widgetGroup = CustomizableUI.getWidget("downloads-button"); + let widget = widgetGroup.forWindow(window); + if (widgetGroup.areaType == CustomizableUI.TYPE_MENU_PANEL) { + if (anchor && this._isAncestorPanelOpen(anchor)) { + // If the containing panel is open, don't do anything, because the + // notification would appear under the open panel. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=984023 + return; + } + + // Otherwise, try to use the anchor of the panel: + anchor = widget.anchor; + } + if (!anchor || !isElementVisible(anchor.parentNode)) { + // Our container isn't visible, so can't show the animation: + return; + } + + // The notification element is positioned to show in the same location as + // the downloads button. It's not in the downloads button itself in order to + // be able to anchor the notification elsewhere if required, and to ensure + // the notification isn't clipped by overflow properties of the anchor's + // container. + // Note: no notifier animation for download finished in Photon + let notifier = this.notifier; + + if (aType == "start") { + // Show the notifier before measuring for size/placement. Being hidden by default + // avoids the interference with scrolling/APZ when the notifier element is + // tall enough to overlap the tabbrowser element + notifier.removeAttribute("hidden"); + + // the anchor height may vary if font-size is changed or + // compact/tablet mode is selected so recalculate this each time + let anchorRect = anchor.getBoundingClientRect(); + let notifierRect = notifier.getBoundingClientRect(); + let topDiff = anchorRect.top - notifierRect.top; + let leftDiff = anchorRect.left - notifierRect.left; + let heightDiff = anchorRect.height - notifierRect.height; + let widthDiff = anchorRect.width - notifierRect.width; + let translateX = leftDiff + 0.5 * widthDiff + "px"; + let translateY = topDiff + 0.5 * heightDiff + "px"; + notifier.style.transform = + "translate(" + translateX + ", " + translateY + ")"; + notifier.setAttribute("notification", aType); + } + anchor.setAttribute("notification", aType); + + let animationDuration; + // This value is determined by the overall duration of animation in CSS. + animationDuration = aType == "start" ? 760 : 850; + + this._currentNotificationType = aType; + + setTimeout(() => { + requestAnimationFrame(() => { + notifier.setAttribute("hidden", "true"); + notifier.removeAttribute("notification"); + notifier.style.transform = ""; + anchor.removeAttribute("notification"); + + requestAnimationFrame(() => { + let nextType = this._nextNotificationType; + this._currentNotificationType = null; + this._nextNotificationType = null; + if (nextType) { + this._showNotification(nextType); + } + }); + }); + }, animationDuration); + }, + + // 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) { + DownloadsButton.unhide(); + this._ensureOperational(); + } else { + DownloadsButton.checkForAutoHide(); + } + } + return aValue; + }, + 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 this._percentComplete; + } + + if (this._percentComplete !== aValue) { + this._percentComplete = aValue; + this._refreshAttention(); + + if (this._percentComplete >= 0) { + this.indicator.setAttribute("progress", "true"); + // For arrow type only: + // We set animationDelay to a minus value (0s ~ -100s) to show the + // corresponding frame needed for progress. + this._progressIcon.style.animationDelay = -this._percentComplete + "s"; + } else { + this.indicator.removeAttribute("progress"); + this._progressIcon.style.animationDelay = "1s"; + } + } + return aValue; + }, + _percentComplete: null, + + /** + * Set when the indicator should draw user attention to itself. + */ + set attention(aValue) { + if (!this._operational) { + return this._attention; + } + if (this._attention != aValue) { + this._attention = aValue; + this._refreshAttention(); + } + return this._attention; + }, + + _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_MENU_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 + + onWindowUnload() { + // This function is registered as an event listener, we can't use "this". + DownloadsIndicatorView.ensureTerminated(); + }, + + 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(); + 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, 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) { + return this._indicator; + } + + let indicator = document.getElementById("downloads-button"); + if (!indicator || indicator.getAttribute("indicator") != "true") { + return null; + } + + return (this._indicator = indicator); + }, + + get indicatorAnchor() { + let widgetGroup = CustomizableUI.getWidget("downloads-button"); + if (widgetGroup.areaType == CustomizableUI.TYPE_MENU_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" + )) + ); + }, + + get notifier() { + return ( + this._notifier || + (this._notifier = document.getElementById( + "downloads-notification-anchor" + )) + ); + }, + + _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, +}); |