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