Bug 1147231 - Part 1 - Add a module to handle flexible doorhanger notifications. draft
authorPaolo Amadini <paolo.mozmail@amadzone.org>
Sun, 03 May 2015 12:21:51 +0100
changeset 826843 35d4096bb926a5e73e89c7234eb8fd9ad2830b32
parent 826832 b3e7de2df93d2805434e2533f8c5a3fa1a6a40b9
child 826844 35abae6c46616b0f4e4a994118dba872e890d985
push id118390
push userpaolo.mozmail@amadzone.org
push dateSun, 05 Aug 2018 15:07:34 +0000
bugs1147231
milestone63.0a1
Bug 1147231 - Part 1 - Add a module to handle flexible doorhanger notifications. MozReview-Commit-ID: HMZ0ewCyKgE
browser/base/content/browser.css
browser/base/content/popup-notifications.inc
toolkit/modules/Doorhangers.jsm
toolkit/modules/PopupNotifications.jsm
toolkit/modules/moz.build
toolkit/modules/tests/browser/browser.ini
toolkit/modules/tests/browser/browser_Doorhangers.js
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -960,17 +960,17 @@ html|*#fullscreen-exit-button {
 
 /* notification anchors should only be visible when their associated
    notifications are */
 .notification-anchor-icon {
   -moz-user-focus: normal;
 }
 
 #blocked-permissions-container > .blocked-permission-icon:not([showing]),
-.notification-anchor-icon:not([showing]) {
+.notification-anchor-icon:not([showing]):not(.showing) {
   display: none;
 }
 
 #invalid-form-popup > description {
   max-width: 280px;
 }
 
 .popup-anchor {
--- a/browser/base/content/popup-notifications.inc
+++ b/browser/base/content/popup-notifications.inc
@@ -1,10 +1,18 @@
 # to be included inside a popupset element
 
+    <panel id="doorhanger-panel"
+           type="arrow"
+           footertype="promobox"
+           position="after_start"
+           hidden="true"
+           orient="vertical"
+           role="alert"/>
+
     <panel id="notification-popup"
            type="arrow"
            position="after_start"
            hidden="true"
            orient="vertical"
            noautofocus="true"
            role="alert"/>
 
new file mode 100755
--- /dev/null
+++ b/toolkit/modules/Doorhangers.jsm
@@ -0,0 +1,606 @@
+/* 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 doorhanger-style user interfaces. These are displayed inside a panel
+ * anchored to icons that appear in the address bar of browser windows.
+ */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "Doorhangers",
+];
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+/**
+ * Provides constructors for new doorhangers and methods to find existing ones.
+ */
+this.Doorhangers = {
+  /**
+   * Retrieves an existing doorhanger associated with a browser and a type, or
+   * undefined if an associated doorhanger of that type cannot be found.
+   *
+   * @param options
+   *        An object with the following properties:
+   *        {
+   *          browser:
+   *            The <browser> element to which the doorhanger is associated.
+   *          anchorType:
+   *            The anchorType property to search for.
+   *        }
+   */
+  find(options) {
+    return DoorhangersInternal.browserHandlerFor(options.browser)
+                              .doorhangers
+                              .find(x => x.anchorType == options.anchorType);
+  },
+};
+
+this.DoorhangersInternal = {
+  /**
+   * Associates each chrome nsIXULWindow with the object handling the panel and
+   * the anchors for that window.
+   */
+  chromeHandlerFor(chromeWindow) {
+    let chromeHandler = this._chromeHandlersByChromeWindow.get(chromeWindow);
+    if (!chromeHandler) {
+      chromeHandler = new DoorhangersInternal.ChromeHandler(chromeWindow);
+      this._chromeHandlersByChromeWindow.set(chromeWindow, chromeHandler);
+    }
+    return chromeHandler;
+  },
+  _chromeHandlersByChromeWindow: new WeakMap(),
+
+  /**
+   * Associates each <browser> element with the object handling the set of
+   * doorhangers currently associated with that browser.
+   */
+  browserHandlerFor(browser) {
+    let browserHandler = this._browserHandlersByBrowser.get(browser);
+    if (!browserHandler) {
+      browserHandler = new DoorhangersInternal.BrowserHandler(browser);
+      this._browserHandlersByBrowser.set(browser, browserHandler);
+    }
+    return browserHandler;
+  },
+  _browserHandlersByBrowser: new WeakMap(),
+
+  /**
+   * Changes the <browser> element associated with the specified BrowserHandler
+   * object, keeping the mapping updated. The specified browser must currently
+   * be associated to either no BrowserHandler or a BrowserHandler that will be
+   * discarded.
+   */
+  changeBrowserForHandler(browserHandler, otherBrowser) {
+    browserHandler.detach();
+    browserHandler.browser = otherBrowser;
+    this._browserHandlersByBrowser.set(otherBrowser, browserHandler);
+    browserHandler.attach();
+  },
+};
+
+/**
+ * Handles state for a chrome browser window, in particular panel and anchors.
+ *
+ * When created, this object registers its event listeners.
+ */
+this.DoorhangersInternal.ChromeHandler = function (chromeWindow) {
+  this.chromeWindow = chromeWindow;
+  this.visibleAnchorElementsByType = new Map();
+
+  this.tabbrowser.tabContainer.addEventListener("TabSelect",
+                                                () => this.refreshAnchors());
+  this.panel.addEventListener("popuphidden", event => {
+    if (event.target != this.panel) {
+      return;
+    }
+    this.onPanelHidden();
+  });
+};
+
+this.DoorhangersInternal.ChromeHandler.prototype = {
+  /**
+   * Chrome nsIXULWindow associated with this object.
+   */
+  chromeWindow: null,
+
+  /**
+   * Chrome document for this chrome window.
+   */
+  get chromeDocument() {
+    return this.chromeWindow.document;
+  },
+
+  /**
+   * <tabbrowser> element used to find the currently selected <browser>.
+   */
+  get tabbrowser() {
+    return this.chromeDocument.getElementById("content");
+  },
+
+  /**
+   * XUL element containing the anchor icons.
+   */
+  get anchorContainer() {
+    return this.chromeDocument.getElementById("notification-popup-box");
+  },
+
+  /**
+   * XUL panel element that contains the doorhangers.
+   */
+  get panel() {
+    return this.chromeDocument.getElementById("doorhanger-panel");
+  },
+
+  /**
+   * BrowserHandler for the currently selected <browser> element.
+   */
+  get selectedBrowserHandler() {
+    return DoorhangersInternal.browserHandlerFor(this.tabbrowser
+                                                     .selectedBrowser);
+  },
+
+  /**
+   * Map with one entry for each visible anchor element.
+   */
+  visibleAnchorElementsByType: null,
+
+  /**
+   * Updates the visible anchor icons.
+   *
+   * When there are no visible anchor icons, their container is also hidden.
+   */
+  refreshAnchors() {
+    let visibleAnchorTypes = this.selectedBrowserHandler.anchorTypes;
+    let removeAnchorElementsByType = new Map(this.visibleAnchorElementsByType);
+
+    let previousElement = null;
+    for (let anchorType of visibleAnchorTypes) {
+      let anchorElement = this.visibleAnchorElementsByType.get(anchorType);
+      if (!anchorElement) {
+        anchorElement = this.createAnchorElement(anchorType);
+        // Insert the node after the previous one so that order is preserved.
+        let beforeElement = previousElement ? previousElement.nextSibling
+                                            : this.anchorContainer.firstChild;
+        this.anchorContainer.insertBefore(anchorElement, beforeElement);
+        this.visibleAnchorElementsByType.set(anchorType, anchorElement);
+      }
+      previousElement = anchorElement;
+      removeAnchorElementsByType.delete(anchorType);
+    }
+
+    for (let [anchorType, anchorElement] of removeAnchorElementsByType) {
+      this.anchorContainer.removeChild(anchorElement);
+      this.visibleAnchorElementsByType.delete(anchorType);
+    }
+
+    // We share the anchor container visibility with PopupNotifications.jsm.
+    if (visibleAnchorTypes.length == 0) {
+      this.anchorContainer.removeAttribute("showing-Doorhangers");
+      if (!this.anchorContainer.hasAttribute("showing-PopupNotifications")) {
+        this.anchorContainer.hidden = true;
+      }
+    } else {
+      this.anchorContainer.setAttribute("showing-Doorhangers", "true");
+      this.anchorContainer.hidden = false;
+    }
+  },
+
+  /**
+   * Creates a new anchor element for the speficied type and returns it.
+   */
+  createAnchorElement(anchorType) {
+    let anchorElement = this.chromeDocument.createElementNS(XUL_NS, "image");
+    anchorElement.setAttribute("role", "button");
+    anchorElement.classList.add("notification-anchor-icon");
+    // The "id" attribute is used for styling. We use this instead of a class
+    // name to simplify migration from PopupNotifications.jsm styling.
+    anchorElement.setAttribute("id", anchorType);
+    // This extra "showing" class is required to prevent the styles for
+    // PopupNotifications.jsm from hiding our icons.
+    anchorElement.classList.add("showing");
+    anchorElement.addEventListener("click", event => {
+      this.selectedAnchorType = anchorType;
+      this.showPanel();
+    });
+    return anchorElement;
+  },
+
+  /**
+   * Anchor to which the panel will be attached.
+   */
+  selectedAnchorType: "",
+
+  /**
+   * Object derived from DoorhangerBase that is currently displayed.
+   */
+  selectedDoorhanger: null,
+
+  /**
+   * Displays the panel for the selected anchor type asynchronously.
+   */
+  showPanel() {
+    Task.spawn(function* () {
+      if (this.panel.state != "closed") {
+        yield this.hidePanel();
+      }
+
+      let anchorElement = this.visibleAnchorElementsByType
+                              .get(this.selectedAnchorType);
+      let doorhanger = this.selectedBrowserHandler
+                           .doorhangers
+                           .find(x => x.anchorType == this.selectedAnchorType);
+      doorhanger.bind();
+      this.panel.appendChild(doorhanger.element);
+      // The panel has the "hidden" attribute because making it visible would
+      // have a performance impact when the browser window is first opened.
+      this.panel.hidden = false;
+      this.panel.openPopup(anchorElement, "bottomcenter topleft");
+      doorhanger.lateBind();
+
+      this.selectedDoorhanger = doorhanger;
+    }.bind(this)).catch(Cu.reportError);
+  },
+
+  /**
+   * Hides the currently displayed panel, then resolves the returned Promise.
+   */
+  hidePanel() {
+    if (this.panel.state == "closed") {
+      return Promise.resolve();
+    }
+    return new Promise(resolve => {
+      let listener = () => {
+        this.panel.removeEventListener("popuphidden", listener);
+        resolve();
+      }
+      this.panel.addEventListener("popuphidden", listener);
+      this.panel.hidePopup();
+    });
+  },
+
+  /**
+   * Called when the currently displayed panel is hidden.
+   */
+  onPanelHidden() {
+    this.panel.removeChild(this.selectedDoorhanger.element);
+    this.selectedDoorhanger.unbind();
+    this.selectedDoorhanger = null;
+  },
+};
+
+/**
+ * Handles state for a <browser> element inside a chrome browser window, in
+ * particular the set of doorhangers currently associated with the browser.
+ */
+this.DoorhangersInternal.BrowserHandler = function (browser) {
+  this.browser = browser;
+  this.doorhangers = [];
+  this.onSwapDocShells = this.onSwapDocShells.bind(this);
+  this.attach();
+};
+
+this.DoorhangersInternal.BrowserHandler.prototype = {
+  /**
+   * <browser> element associated with this object.
+   */
+  browser: null,
+
+  /**
+   * Registers event listeners for this browser.
+   */
+  attach() {
+    this.browser.addEventListener("SwapDocShells", this.onSwapDocShells);
+  },
+
+  /**
+   * Unregisters event listeners for this browser.
+   *
+   * This is only called in case the browser element changes.
+   */
+  detach() {
+    this.browser.removeEventListener("SwapDocShells", this.onSwapDocShells);
+  },
+
+  /**
+   * Handles the case where a tab is moved to a different window.
+   */
+  onSwapDocShells(event) {
+    // Since the first SwapDocShells event is raised on the target "about:blank"
+    // browser, we may miss it if no BrowserHandler was registered. This depends
+    // on whether the target window ever displayed doorhangers. Even if we get
+    // the event, we ignore it because we only want to swap the handlers once.
+    if (this.browser.currentURI.spec == "about:blank") {
+      // Note that due to how this check works, if an "about:blank" tab gets a
+      // doorhanger for some reason, it will not be persisted across windows.
+      return;
+    }
+
+    DoorhangersInternal.changeBrowserForHandler(this, event.detail);
+
+    // Refresh state now that this is attached to the new window.
+    this.maybeRefreshAnchors();
+  },
+
+  /**
+   * Array of objects derived from DoorhangerBase associated with this browser.
+   */
+  doorhangers: null,
+
+  /**
+   * Array of anchors types to display for this browser.
+   */
+  get anchorTypes() {
+    return [...new Set(this.doorhangers.map(x => x.anchorType))].sort();
+  },
+
+  /**
+   * Reference to the ChromeHandler object currently associated with this
+   * <browser> element. This may change when tabs are moved across windows.
+   */
+  get chromeHandler() {
+    let chromeWindow = this.browser.ownerDocument.defaultView;
+    return DoorhangersInternal.chromeHandlerFor(chromeWindow);
+  },
+
+  /**
+   * Whether this browser is selected.
+   */
+  get selected() {
+    return this.chromeHandler.selectedBrowserHandler == this;
+  },
+
+  /**
+   * Shows the specified doorhanger if this browser is selected.
+   */
+  maybeShow(doorhanger) {
+    if (this.selected) {
+      this.chromeHandler.selectedAnchorType = doorhanger.anchorType;
+      this.chromeHandler.showPanel();
+    }
+  },
+
+  /**
+   * Hides the displayed doorhanger if it is visible.
+   */
+  maybeHide(doorhanger) {
+    if (this.chromeHandler.selectedDoorhanger == doorhanger) {
+      return this.chromeHandler.hidePanel();
+    }
+    return Promise.resolve();
+  },
+
+  /**
+   * Updates the user interface if this browser is selected.
+   */
+  maybeRefreshAnchors() {
+    if (this.selected) {
+      this.chromeHandler.refreshAnchors();
+    }
+  },
+};
+
+/**
+ * Contains logic for anchoring a doorhanger and associating it with a browser.
+ *
+ * Derived objects should provide methods for handling the actual UI.
+ *
+ * @param {Object} properties
+ *        Properties from this object will be applied to the new instance.
+ */
+this.Doorhangers.DoorhangerBase = function (properties) {
+  if (properties) {
+    for (let name of Object.getOwnPropertyNames(properties)) {
+      if (name != "browser") {
+        this[name] = properties[name];
+      }
+    }
+  }
+  // The "browser" property must be set last because we need the values for all
+  // the other properties to be already set for a correct association.
+  if (properties.browser) {
+    this.browser = properties.browser;
+  }
+};
+
+this.Doorhangers.DoorhangerBase.prototype = {
+  /**
+   * Defines to which anchor the doorhanger will be attached, for example
+   * "password-fill-notification-icon".
+   */
+  anchorType: "default-doorhanger-anchor",
+
+  /**
+   * Current internal BrowserHandler object for this doorhanger, or null if the
+   * doorhanger has not been associated yet.
+   */
+  browserHandler: null,
+
+  /**
+   * <browser> element to which the doorhanger should be associated, or null if
+   * the doorhanger has not been associated yet.
+   *
+   * This may change during the lifetime of the doorhanger, in case the web page
+   * is moved to a different chrome window by the swapDocShells method.
+   */
+  get browser() {
+    return this.browserHandler ? this.browserHandler.browser : null;
+  },
+
+  /**
+   * Associates the doorhanger with its browser, or removes the association.
+   */
+  set browser(browser) {
+    if (browser == this.browser) {
+      return;
+    }
+    if (this.browserHandler) {
+      let index = this.browserHandler.doorhangers.indexOf(this);
+      this.browserHandler.doorhangers.splice(index, 1);
+      this.browserHandler.maybeRefreshAnchors();
+      this.browserHandler = null;
+    }
+    if (browser) {
+      this.browserHandler = DoorhangersInternal.browserHandlerFor(browser);
+      this.browserHandler.doorhangers.unshift(this);
+      this.browserHandler.maybeRefreshAnchors();
+    }
+  },
+
+  /**
+   * Opens the panel containing this doorhanger.
+   */
+  show() {
+    this.browserHandler.maybeShow(this);
+  },
+
+  /**
+   * Hides the panel containing this doorhanger asynchronously.
+   */
+  hide() {
+    this.browserHandler.maybeHide(this).catch(Cu.reportError);
+  },
+
+  /**
+   * Removes the doorhanger from the browser asynchronously.
+   */
+  remove() {
+    this.browserHandler.maybeHide(this).then(() => {
+      this.browser = null;
+    }).catch(Cu.reportError);
+  },
+
+  /**
+   * DOM document to which the doorhanger is currently associated.
+   *
+   * This may change during the lifetime of the doorhanger, in case the web page
+   * is moved to a different chrome window by the swapDocShells method.
+   */
+  get chomeDocument() {
+    return this.browserHandler.chromeHandler.chromeDocument;
+  },
+
+  /**
+   * XUL element with the doorhanger content.
+   *
+   * This element should be created or obtained from the chomeDocument of the
+   * doorhanger. Since the chromeDocument may change while the doorhanger is
+   * not bound, the reference to this element should not be cached.
+   */
+  element: null,
+
+  /**
+   * Binds this doorhanger to its UI controls.
+   *
+   * After this function returns, the "element" property must return a reference
+   * to the element that will be placed in the doorhangers panel.
+   */
+  bind() {
+    throw new Error("This method must be implemented by derived objects.");
+  },
+
+  /**
+   * Called when "element" is visible to attach event listeners. This is only
+   * implemented as a workaround for the inability to use XBL while hidden.
+   */
+  lateBind() {},
+
+  /**
+   * Unbinds this doorhanger from its UI controls. This is called after the
+   * element has been removed from the doorhangers panel.
+   *
+   * The default behavior is just to discard the element.
+   */
+  unbind() {
+    this.element = null;
+  },
+};
+
+/**
+ * Doorhanger implemented using the <popupnotification> element.
+ *
+ * @param {Object} properties
+ *        Properties from this object will be applied to the new instance.
+ */
+this.Doorhangers.NotificationDoorhanger = function (properties) {
+  Doorhangers.DoorhangerBase.call(this, properties);
+};
+
+this.Doorhangers.NotificationDoorhanger.prototype = {
+  __proto__: this.Doorhangers.DoorhangerBase.prototype,
+
+  /**
+   * Value assigned to the "popupid" attribute of the <popupnotification>
+   * element. This is only used for styling and is not required to be unique.
+   */
+  popupIdForStyling: null,
+
+  /**
+   * Main label of the notification.
+   */
+  message: null,
+
+  /**
+   * An object with the following properties:
+   * {
+   *   label:
+   *     The label of the main buton.
+   *   accessKey:
+   *     Access key associated with the label.
+   * }
+   */
+  mainAction: null,
+
+  /**
+   * Generally overridden to handle the main action.
+   */
+  callback() {},
+
+  /**
+   * Handles the main button.
+   *
+   * The default behavior is invoke the callback and remove the doorhanger.
+   */
+  onButtonClick() {
+    this.callback();
+    this.remove();
+  },
+
+  /**
+   * Handles the "not now" button.
+   *
+   * The default behavior is to hide the doorhanger.
+   */
+  onNotNowClick() {
+    this.hide();
+  },
+
+  // DoorhangerBase
+  bind() {
+    this.element = this.chomeDocument.createElementNS(XUL_NS,
+                                                      "popupnotification");
+    this.element.setAttribute("popupid", this.popupIdForStyling);
+    this.element.setAttribute("label", this.message);
+    this.element.setAttribute("buttonlabel", this.mainAction.label);
+    this.element.setAttribute("buttonaccesskey", this.mainAction.accessKey);
+  },
+
+  // DoorhangerBase
+  lateBind() {
+    this.element.button.addEventListener("command", () => this.onButtonClick());
+    this.element.menupopup.addEventListener("command", event => {
+      event.stopPropagation();
+      this.onNotNowClick();
+    });
+    this.element.closebutton.addEventListener("command", () => this.hide());
+  },
+};
--- a/toolkit/modules/PopupNotifications.jsm
+++ b/toolkit/modules/PopupNotifications.jsm
@@ -1127,16 +1127,17 @@ PopupNotifications.prototype = {
       // Also filter out notifications that are for a different anchor.
       notificationsToShow = notificationsToShow.filter(function(n) {
         return anchors.has(n.anchorElement);
       });
 
       if (useIconBox) {
         this._showIcons(notifications);
         this.iconBox.hidden = false;
+        this.iconBox.setAttribute("showing-PopupNotifications", "true");
         // Make sure that panels can only be attached to anchors of shown
         // notifications inside an iconBox.
         anchors = this._getAnchorsForNotifications(notificationsToShow);
       } else if (anchors.size) {
         this._updateAnchorIcons(notifications, anchors);
       }
     }
 
@@ -1159,17 +1160,20 @@ PopupNotifications.prototype = {
       // which case we want to continue showing any existing notifications.
       if (!dismissShowing)
         this._dismiss();
 
       // Only hide the iconBox if we actually have no notifications (as opposed
       // to not having any showable notifications)
       if (!haveNotifications) {
         if (useIconBox) {
-          this.iconBox.hidden = true;
+          this.iconBox.removeAttribute("showing-PopupNotifications");
+          if (!this.iconBox.hasAttribute("showing-Doorhangers")) {
+            this.iconBox.hidden = true;
+          }
         } else if (anchors.size) {
           for (let anchorElement of anchors)
             anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
         }
       }
 
       // Stop listening to keyboard events for notifications.
       this.window.removeEventListener("keypress", this._handleWindowKeyPress, true);
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -192,16 +192,17 @@ EXTRA_JS_MODULES += [
     'Color.jsm',
     'Console.jsm',
     'CreditCard.jsm',
     'css-selector.js',
     'DateTimePickerContent.jsm',
     'DateTimePickerParent.jsm',
     'DeferredTask.jsm',
     'Deprecated.jsm',
+    'Doorhangers.jsm',
     'E10SUtils.jsm',
     'EventEmitter.jsm',
     'FileUtils.jsm',
     'FindBarChild.jsm',
     'Finder.jsm',
     'FinderHighlighter.jsm',
     'FinderIterator.jsm',
     'FormLikeFactory.jsm',
--- a/toolkit/modules/tests/browser/browser.ini
+++ b/toolkit/modules/tests/browser/browser.ini
@@ -24,16 +24,17 @@ support-files =
   WebRequest_dynamic.sjs
   WebRequest_redirection.sjs
 
 [browser_AsyncPrefs.js]
 [browser_Battery.js]
 [browser_BrowserUtils.js]
 [browser_CreditCard.js]
 [browser_Deprecated.js]
+[browser_Doorhangers.js]
 [browser_Finder.js]
 [browser_Finder_hidden_textarea.js]
 skip-if = verify && debug
 [browser_Finder_offscreen_text.js]
 [browser_Finder_overflowed_onscreen.js]
 [browser_Finder_overflowed_textarea.js]
 skip-if = (verify && debug && (os == 'mac' || os == 'linux'))
 [browser_Finder_pointer_events_none.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/tests/browser/browser_Doorhangers.js
@@ -0,0 +1,11 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource://gre/modules/Doorhangers.jsm", this);
+
+/**
+ * Test creating and disaplaying a notification-type doorhanger.
+ */
+add_task(function* test_notification() {
+  ok(true, "Test");
+});