michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public
michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this
michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
michael@0:
michael@0: this.EXPORTED_SYMBOLS = ["PopupNotifications"];
michael@0:
michael@0: var Cc = Components.classes, Ci = Components.interfaces, Cu = Components.utils;
michael@0:
michael@0: Cu.import("resource://gre/modules/Services.jsm");
michael@0:
michael@0: const NOTIFICATION_EVENT_DISMISSED = "dismissed";
michael@0: const NOTIFICATION_EVENT_REMOVED = "removed";
michael@0: const NOTIFICATION_EVENT_SHOWING = "showing";
michael@0: const NOTIFICATION_EVENT_SHOWN = "shown";
michael@0: const NOTIFICATION_EVENT_SWAPPING = "swapping";
michael@0:
michael@0: const ICON_SELECTOR = ".notification-anchor-icon";
michael@0: const ICON_ATTRIBUTE_SHOWING = "showing";
michael@0:
michael@0: const PREF_SECURITY_DELAY = "security.notification_enable_delay";
michael@0:
michael@0: let popupNotificationsMap = new WeakMap();
michael@0: let gNotificationParents = new WeakMap;
michael@0:
michael@0: function getAnchorFromBrowser(aBrowser) {
michael@0: let anchor = aBrowser.getAttribute("popupnotificationanchor") ||
michael@0: aBrowser.popupnotificationanchor;
michael@0: if (anchor) {
michael@0: if (anchor instanceof Ci.nsIDOMXULElement) {
michael@0: return anchor;
michael@0: }
michael@0: return aBrowser.ownerDocument.getElementById(anchor);
michael@0: }
michael@0: return null;
michael@0: }
michael@0:
michael@0: /**
michael@0: * Notification object describes a single popup notification.
michael@0: *
michael@0: * @see PopupNotifications.show()
michael@0: */
michael@0: function Notification(id, message, anchorID, mainAction, secondaryActions,
michael@0: browser, owner, options) {
michael@0: this.id = id;
michael@0: this.message = message;
michael@0: this.anchorID = anchorID;
michael@0: this.mainAction = mainAction;
michael@0: this.secondaryActions = secondaryActions || [];
michael@0: this.browser = browser;
michael@0: this.owner = owner;
michael@0: this.options = options || {};
michael@0: }
michael@0:
michael@0: Notification.prototype = {
michael@0:
michael@0: id: null,
michael@0: message: null,
michael@0: anchorID: null,
michael@0: mainAction: null,
michael@0: secondaryActions: null,
michael@0: browser: null,
michael@0: owner: null,
michael@0: options: null,
michael@0: timeShown: null,
michael@0:
michael@0: /**
michael@0: * Removes the notification and updates the popup accordingly if needed.
michael@0: */
michael@0: remove: function Notification_remove() {
michael@0: this.owner.remove(this);
michael@0: },
michael@0:
michael@0: get anchorElement() {
michael@0: let iconBox = this.owner.iconBox;
michael@0:
michael@0: let anchorElement = getAnchorFromBrowser(this.browser);
michael@0:
michael@0: if (!iconBox)
michael@0: return anchorElement;
michael@0:
michael@0: if (!anchorElement && this.anchorID)
michael@0: anchorElement = iconBox.querySelector("#"+this.anchorID);
michael@0:
michael@0: // Use a default anchor icon if it's available
michael@0: if (!anchorElement)
michael@0: anchorElement = iconBox.querySelector("#default-notification-icon") ||
michael@0: iconBox;
michael@0:
michael@0: return anchorElement;
michael@0: },
michael@0:
michael@0: reshow: function() {
michael@0: this.owner._reshowNotifications(this.anchorElement, this.browser);
michael@0: }
michael@0: };
michael@0:
michael@0: /**
michael@0: * The PopupNotifications object manages popup notifications for a given browser
michael@0: * window.
michael@0: * @param tabbrowser
michael@0: * window's . Used to observe tab switching events and
michael@0: * for determining the active browser element.
michael@0: * @param panel
michael@0: * The element to use for notifications. The panel is
michael@0: * populated with children and displayed it as
michael@0: * needed.
michael@0: * @param iconBox
michael@0: * Reference to a container element that should be hidden or
michael@0: * unhidden when notifications are hidden or shown. It should be the
michael@0: * parent of anchor elements whose IDs are passed to show().
michael@0: * It is used as a fallback popup anchor if notifications specify
michael@0: * invalid or non-existent anchor IDs.
michael@0: */
michael@0: this.PopupNotifications = function PopupNotifications(tabbrowser, panel, iconBox) {
michael@0: if (!(tabbrowser instanceof Ci.nsIDOMXULElement))
michael@0: throw "Invalid tabbrowser";
michael@0: if (iconBox && !(iconBox instanceof Ci.nsIDOMXULElement))
michael@0: throw "Invalid iconBox";
michael@0: if (!(panel instanceof Ci.nsIDOMXULElement))
michael@0: throw "Invalid panel";
michael@0:
michael@0: this.window = tabbrowser.ownerDocument.defaultView;
michael@0: this.panel = panel;
michael@0: this.tabbrowser = tabbrowser;
michael@0: this.iconBox = iconBox;
michael@0: this.buttonDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY);
michael@0:
michael@0: this.panel.addEventListener("popuphidden", this, true);
michael@0:
michael@0: this.window.addEventListener("activate", this, true);
michael@0: if (this.tabbrowser.tabContainer)
michael@0: this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true);
michael@0: }
michael@0:
michael@0: PopupNotifications.prototype = {
michael@0:
michael@0: window: null,
michael@0: panel: null,
michael@0: tabbrowser: null,
michael@0:
michael@0: _iconBox: null,
michael@0: set iconBox(iconBox) {
michael@0: // Remove the listeners on the old iconBox, if needed
michael@0: if (this._iconBox) {
michael@0: this._iconBox.removeEventListener("click", this, false);
michael@0: this._iconBox.removeEventListener("keypress", this, false);
michael@0: }
michael@0: this._iconBox = iconBox;
michael@0: if (iconBox) {
michael@0: iconBox.addEventListener("click", this, false);
michael@0: iconBox.addEventListener("keypress", this, false);
michael@0: }
michael@0: },
michael@0: get iconBox() {
michael@0: return this._iconBox;
michael@0: },
michael@0:
michael@0: /**
michael@0: * Enable or disable the opening/closing transition.
michael@0: * @param state
michael@0: * Boolean state
michael@0: */
michael@0: set transitionsEnabled(state) {
michael@0: if (state) {
michael@0: this.panel.removeAttribute("animate");
michael@0: }
michael@0: else {
michael@0: this.panel.setAttribute("animate", "false");
michael@0: }
michael@0: },
michael@0:
michael@0: /**
michael@0: * Retrieve a Notification object associated with the browser/ID pair.
michael@0: * @param id
michael@0: * The Notification ID to search for.
michael@0: * @param browser
michael@0: * The browser whose notifications should be searched. If null, the
michael@0: * currently selected browser's notifications will be searched.
michael@0: *
michael@0: * @returns the corresponding Notification object, or null if no such
michael@0: * notification exists.
michael@0: */
michael@0: getNotification: function PopupNotifications_getNotification(id, browser) {
michael@0: let n = null;
michael@0: let notifications = this._getNotificationsForBrowser(browser || this.tabbrowser.selectedBrowser);
michael@0: notifications.some(function(x) x.id == id && (n = x));
michael@0: return n;
michael@0: },
michael@0:
michael@0: /**
michael@0: * Adds a new popup notification.
michael@0: * @param browser
michael@0: * The element associated with the notification. Must not
michael@0: * be null.
michael@0: * @param id
michael@0: * A unique ID that identifies the type of notification (e.g.
michael@0: * "geolocation"). Only one notification with a given ID can be visible
michael@0: * at a time. If a notification already exists with the given ID, it
michael@0: * will be replaced.
michael@0: * @param message
michael@0: * The text to be displayed in the notification.
michael@0: * @param anchorID
michael@0: * The ID of the element that should be used as this notification
michael@0: * popup's anchor. May be null, in which case the notification will be
michael@0: * anchored to the iconBox.
michael@0: * @param mainAction
michael@0: * A JavaScript object literal describing the notification button's
michael@0: * action. If present, it must have the following properties:
michael@0: * - label (string): the button's label.
michael@0: * - accessKey (string): the button's accessKey.
michael@0: * - callback (function): a callback to be invoked when the button is
michael@0: * pressed.
michael@0: * - [optional] dismiss (boolean): If this is true, the notification
michael@0: * will be dismissed instead of removed after running the callback.
michael@0: * If null, the notification will not have a button, and
michael@0: * secondaryActions will be ignored.
michael@0: * @param secondaryActions
michael@0: * An optional JavaScript array describing the notification's alternate
michael@0: * actions. The array should contain objects with the same properties
michael@0: * as mainAction. These are used to populate the notification button's
michael@0: * dropdown menu.
michael@0: * @param options
michael@0: * An options JavaScript object holding additional properties for the
michael@0: * notification. The following properties are currently supported:
michael@0: * persistence: An integer. The notification will not automatically
michael@0: * dismiss for this many page loads.
michael@0: * timeout: A time in milliseconds. The notification will not
michael@0: * automatically dismiss before this time.
michael@0: * persistWhileVisible:
michael@0: * A boolean. If true, a visible notification will always
michael@0: * persist across location changes.
michael@0: * dismissed: Whether the notification should be added as a dismissed
michael@0: * notification. Dismissed notifications can be activated
michael@0: * by clicking on their anchorElement.
michael@0: * eventCallback:
michael@0: * Callback to be invoked when the notification changes
michael@0: * state. The callback's first argument is a string
michael@0: * identifying the state change:
michael@0: * "dismissed": notification has been dismissed by the
michael@0: * user (e.g. by clicking away or switching
michael@0: * tabs)
michael@0: * "removed": notification has been removed (due to
michael@0: * location change or user action)
michael@0: * "showing": notification is about to be shown
michael@0: * (this can be fired multiple times as
michael@0: * notifications are dismissed and re-shown)
michael@0: * If the callback returns true, the notification
michael@0: * will be dismissed.
michael@0: * "shown": notification has been shown (this can be fired
michael@0: * multiple times as notifications are dismissed
michael@0: * and re-shown)
michael@0: * "swapping": the docshell of the browser that created
michael@0: * the notification is about to be swapped to
michael@0: * another browser. A second parameter contains
michael@0: * the browser that is receiving the docshell,
michael@0: * so that the event callback can transfer stuff
michael@0: * specific to this notification.
michael@0: * If the callback returns true, the notification
michael@0: * will be moved to the new browser.
michael@0: * If the callback isn't implemented, returns false,
michael@0: * or doesn't return any value, the notification
michael@0: * will be removed.
michael@0: * neverShow: Indicate that no popup should be shown for this
michael@0: * notification. Useful for just showing the anchor icon.
michael@0: * removeOnDismissal:
michael@0: * Notifications with this parameter set to true will be
michael@0: * removed when they would have otherwise been dismissed
michael@0: * (i.e. any time the popup is closed due to user
michael@0: * interaction).
michael@0: * hideNotNow: If true, indicates that the 'Not Now' menuitem should
michael@0: * not be shown. If 'Not Now' is hidden, it needs to be
michael@0: * replaced by another 'do nothing' item, so providing at
michael@0: * least one secondary action is required; and one of the
michael@0: * actions needs to have the 'dismiss' property set to true.
michael@0: * popupIconURL:
michael@0: * A string. URL of the image to be displayed in the popup.
michael@0: * Normally specified in CSS using list-style-image and the
michael@0: * .popup-notification-icon[popupid=...] selector.
michael@0: * learnMoreURL:
michael@0: * A string URL. Setting this property will make the
michael@0: * prompt display a "Learn More" link that, when clicked,
michael@0: * opens the URL in a new tab.
michael@0: * @returns the Notification object corresponding to the added notification.
michael@0: */
michael@0: show: function PopupNotifications_show(browser, id, message, anchorID,
michael@0: mainAction, secondaryActions, options) {
michael@0: function isInvalidAction(a) {
michael@0: return !a || !(typeof(a.callback) == "function") || !a.label || !a.accessKey;
michael@0: }
michael@0:
michael@0: if (!browser)
michael@0: throw "PopupNotifications_show: invalid browser";
michael@0: if (!id)
michael@0: throw "PopupNotifications_show: invalid ID";
michael@0: if (mainAction && isInvalidAction(mainAction))
michael@0: throw "PopupNotifications_show: invalid mainAction";
michael@0: if (secondaryActions && secondaryActions.some(isInvalidAction))
michael@0: throw "PopupNotifications_show: invalid secondaryActions";
michael@0: if (options && options.hideNotNow &&
michael@0: (!secondaryActions || !secondaryActions.length ||
michael@0: !secondaryActions.concat(mainAction).some(action => action.dismiss)))
michael@0: throw "PopupNotifications_show: 'Not Now' item hidden without replacement";
michael@0:
michael@0: let notification = new Notification(id, message, anchorID, mainAction,
michael@0: secondaryActions, browser, this, options);
michael@0:
michael@0: if (options && options.dismissed)
michael@0: notification.dismissed = true;
michael@0:
michael@0: let existingNotification = this.getNotification(id, browser);
michael@0: if (existingNotification)
michael@0: this._remove(existingNotification);
michael@0:
michael@0: let notifications = this._getNotificationsForBrowser(browser);
michael@0: notifications.push(notification);
michael@0:
michael@0: let isActive = this._isActiveBrowser(browser);
michael@0: let fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager);
michael@0: if (isActive && fm.activeWindow == this.window) {
michael@0: // show panel now
michael@0: this._update(notifications, notification.anchorElement, true);
michael@0: } else {
michael@0: // Otherwise, update() will display the notification the next time the
michael@0: // relevant tab/window is selected.
michael@0:
michael@0: // If the tab is selected but the window is in the background, let the OS
michael@0: // tell the user that there's a notification waiting in that window.
michael@0: // At some point we might want to do something about background tabs here
michael@0: // too. When the user switches to this window, we'll show the panel if
michael@0: // this browser is a tab (thus showing the anchor icon). For
michael@0: // non-tabbrowser browsers, we need to make the icon visible now or the
michael@0: // user will not be able to open the panel.
michael@0: if (!notification.dismissed && isActive) {
michael@0: this.window.getAttention();
michael@0: if (notification.anchorElement.parentNode != this.iconBox) {
michael@0: this._updateAnchorIcon(notifications, notification.anchorElement);
michael@0: }
michael@0: }
michael@0:
michael@0: // Notify observers that we're not showing the popup (useful for testing)
michael@0: this._notify("backgroundShow");
michael@0: }
michael@0:
michael@0: return notification;
michael@0: },
michael@0:
michael@0: /**
michael@0: * Returns true if the notification popup is currently being displayed.
michael@0: */
michael@0: get isPanelOpen() {
michael@0: let panelState = this.panel.state;
michael@0:
michael@0: return panelState == "showing" || panelState == "open";
michael@0: },
michael@0:
michael@0: /**
michael@0: * Called by the consumer to indicate that a browser's location has changed,
michael@0: * so that we can update the active notifications accordingly.
michael@0: */
michael@0: locationChange: function PopupNotifications_locationChange(aBrowser) {
michael@0: if (!aBrowser)
michael@0: throw "PopupNotifications_locationChange: invalid browser";
michael@0:
michael@0: let notifications = this._getNotificationsForBrowser(aBrowser);
michael@0:
michael@0: notifications = notifications.filter(function (notification) {
michael@0: // The persistWhileVisible option allows an open notification to persist
michael@0: // across location changes
michael@0: if (notification.options.persistWhileVisible &&
michael@0: this.isPanelOpen) {
michael@0: if ("persistence" in notification.options &&
michael@0: notification.options.persistence)
michael@0: notification.options.persistence--;
michael@0: return true;
michael@0: }
michael@0:
michael@0: // The persistence option allows a notification to persist across multiple
michael@0: // page loads
michael@0: if ("persistence" in notification.options &&
michael@0: notification.options.persistence) {
michael@0: notification.options.persistence--;
michael@0: return true;
michael@0: }
michael@0:
michael@0: // The timeout option allows a notification to persist until a certain time
michael@0: if ("timeout" in notification.options &&
michael@0: Date.now() <= notification.options.timeout) {
michael@0: return true;
michael@0: }
michael@0:
michael@0: this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED);
michael@0: return false;
michael@0: }, this);
michael@0:
michael@0: this._setNotificationsForBrowser(aBrowser, notifications);
michael@0:
michael@0: if (this._isActiveBrowser(aBrowser)) {
michael@0: // get the anchor element if the browser has defined one so it will
michael@0: // _update will handle both the tabs iconBox and non-tab permission
michael@0: // anchors.
michael@0: let anchorElement = notifications.length > 0 ? notifications[0].anchorElement : null;
michael@0: if (!anchorElement)
michael@0: anchorElement = getAnchorFromBrowser(aBrowser);
michael@0: this._update(notifications, anchorElement);
michael@0: }
michael@0: },
michael@0:
michael@0: /**
michael@0: * Removes a Notification.
michael@0: * @param notification
michael@0: * The Notification object to remove.
michael@0: */
michael@0: remove: function PopupNotifications_remove(notification) {
michael@0: this._remove(notification);
michael@0:
michael@0: if (this._isActiveBrowser(notification.browser)) {
michael@0: let notifications = this._getNotificationsForBrowser(notification.browser);
michael@0: this._update(notifications, notification.anchorElement);
michael@0: }
michael@0: },
michael@0:
michael@0: handleEvent: function (aEvent) {
michael@0: switch (aEvent.type) {
michael@0: case "popuphidden":
michael@0: this._onPopupHidden(aEvent);
michael@0: break;
michael@0: case "activate":
michael@0: case "TabSelect":
michael@0: let self = this;
michael@0: // setTimeout(..., 0) needed, otherwise openPopup from "activate" event
michael@0: // handler results in the popup being hidden again for some reason...
michael@0: this.window.setTimeout(function () {
michael@0: self._update();
michael@0: }, 0);
michael@0: break;
michael@0: case "click":
michael@0: case "keypress":
michael@0: this._onIconBoxCommand(aEvent);
michael@0: break;
michael@0: }
michael@0: },
michael@0:
michael@0: ////////////////////////////////////////////////////////////////////////////////
michael@0: // Utility methods
michael@0: ////////////////////////////////////////////////////////////////////////////////
michael@0:
michael@0: _ignoreDismissal: null,
michael@0: _currentAnchorElement: null,
michael@0:
michael@0: /**
michael@0: * Gets notifications for the currently selected browser.
michael@0: */
michael@0: get _currentNotifications() {
michael@0: return this.tabbrowser.selectedBrowser ? this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser) : [];
michael@0: },
michael@0:
michael@0: _remove: function PopupNotifications_removeHelper(notification) {
michael@0: // This notification may already be removed, in which case let's just fail
michael@0: // silently.
michael@0: let notifications = this._getNotificationsForBrowser(notification.browser);
michael@0: if (!notifications)
michael@0: return;
michael@0:
michael@0: var index = notifications.indexOf(notification);
michael@0: if (index == -1)
michael@0: return;
michael@0:
michael@0: if (this._isActiveBrowser(notification.browser))
michael@0: notification.anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
michael@0:
michael@0: // remove the notification
michael@0: notifications.splice(index, 1);
michael@0: this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED);
michael@0: },
michael@0:
michael@0: /**
michael@0: * Dismisses the notification without removing it.
michael@0: */
michael@0: _dismiss: function PopupNotifications_dismiss() {
michael@0: let browser = this.panel.firstChild &&
michael@0: this.panel.firstChild.notification.browser;
michael@0: this.panel.hidePopup();
michael@0: if (browser)
michael@0: browser.focus();
michael@0: },
michael@0:
michael@0: /**
michael@0: * Hides the notification popup.
michael@0: */
michael@0: _hidePanel: function PopupNotifications_hide() {
michael@0: this._ignoreDismissal = true;
michael@0: this.panel.hidePopup();
michael@0: this._ignoreDismissal = false;
michael@0: },
michael@0:
michael@0: /**
michael@0: * Removes all notifications from the notification popup.
michael@0: */
michael@0: _clearPanel: function () {
michael@0: let popupnotification;
michael@0: while ((popupnotification = this.panel.lastChild)) {
michael@0: this.panel.removeChild(popupnotification);
michael@0:
michael@0: // If this notification was provided by the chrome document rather than
michael@0: // created ad hoc, move it back to where we got it from.
michael@0: let originalParent = gNotificationParents.get(popupnotification);
michael@0: if (originalParent) {
michael@0: popupnotification.notification = null;
michael@0:
michael@0: // Remove nodes dynamically added to the notification's menu button
michael@0: // in _refreshPanel. Keep popupnotificationcontent nodes; they are
michael@0: // provided by the chrome document.
michael@0: let contentNode = popupnotification.lastChild;
michael@0: while (contentNode) {
michael@0: let previousSibling = contentNode.previousSibling;
michael@0: if (contentNode.nodeName != "popupnotificationcontent")
michael@0: popupnotification.removeChild(contentNode);
michael@0: contentNode = previousSibling;
michael@0: }
michael@0:
michael@0: // Re-hide the notification such that it isn't rendered in the chrome
michael@0: // document. _refreshPanel will unhide it again when needed.
michael@0: popupnotification.hidden = true;
michael@0:
michael@0: originalParent.appendChild(popupnotification);
michael@0: }
michael@0: }
michael@0: },
michael@0:
michael@0: _refreshPanel: function PopupNotifications_refreshPanel(notificationsToShow) {
michael@0: this._clearPanel();
michael@0:
michael@0: const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
michael@0:
michael@0: notificationsToShow.forEach(function (n) {
michael@0: let doc = this.window.document;
michael@0:
michael@0: // Append "-notification" to the ID to try to avoid ID conflicts with other stuff
michael@0: // in the document.
michael@0: let popupnotificationID = n.id + "-notification";
michael@0:
michael@0: // If the chrome document provides a popupnotification with this id, use
michael@0: // that. Otherwise create it ad-hoc.
michael@0: let popupnotification = doc.getElementById(popupnotificationID);
michael@0: if (popupnotification)
michael@0: gNotificationParents.set(popupnotification, popupnotification.parentNode);
michael@0: else
michael@0: popupnotification = doc.createElementNS(XUL_NS, "popupnotification");
michael@0:
michael@0: popupnotification.setAttribute("label", n.message);
michael@0: popupnotification.setAttribute("id", popupnotificationID);
michael@0: popupnotification.setAttribute("popupid", n.id);
michael@0: popupnotification.setAttribute("closebuttoncommand", "PopupNotifications._dismiss();");
michael@0: if (n.mainAction) {
michael@0: popupnotification.setAttribute("buttonlabel", n.mainAction.label);
michael@0: popupnotification.setAttribute("buttonaccesskey", n.mainAction.accessKey);
michael@0: popupnotification.setAttribute("buttoncommand", "PopupNotifications._onButtonCommand(event);");
michael@0: popupnotification.setAttribute("menucommand", "PopupNotifications._onMenuCommand(event);");
michael@0: popupnotification.setAttribute("closeitemcommand", "PopupNotifications._dismiss();event.stopPropagation();");
michael@0: } else {
michael@0: popupnotification.removeAttribute("buttonlabel");
michael@0: popupnotification.removeAttribute("buttonaccesskey");
michael@0: popupnotification.removeAttribute("buttoncommand");
michael@0: popupnotification.removeAttribute("menucommand");
michael@0: popupnotification.removeAttribute("closeitemcommand");
michael@0: }
michael@0:
michael@0: if (n.options.popupIconURL)
michael@0: popupnotification.setAttribute("icon", n.options.popupIconURL);
michael@0: if (n.options.learnMoreURL)
michael@0: popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL);
michael@0: else
michael@0: popupnotification.removeAttribute("learnmoreurl");
michael@0:
michael@0: popupnotification.notification = n;
michael@0:
michael@0: if (n.secondaryActions) {
michael@0: n.secondaryActions.forEach(function (a) {
michael@0: let item = doc.createElementNS(XUL_NS, "menuitem");
michael@0: item.setAttribute("label", a.label);
michael@0: item.setAttribute("accesskey", a.accessKey);
michael@0: item.notification = n;
michael@0: item.action = a;
michael@0:
michael@0: popupnotification.appendChild(item);
michael@0: }, this);
michael@0:
michael@0: if (n.options.hideNotNow) {
michael@0: popupnotification.setAttribute("hidenotnow", "true");
michael@0: }
michael@0: else if (n.secondaryActions.length) {
michael@0: let closeItemSeparator = doc.createElementNS(XUL_NS, "menuseparator");
michael@0: popupnotification.appendChild(closeItemSeparator);
michael@0: }
michael@0: }
michael@0:
michael@0: this.panel.appendChild(popupnotification);
michael@0:
michael@0: // The popupnotification may be hidden if we got it from the chrome
michael@0: // document rather than creating it ad hoc.
michael@0: popupnotification.hidden = false;
michael@0: }, this);
michael@0: },
michael@0:
michael@0: _showPanel: function PopupNotifications_showPanel(notificationsToShow, anchorElement) {
michael@0: this.panel.hidden = false;
michael@0:
michael@0: notificationsToShow = notificationsToShow.filter(n => {
michael@0: let dismiss = this._fireCallback(n, NOTIFICATION_EVENT_SHOWING);
michael@0: if (dismiss)
michael@0: n.dismissed = true;
michael@0: return !dismiss;
michael@0: });
michael@0: if (!notificationsToShow.length)
michael@0: return;
michael@0:
michael@0: this._refreshPanel(notificationsToShow);
michael@0:
michael@0: if (this.isPanelOpen && this._currentAnchorElement == anchorElement)
michael@0: return;
michael@0:
michael@0: // If the panel is already open but we're changing anchors, we need to hide
michael@0: // it first. Otherwise it can appear in the wrong spot. (_hidePanel is
michael@0: // safe to call even if the panel is already hidden.)
michael@0: this._hidePanel();
michael@0:
michael@0: // If the anchor element is hidden or null, use the tab as the anchor. We
michael@0: // only ever show notifications for the current browser, so we can just use
michael@0: // the current tab.
michael@0: let selectedTab = this.tabbrowser.selectedTab;
michael@0: if (anchorElement) {
michael@0: let bo = anchorElement.boxObject;
michael@0: if (bo.height == 0 && bo.width == 0)
michael@0: anchorElement = selectedTab; // hidden
michael@0: } else {
michael@0: anchorElement = selectedTab; // null
michael@0: }
michael@0:
michael@0: this._currentAnchorElement = anchorElement;
michael@0:
michael@0: // On OS X and Linux we need a different panel arrow color for
michael@0: // click-to-play plugins, so copy the popupid and use css.
michael@0: this.panel.setAttribute("popupid", this.panel.firstChild.getAttribute("popupid"));
michael@0: notificationsToShow.forEach(function (n) {
michael@0: // Remember the time the notification was shown for the security delay.
michael@0: n.timeShown = this.window.performance.now();
michael@0: }, this);
michael@0: this.panel.openPopup(anchorElement, "bottomcenter topleft");
michael@0: notificationsToShow.forEach(function (n) {
michael@0: this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
michael@0: }, this);
michael@0: },
michael@0:
michael@0: /**
michael@0: * Updates the notification state in response to window activation or tab
michael@0: * selection changes.
michael@0: *
michael@0: * @param notifications an array of Notification instances. if null,
michael@0: * notifications will be retrieved off the current
michael@0: * browser tab
michael@0: * @param anchor is a XUL element that the notifications panel will be
michael@0: * anchored to
michael@0: * @param dismissShowing if true, dismiss any currently visible notifications
michael@0: * if there are no notifications to show. Otherwise,
michael@0: * currently displayed notifications will be left alone.
michael@0: */
michael@0: _update: function PopupNotifications_update(notifications, anchor, dismissShowing = false) {
michael@0: let useIconBox = this.iconBox && (!anchor || anchor.parentNode == this.iconBox);
michael@0: if (useIconBox) {
michael@0: // hide icons of the previous tab.
michael@0: this._hideIcons();
michael@0: }
michael@0:
michael@0: let anchorElement = anchor, notificationsToShow = [];
michael@0: if (!notifications)
michael@0: notifications = this._currentNotifications;
michael@0: let haveNotifications = notifications.length > 0;
michael@0: if (haveNotifications) {
michael@0: // Only show the notifications that have the passed-in anchor (or the
michael@0: // first notification's anchor, if none was passed in). Other
michael@0: // notifications will be shown once these are dismissed.
michael@0: anchorElement = anchor || notifications[0].anchorElement;
michael@0:
michael@0: if (useIconBox) {
michael@0: this._showIcons(notifications);
michael@0: this.iconBox.hidden = false;
michael@0: } else if (anchorElement) {
michael@0: this._updateAnchorIcon(notifications, anchorElement);
michael@0: }
michael@0:
michael@0: // Also filter out notifications that have been dismissed.
michael@0: notificationsToShow = notifications.filter(function (n) {
michael@0: return !n.dismissed && n.anchorElement == anchorElement &&
michael@0: !n.options.neverShow;
michael@0: });
michael@0: }
michael@0:
michael@0: if (notificationsToShow.length > 0) {
michael@0: this._showPanel(notificationsToShow, anchorElement);
michael@0: } else {
michael@0: // Notify observers that we're not showing the popup (useful for testing)
michael@0: this._notify("updateNotShowing");
michael@0:
michael@0: // Close the panel if there are no notifications to show.
michael@0: // When called from PopupNotifications.show() we should never close the
michael@0: // panel, however. It may just be adding a dismissed notification, in
michael@0: // which case we want to continue showing any existing notifications.
michael@0: if (!dismissShowing)
michael@0: this._dismiss();
michael@0:
michael@0: // Only hide the iconBox if we actually have no notifications (as opposed
michael@0: // to not having any showable notifications)
michael@0: if (!haveNotifications) {
michael@0: if (useIconBox)
michael@0: this.iconBox.hidden = true;
michael@0: else if (anchorElement)
michael@0: anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
michael@0: }
michael@0: }
michael@0: },
michael@0:
michael@0: _updateAnchorIcon: function PopupNotifications_updateAnchorIcon(notifications,
michael@0: anchorElement) {
michael@0: anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
michael@0: // Use the anchorID as a class along with the default icon class as a
michael@0: // fallback if anchorID is not defined in CSS. We always use the first
michael@0: // notifications icon, so in the case of multiple notifications we'll
michael@0: // only use the default icon.
michael@0: if (anchorElement.classList.contains("notification-anchor-icon")) {
michael@0: // remove previous icon classes
michael@0: let className = anchorElement.className.replace(/([-\w]+-notification-icon\s?)/g,"")
michael@0: className = "default-notification-icon " + className;
michael@0: if (notifications.length == 1) {
michael@0: className = notifications[0].anchorID + " " + className;
michael@0: }
michael@0: anchorElement.className = className;
michael@0: }
michael@0: },
michael@0:
michael@0: _showIcons: function PopupNotifications_showIcons(aCurrentNotifications) {
michael@0: for (let notification of aCurrentNotifications) {
michael@0: let anchorElm = notification.anchorElement;
michael@0: if (anchorElm) {
michael@0: anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
michael@0: }
michael@0: }
michael@0: },
michael@0:
michael@0: _hideIcons: function PopupNotifications_hideIcons() {
michael@0: let icons = this.iconBox.querySelectorAll(ICON_SELECTOR);
michael@0: for (let icon of icons) {
michael@0: icon.removeAttribute(ICON_ATTRIBUTE_SHOWING);
michael@0: }
michael@0: },
michael@0:
michael@0: /**
michael@0: * Gets and sets notifications for the browser.
michael@0: */
michael@0: _getNotificationsForBrowser: function PopupNotifications_getNotifications(browser) {
michael@0: let notifications = popupNotificationsMap.get(browser);
michael@0: if (!notifications) {
michael@0: // Initialize the WeakMap for the browser so callers can reference/manipulate the array.
michael@0: notifications = [];
michael@0: popupNotificationsMap.set(browser, notifications);
michael@0: }
michael@0: return notifications;
michael@0: },
michael@0: _setNotificationsForBrowser: function PopupNotifications_setNotifications(browser, notifications) {
michael@0: popupNotificationsMap.set(browser, notifications);
michael@0: return notifications;
michael@0: },
michael@0:
michael@0: _isActiveBrowser: function (browser) {
michael@0: // Note: This helper only exists, because in e10s builds,
michael@0: // we can't access the docShell of a browser from chrome.
michael@0: return browser.docShell
michael@0: ? browser.docShell.isActive
michael@0: : (this.window.gBrowser.selectedBrowser == browser);
michael@0: },
michael@0:
michael@0: _onIconBoxCommand: function PopupNotifications_onIconBoxCommand(event) {
michael@0: // Left click, space or enter only
michael@0: let type = event.type;
michael@0: if (type == "click" && event.button != 0)
michael@0: return;
michael@0:
michael@0: if (type == "keypress" &&
michael@0: !(event.charCode == Ci.nsIDOMKeyEvent.DOM_VK_SPACE ||
michael@0: event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN))
michael@0: return;
michael@0:
michael@0: if (this._currentNotifications.length == 0)
michael@0: return;
michael@0:
michael@0: // Get the anchor that is the immediate child of the icon box
michael@0: let anchor = event.target;
michael@0: while (anchor && anchor.parentNode != this.iconBox)
michael@0: anchor = anchor.parentNode;
michael@0:
michael@0: this._reshowNotifications(anchor);
michael@0: },
michael@0:
michael@0: _reshowNotifications: function PopupNotifications_reshowNotifications(anchor, browser) {
michael@0: // Mark notifications anchored to this anchor as un-dismissed
michael@0: let notifications = this._getNotificationsForBrowser(browser || this.tabbrowser.selectedBrowser);
michael@0: notifications.forEach(function (n) {
michael@0: if (n.anchorElement == anchor)
michael@0: n.dismissed = false;
michael@0: });
michael@0:
michael@0: // ...and then show them.
michael@0: this._update(notifications, anchor);
michael@0: },
michael@0:
michael@0: _swapBrowserNotifications: function PopupNotifications_swapBrowserNoficications(ourBrowser, otherBrowser) {
michael@0: // When swaping browser docshells (e.g. dragging tab to new window) we need
michael@0: // to update our notification map.
michael@0:
michael@0: let ourNotifications = this._getNotificationsForBrowser(ourBrowser);
michael@0: let other = otherBrowser.ownerDocument.defaultView.PopupNotifications;
michael@0: if (!other) {
michael@0: if (ourNotifications.length > 0)
michael@0: Cu.reportError("unable to swap notifications: otherBrowser doesn't support notifications");
michael@0: return;
michael@0: }
michael@0: let otherNotifications = other._getNotificationsForBrowser(otherBrowser);
michael@0: if (ourNotifications.length < 1 && otherNotifications.length < 1) {
michael@0: // No notification to swap.
michael@0: return;
michael@0: }
michael@0:
michael@0: otherNotifications = otherNotifications.filter(n => {
michael@0: if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, ourBrowser)) {
michael@0: n.browser = ourBrowser;
michael@0: n.owner = this;
michael@0: return true;
michael@0: }
michael@0: other._fireCallback(n, NOTIFICATION_EVENT_REMOVED);
michael@0: return false;
michael@0: });
michael@0:
michael@0: ourNotifications = ourNotifications.filter(n => {
michael@0: if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, otherBrowser)) {
michael@0: n.browser = otherBrowser;
michael@0: n.owner = other;
michael@0: return true;
michael@0: }
michael@0: this._fireCallback(n, NOTIFICATION_EVENT_REMOVED);
michael@0: return false;
michael@0: });
michael@0:
michael@0: this._setNotificationsForBrowser(otherBrowser, ourNotifications);
michael@0: other._setNotificationsForBrowser(ourBrowser, otherNotifications);
michael@0:
michael@0: if (otherNotifications.length > 0)
michael@0: this._update(otherNotifications, otherNotifications[0].anchorElement);
michael@0: if (ourNotifications.length > 0)
michael@0: other._update(ourNotifications, ourNotifications[0].anchorElement);
michael@0: },
michael@0:
michael@0: _fireCallback: function PopupNotifications_fireCallback(n, event, ...args) {
michael@0: try {
michael@0: if (n.options.eventCallback)
michael@0: return n.options.eventCallback.call(n, event, ...args);
michael@0: } catch (error) {
michael@0: Cu.reportError(error);
michael@0: }
michael@0: return undefined;
michael@0: },
michael@0:
michael@0: _onPopupHidden: function PopupNotifications_onPopupHidden(event) {
michael@0: if (event.target != this.panel || this._ignoreDismissal)
michael@0: return;
michael@0:
michael@0: let browser = this.panel.firstChild &&
michael@0: this.panel.firstChild.notification.browser;
michael@0: if (!browser)
michael@0: return;
michael@0:
michael@0: let notifications = this._getNotificationsForBrowser(browser);
michael@0: // Mark notifications as dismissed and call dismissal callbacks
michael@0: Array.forEach(this.panel.childNodes, function (nEl) {
michael@0: let notificationObj = nEl.notification;
michael@0: // Never call a dismissal handler on a notification that's been removed.
michael@0: if (notifications.indexOf(notificationObj) == -1)
michael@0: return;
michael@0:
michael@0: // Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED
michael@0: // if the notification is removed.
michael@0: if (notificationObj.options.removeOnDismissal)
michael@0: this._remove(notificationObj);
michael@0: else {
michael@0: notificationObj.dismissed = true;
michael@0: this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED);
michael@0: }
michael@0: }, this);
michael@0:
michael@0: this._clearPanel();
michael@0:
michael@0: this._update();
michael@0: },
michael@0:
michael@0: _onButtonCommand: function PopupNotifications_onButtonCommand(event) {
michael@0: // Need to find the associated notification object, which is a bit tricky
michael@0: // since it isn't associated with the button directly - this is kind of
michael@0: // gross and very dependent on the structure of the popupnotification
michael@0: // binding's content.
michael@0: let target = event.originalTarget;
michael@0: let notificationEl;
michael@0: let parent = target;
michael@0: while (parent && (parent = target.ownerDocument.getBindingParent(parent)))
michael@0: notificationEl = parent;
michael@0:
michael@0: if (!notificationEl)
michael@0: throw "PopupNotifications_onButtonCommand: couldn't find notification element";
michael@0:
michael@0: if (!notificationEl.notification)
michael@0: throw "PopupNotifications_onButtonCommand: couldn't find notification";
michael@0:
michael@0: let notification = notificationEl.notification;
michael@0: let timeSinceShown = this.window.performance.now() - notification.timeShown;
michael@0:
michael@0: // Only report the first time mainAction is triggered and remember that this occurred.
michael@0: if (!notification.timeMainActionFirstTriggered) {
michael@0: notification.timeMainActionFirstTriggered = timeSinceShown;
michael@0: Services.telemetry.getHistogramById("POPUP_NOTIFICATION_MAINACTION_TRIGGERED_MS").
michael@0: add(timeSinceShown);
michael@0: }
michael@0:
michael@0: if (timeSinceShown < this.buttonDelay) {
michael@0: Services.console.logStringMessage("PopupNotifications_onButtonCommand: " +
michael@0: "Button click happened before the security delay: " +
michael@0: timeSinceShown + "ms");
michael@0: return;
michael@0: }
michael@0: try {
michael@0: notification.mainAction.callback.call();
michael@0: } catch(error) {
michael@0: Cu.reportError(error);
michael@0: }
michael@0:
michael@0: if (notification.mainAction.dismiss) {
michael@0: this._dismiss();
michael@0: return;
michael@0: }
michael@0:
michael@0: this._remove(notification);
michael@0: this._update();
michael@0: },
michael@0:
michael@0: _onMenuCommand: function PopupNotifications_onMenuCommand(event) {
michael@0: let target = event.originalTarget;
michael@0: if (!target.action || !target.notification)
michael@0: throw "menucommand target has no associated action/notification";
michael@0:
michael@0: event.stopPropagation();
michael@0: try {
michael@0: target.action.callback.call();
michael@0: } catch(error) {
michael@0: Cu.reportError(error);
michael@0: }
michael@0:
michael@0: if (target.action.dismiss) {
michael@0: this._dismiss();
michael@0: return;
michael@0: }
michael@0:
michael@0: this._remove(target.notification);
michael@0: this._update();
michael@0: },
michael@0:
michael@0: _notify: function PopupNotifications_notify(topic) {
michael@0: Services.obs.notifyObservers(null, "PopupNotifications-" + topic, "");
michael@0: },
michael@0: };