/* -*- 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/. */

/**
 * 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) {
    ToolbarDropHandler.onDragOver(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 = Services.droppedLinkHandler.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,
});
