1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 var EXPORTED_SYMBOLS = ["PopupNotifications"];
7 var Cc = Components.classes, Ci = Components.interfaces;
9 Components.utils.import("resource://gre/modules/Services.jsm");
11 const NOTIFICATION_EVENT_DISMISSED = "dismissed";
12 const NOTIFICATION_EVENT_REMOVED = "removed";
13 const NOTIFICATION_EVENT_SHOWN = "shown";
15 const ICON_SELECTOR = ".notification-anchor-icon";
16 const ICON_ATTRIBUTE_SHOWING = "showing";
18 let popupNotificationsMap = new WeakMap();
21 * Notification object describes a single popup notification.
23 * @see PopupNotifications.show()
25 function Notification(id, message, anchorID, mainAction, secondaryActions,
26 browser, owner, options) {
28 this.message = message;
29 this.anchorID = anchorID;
30 this.mainAction = mainAction;
31 this.secondaryActions = secondaryActions || [];
32 this.browser = browser;
34 this.options = options || {};
37 Notification.prototype = {
43 secondaryActions: null,
49 * Removes the notification and updates the popup accordingly if needed.
51 remove: function Notification_remove() {
52 this.owner.remove(this);
56 let iconBox = this.owner.iconBox;
60 let anchorElement = null;
62 anchorElement = iconBox.querySelector("#"+this.anchorID);
64 // Use a default anchor icon if it's available
66 anchorElement = iconBox.querySelector("#default-notification-icon") ||
74 * The PopupNotifications object manages popup notifications for a given browser
77 * window's <xul:tabbrowser/>. Used to observe tab switching events and
78 * for determining the active browser element.
80 * The <xul:panel/> element to use for notifications. The panel is
81 * populated with <popupnotification> children and displayed it as
84 * Reference to a container element that should be hidden or
85 * unhidden when notifications are hidden or shown. It should be the
86 * parent of anchor elements whose IDs are passed to show().
87 * It is used as a fallback popup anchor if notifications specify
88 * invalid or non-existent anchor IDs.
90 function PopupNotifications(tabbrowser, panel, iconBox) {
91 if (!(tabbrowser instanceof Ci.nsIDOMXULElement))
92 throw "Invalid tabbrowser";
93 if (iconBox && !(iconBox instanceof Ci.nsIDOMXULElement))
94 throw "Invalid iconBox";
95 if (!(panel instanceof Ci.nsIDOMXULElement))
96 throw "Invalid panel";
98 this.window = tabbrowser.ownerDocument.defaultView;
100 this.tabbrowser = tabbrowser;
101 this.iconBox = iconBox;
103 this.panel.addEventListener("popuphidden", this, true);
105 this.window.addEventListener("activate", this, true);
106 this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true);
109 PopupNotifications.prototype = {
116 set iconBox(iconBox) {
117 // Remove the listeners on the old iconBox, if needed
119 this._iconBox.removeEventListener("click", this, false);
120 this._iconBox.removeEventListener("keypress", this, false);
122 this._iconBox = iconBox;
124 iconBox.addEventListener("click", this, false);
125 iconBox.addEventListener("keypress", this, false);
129 return this._iconBox;
133 * Retrieve a Notification object associated with the browser/ID pair.
135 * The Notification ID to search for.
137 * The browser whose notifications should be searched. If null, the
138 * currently selected browser's notifications will be searched.
140 * @returns the corresponding Notification object, or null if no such
141 * notification exists.
143 getNotification: function PopupNotifications_getNotification(id, browser) {
145 let notifications = this._getNotificationsForBrowser(browser || this.tabbrowser.selectedBrowser);
146 notifications.some(function(x) x.id == id && (n = x));
151 * Adds a new popup notification.
153 * The <xul:browser> element associated with the notification. Must not
156 * A unique ID that identifies the type of notification (e.g.
157 * "geolocation"). Only one notification with a given ID can be visible
158 * at a time. If a notification already exists with the given ID, it
161 * The text to be displayed in the notification.
163 * The ID of the element that should be used as this notification
164 * popup's anchor. May be null, in which case the notification will be
165 * anchored to the iconBox.
167 * A JavaScript object literal describing the notification button's
168 * action. If present, it must have the following properties:
169 * - label (string): the button's label.
170 * - accessKey (string): the button's accessKey.
171 * - callback (function): a callback to be invoked when the button is
173 * If null, the notification will not have a button, and
174 * secondaryActions will be ignored.
175 * @param secondaryActions
176 * An optional JavaScript array describing the notification's alternate
177 * actions. The array should contain objects with the same properties
178 * as mainAction. These are used to populate the notification button's
181 * An options JavaScript object holding additional properties for the
182 * notification. The following properties are currently supported:
183 * persistence: An integer. The notification will not automatically
184 * dismiss for this many page loads.
185 * timeout: A time in milliseconds. The notification will not
186 * automatically dismiss before this time.
187 * persistWhileVisible:
188 * A boolean. If true, a visible notification will always
189 * persist across location changes.
190 * dismissed: Whether the notification should be added as a dismissed
191 * notification. Dismissed notifications can be activated
192 * by clicking on their anchorElement.
194 * Callback to be invoked when the notification changes
195 * state. The callback's first argument is a string
196 * identifying the state change:
197 * "dismissed": notification has been dismissed by the
198 * user (e.g. by clicking away or switching
200 * "removed": notification has been removed (due to
201 * location change or user action)
202 * "shown": notification has been shown (this can be fired
203 * multiple times as notifications are dismissed
205 * neverShow: Indicate that no popup should be shown for this
206 * notification. Useful for just showing the anchor icon.
208 * Notifications with this parameter set to true will be
209 * removed when they would have otherwise been dismissed
210 * (i.e. any time the popup is closed due to user
213 * A string. URL of the image to be displayed in the popup.
214 * Normally specified in CSS using list-style-image and the
215 * .popup-notification-icon[popupid=...] selector.
216 * @returns the Notification object corresponding to the added notification.
218 show: function PopupNotifications_show(browser, id, message, anchorID,
219 mainAction, secondaryActions, options) {
220 function isInvalidAction(a) {
221 return !a || !(typeof(a.callback) == "function") || !a.label || !a.accessKey;
225 throw "PopupNotifications_show: invalid browser";
227 throw "PopupNotifications_show: invalid ID";
228 if (mainAction && isInvalidAction(mainAction))
229 throw "PopupNotifications_show: invalid mainAction";
230 if (secondaryActions && secondaryActions.some(isInvalidAction))
231 throw "PopupNotifications_show: invalid secondaryActions";
233 let notification = new Notification(id, message, anchorID, mainAction,
234 secondaryActions, browser, this, options);
236 if (options && options.dismissed)
237 notification.dismissed = true;
239 let existingNotification = this.getNotification(id, browser);
240 if (existingNotification)
241 this._remove(existingNotification);
243 let notifications = this._getNotificationsForBrowser(browser);
244 notifications.push(notification);
246 let fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager);
247 if (browser == this.tabbrowser.selectedBrowser && fm.activeWindow == this.window) {
249 this._update(notification.anchorElement);
251 // Otherwise, update() will display the notification the next time the
252 // relevant tab/window is selected.
254 // Notify observers that we're not showing the popup (useful for testing)
255 this._notify("backgroundShow");
262 * Returns true if the notification popup is currently being displayed.
265 let panelState = this.panel.state;
267 return panelState == "showing" || panelState == "open";
271 * Called by the consumer to indicate that the current browser's location has
272 * changed, so that we can update the active notifications accordingly.
274 locationChange: function PopupNotifications_locationChange() {
275 this._currentNotifications = this._currentNotifications.filter(function(notification) {
276 // The persistWhileVisible option allows an open notification to persist
277 // across location changes
278 if (notification.options.persistWhileVisible &&
280 if ("persistence" in notification.options &&
281 notification.options.persistence)
282 notification.options.persistence--;
286 // The persistence option allows a notification to persist across multiple
288 if ("persistence" in notification.options &&
289 notification.options.persistence) {
290 notification.options.persistence--;
294 // The timeout option allows a notification to persist until a certain time
295 if ("timeout" in notification.options &&
296 Date.now() <= notification.options.timeout) {
300 this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED);
308 * Removes a Notification.
309 * @param notification
310 * The Notification object to remove.
312 remove: function PopupNotifications_remove(notification) {
313 let isCurrent = this._currentNotifications.indexOf(notification) != -1;
314 this._remove(notification);
316 // update the panel, if needed
321 handleEvent: function (aEvent) {
322 switch (aEvent.type) {
324 this._onPopupHidden(aEvent);
329 // setTimeout(..., 0) needed, otherwise openPopup from "activate" event
330 // handler results in the popup being hidden again for some reason...
331 this.window.setTimeout(function () {
337 this._onIconBoxCommand(aEvent);
342 ////////////////////////////////////////////////////////////////////////////////
344 ////////////////////////////////////////////////////////////////////////////////
346 _ignoreDismissal: null,
347 _currentAnchorElement: null,
350 * Gets and sets notifications for the currently selected browser.
352 get _currentNotifications() {
353 return this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser);
355 set _currentNotifications(a) {
356 return this._setNotificationsForBrowser(this.tabbrowser.selectedBrowser, a);
359 _remove: function PopupNotifications_removeHelper(notification) {
360 // This notification may already be removed, in which case let's just fail
362 let notifications = this._getNotificationsForBrowser(notification.browser);
366 var index = notifications.indexOf(notification);
370 notification.anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
372 // remove the notification
373 notifications.splice(index, 1);
374 this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED);
378 * Dismisses the notification without removing it.
380 _dismiss: function PopupNotifications_dismiss() {
381 let browser = this.panel.firstChild &&
382 this.panel.firstChild.notification.browser;
383 this.panel.hidePopup();
389 * Hides the notification popup.
391 _hidePanel: function PopupNotifications_hide() {
392 this._ignoreDismissal = true;
393 this.panel.hidePopup();
394 this._ignoreDismissal = false;
400 _refreshPanel: function PopupNotifications_refreshPanel(notificationsToShow) {
401 while (this.panel.lastChild)
402 this.panel.removeChild(this.panel.lastChild);
404 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
406 notificationsToShow.forEach(function (n) {
407 let doc = this.window.document;
408 let popupnotification = doc.createElementNS(XUL_NS, "popupnotification");
409 popupnotification.setAttribute("label", n.message);
410 // Append "-notification" to the ID to try to avoid ID conflicts with other stuff
412 popupnotification.setAttribute("id", n.id + "-notification");
413 popupnotification.setAttribute("popupid", n.id);
414 popupnotification.setAttribute("closebuttoncommand", "PopupNotifications._dismiss();");
416 popupnotification.setAttribute("buttonlabel", n.mainAction.label);
417 popupnotification.setAttribute("buttonaccesskey", n.mainAction.accessKey);
418 popupnotification.setAttribute("buttoncommand", "PopupNotifications._onButtonCommand(event);");
419 popupnotification.setAttribute("menucommand", "PopupNotifications._onMenuCommand(event);");
420 popupnotification.setAttribute("closeitemcommand", "PopupNotifications._dismiss();event.stopPropagation();");
422 if (n.options.popupIconURL)
423 popupnotification.setAttribute("icon", n.options.popupIconURL);
424 popupnotification.notification = n;
426 if (n.secondaryActions) {
427 n.secondaryActions.forEach(function (a) {
428 let item = doc.createElementNS(XUL_NS, "menuitem");
429 item.setAttribute("label", a.label);
430 item.setAttribute("accesskey", a.accessKey);
431 item.notification = n;
434 popupnotification.appendChild(item);
437 if (n.secondaryActions.length) {
438 let closeItemSeparator = doc.createElementNS(XUL_NS, "menuseparator");
439 popupnotification.appendChild(closeItemSeparator);
443 this.panel.appendChild(popupnotification);
447 _showPanel: function PopupNotifications_showPanel(notificationsToShow, anchorElement) {
448 this.panel.hidden = false;
450 this._refreshPanel(notificationsToShow);
452 if (this.isPanelOpen && this._currentAnchorElement == anchorElement)
455 // If the panel is already open but we're changing anchors, we need to hide
456 // it first. Otherwise it can appear in the wrong spot. (_hidePanel is
457 // safe to call even if the panel is already hidden.)
460 // If the anchor element is hidden or null, use the tab as the anchor. We
461 // only ever show notifications for the current browser, so we can just use
463 let selectedTab = this.tabbrowser.selectedTab;
465 let bo = anchorElement.boxObject;
466 if (bo.height == 0 && bo.width == 0)
467 anchorElement = selectedTab; // hidden
469 anchorElement = selectedTab; // null
472 this._currentAnchorElement = anchorElement;
474 // On OS X and Linux we need a different panel arrow color for
475 // click-to-play plugins, so copy the popupid and use css.
476 this.panel.setAttribute("popupid", this.panel.firstChild.getAttribute("popupid"));
477 this.panel.openPopup(anchorElement, "bottomcenter topleft");
478 notificationsToShow.forEach(function (n) {
479 this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
484 * Updates the notification state in response to window activation or tab
487 _update: function PopupNotifications_update(anchor) {
489 // hide icons of the previous tab.
493 let anchorElement, notificationsToShow = [];
494 let currentNotifications = this._currentNotifications;
495 let haveNotifications = currentNotifications.length > 0;
496 if (haveNotifications) {
497 // Only show the notifications that have the passed-in anchor (or the
498 // first notification's anchor, if none was passed in). Other
499 // notifications will be shown once these are dismissed.
500 anchorElement = anchor || currentNotifications[0].anchorElement;
503 this._showIcons(currentNotifications);
504 this.iconBox.hidden = false;
507 // Also filter out notifications that have been dismissed.
508 notificationsToShow = currentNotifications.filter(function (n) {
509 return !n.dismissed && n.anchorElement == anchorElement &&
510 !n.options.neverShow;
514 if (notificationsToShow.length > 0) {
515 this._showPanel(notificationsToShow, anchorElement);
517 // Notify observers that we're not showing the popup (useful for testing)
518 this._notify("updateNotShowing");
520 // Dismiss the panel if needed. _onPopupHidden will ensure we never call
521 // a dismissal handler on a notification that's been removed.
524 // Only hide the iconBox if we actually have no notifications (as opposed
525 // to not having any showable notifications)
526 if (this.iconBox && !haveNotifications)
527 this.iconBox.hidden = true;
531 _showIcons: function PopupNotifications_showIcons(aCurrentNotifications) {
532 for (let notification of aCurrentNotifications) {
533 let anchorElm = notification.anchorElement;
535 anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
540 _hideIcons: function PopupNotifications_hideIcons() {
541 let icons = this.iconBox.querySelectorAll(ICON_SELECTOR);
542 for (let icon of icons) {
543 icon.removeAttribute(ICON_ATTRIBUTE_SHOWING);
548 * Gets and sets notifications for the browser.
550 _getNotificationsForBrowser: function PopupNotifications_getNotifications(browser) {
551 let notifications = popupNotificationsMap.get(browser);
552 if (!notifications) {
553 // Initialize the WeakMap for the browser so callers can reference/manipulate the array.
555 popupNotificationsMap.set(browser, notifications);
557 return notifications;
559 _setNotificationsForBrowser: function PopupNotifications_setNotifications(browser, notifications) {
560 popupNotificationsMap.set(browser, notifications);
561 return notifications;
564 _onIconBoxCommand: function PopupNotifications_onIconBoxCommand(event) {
565 // Left click, space or enter only
566 let type = event.type;
567 if (type == "click" && event.button != 0)
570 if (type == "keypress" &&
571 !(event.charCode == Ci.nsIDOMKeyEvent.DOM_VK_SPACE ||
572 event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN))
575 if (this._currentNotifications.length == 0)
578 // Get the anchor that is the immediate child of the icon box
579 let anchor = event.target;
580 while (anchor && anchor.parentNode != this.iconBox)
581 anchor = anchor.parentNode;
583 // Mark notifications anchored to this anchor as un-dismissed
584 this._currentNotifications.forEach(function (n) {
585 if (n.anchorElement == anchor)
589 // ...and then show them.
590 this._update(anchor);
593 _fireCallback: function PopupNotifications_fireCallback(n, event) {
594 if (n.options.eventCallback)
595 n.options.eventCallback.call(n, event);
598 _onPopupHidden: function PopupNotifications_onPopupHidden(event) {
599 if (event.target != this.panel || this._ignoreDismissal)
602 let browser = this.panel.firstChild &&
603 this.panel.firstChild.notification.browser;
607 let notifications = this._getNotificationsForBrowser(browser);
608 // Mark notifications as dismissed and call dismissal callbacks
609 Array.forEach(this.panel.childNodes, function (nEl) {
610 let notificationObj = nEl.notification;
611 // Never call a dismissal handler on a notification that's been removed.
612 if (notifications.indexOf(notificationObj) == -1)
615 // Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED
616 // if the notification is removed.
617 if (notificationObj.options.removeOnDismissal)
618 this._remove(notificationObj);
620 notificationObj.dismissed = true;
621 this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED);
625 while (this.panel.lastChild)
626 this.panel.removeChild(this.panel.lastChild);
631 _onButtonCommand: function PopupNotifications_onButtonCommand(event) {
632 // Need to find the associated notification object, which is a bit tricky
633 // since it isn't associated with the button directly - this is kind of
634 // gross and very dependent on the structure of the popupnotification
635 // binding's content.
636 let target = event.originalTarget;
639 while (parent && (parent = target.ownerDocument.getBindingParent(parent)))
640 notificationEl = parent;
643 throw "PopupNotifications_onButtonCommand: couldn't find notification element";
645 if (!notificationEl.notification)
646 throw "PopupNotifications_onButtonCommand: couldn't find notification";
648 let notification = notificationEl.notification;
649 notification.mainAction.callback.call();
651 this._remove(notification);
655 _onMenuCommand: function PopupNotifications_onMenuCommand(event) {
656 let target = event.originalTarget;
657 if (!target.action || !target.notification)
658 throw "menucommand target has no associated action/notification";
660 event.stopPropagation();
661 target.action.callback.call();
663 this._remove(target.notification);
667 _notify: function PopupNotifications_notify(topic) {
668 Services.obs.notifyObservers(null, "PopupNotifications-" + topic, "");