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: };