Bug 1147231 - Part 1 - Add a module to handle flexible doorhanger notifications.
MozReview-Commit-ID: HMZ0ewCyKgE
--- 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");
+});