Bug 754472 - Implement multiple plugin click-to-play UI. r=jaws r=margaret r=dietrich
[gecko.git] / toolkit / content / PopupNotifications.jsm
blob258b95d6c4dd1ab08eb2df83aef700fa6d09dbee
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();
20 /**
21  * Notification object describes a single popup notification.
22  *
23  * @see PopupNotifications.show()
24  */
25 function Notification(id, message, anchorID, mainAction, secondaryActions,
26                       browser, owner, options) {
27   this.id = id;
28   this.message = message;
29   this.anchorID = anchorID;
30   this.mainAction = mainAction;
31   this.secondaryActions = secondaryActions || [];
32   this.browser = browser;
33   this.owner = owner;
34   this.options = options || {};
37 Notification.prototype = {
39   id: null,
40   message: null,
41   anchorID: null,
42   mainAction: null,
43   secondaryActions: null,
44   browser: null,
45   owner: null,
46   options: null,
48   /**
49    * Removes the notification and updates the popup accordingly if needed.
50    */
51   remove: function Notification_remove() {
52     this.owner.remove(this);
53   },
55   get anchorElement() {
56     let iconBox = this.owner.iconBox;
57     if (!iconBox)
58       return null;
60     let anchorElement = null;
61     if (this.anchorID)
62       anchorElement = iconBox.querySelector("#"+this.anchorID);
64     // Use a default anchor icon if it's available
65     if (!anchorElement)
66       anchorElement = iconBox.querySelector("#default-notification-icon") ||
67                       iconBox;
69     return anchorElement;
70   }
73 /**
74  * The PopupNotifications object manages popup notifications for a given browser
75  * window.
76  * @param tabbrowser
77  *        window's <xul:tabbrowser/>. Used to observe tab switching events and
78  *        for determining the active browser element.
79  * @param panel
80  *        The <xul:panel/> element to use for notifications. The panel is
81  *        populated with <popupnotification> children and displayed it as
82  *        needed.
83  * @param iconBox
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.
89  */
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;
99   this.panel = panel;
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 = {
111   window: null,
112   panel: null,
113   tabbrowser: null,
115   _iconBox: null,
116   set iconBox(iconBox) {
117     // Remove the listeners on the old iconBox, if needed
118     if (this._iconBox) {
119       this._iconBox.removeEventListener("click", this, false);
120       this._iconBox.removeEventListener("keypress", this, false);
121     }
122     this._iconBox = iconBox;
123     if (iconBox) {
124       iconBox.addEventListener("click", this, false);
125       iconBox.addEventListener("keypress", this, false);
126     }
127   },
128   get iconBox() {
129     return this._iconBox;
130   },
132   /**
133    * Retrieve a Notification object associated with the browser/ID pair.
134    * @param id
135    *        The Notification ID to search for.
136    * @param browser
137    *        The browser whose notifications should be searched. If null, the
138    *        currently selected browser's notifications will be searched.
139    *
140    * @returns the corresponding Notification object, or null if no such
141    *          notification exists.
142    */
143   getNotification: function PopupNotifications_getNotification(id, browser) {
144     let n = null;
145     let notifications = this._getNotificationsForBrowser(browser || this.tabbrowser.selectedBrowser);
146     notifications.some(function(x) x.id == id && (n = x));
147     return n;
148   },
150   /**
151    * Adds a new popup notification.
152    * @param browser
153    *        The <xul:browser> element associated with the notification. Must not
154    *        be null.
155    * @param id
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
159    *        will be replaced.
160    * @param message
161    *        The text to be displayed in the notification.
162    * @param anchorID
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.
166    * @param mainAction
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
172    *            pressed.
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
179    *        dropdown menu.
180    * @param options
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.
193    *        eventCallback:
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
199    *                                  tabs)
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
204    *                              and re-shown)
205    *        neverShow:   Indicate that no popup should be shown for this
206    *                     notification. Useful for just showing the anchor icon.
207    *        removeOnDismissal:
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
211    *                     interaction).
212    *        popupIconURL:
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.
217    */
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;
222     }
224     if (!browser)
225       throw "PopupNotifications_show: invalid browser";
226     if (!id)
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) {
248       // show panel now
249       this._update(notification.anchorElement);
250     } else {
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");
256     }
258     return notification;
259   },
261   /**
262    * Returns true if the notification popup is currently being displayed.
263    */
264   get isPanelOpen() {
265     let panelState = this.panel.state;
267     return panelState == "showing" || panelState == "open";
268   },
270   /**
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.
273    */
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 &&
279           this.isPanelOpen) {
280         if ("persistence" in notification.options &&
281           notification.options.persistence)
282           notification.options.persistence--;
283         return true;
284       }
286       // The persistence option allows a notification to persist across multiple
287       // page loads
288       if ("persistence" in notification.options &&
289           notification.options.persistence) {
290         notification.options.persistence--;
291         return true;
292       }
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) {
297         return true;
298       }
300       this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED);
301       return false;
302     }, this);
304     this._update();
305   },
307   /**
308    * Removes a Notification.
309    * @param notification
310    *        The Notification object to remove.
311    */
312   remove: function PopupNotifications_remove(notification) {
313     let isCurrent = this._currentNotifications.indexOf(notification) != -1;
314     this._remove(notification);
316     // update the panel, if needed
317     if (isCurrent)
318       this._update();
319   },
321   handleEvent: function (aEvent) {
322     switch (aEvent.type) {
323       case "popuphidden":
324         this._onPopupHidden(aEvent);
325         break;
326       case "activate":
327       case "TabSelect":
328         let self = this;
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 () {
332           self._update();
333         }, 0);
334         break;
335       case "click":
336       case "keypress":
337         this._onIconBoxCommand(aEvent);
338         break;
339     }
340   },
342 ////////////////////////////////////////////////////////////////////////////////
343 // Utility methods
344 ////////////////////////////////////////////////////////////////////////////////
346   _ignoreDismissal: null,
347   _currentAnchorElement: null,
349   /**
350    * Gets and sets notifications for the currently selected browser.
351    */
352   get _currentNotifications() {
353     return this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser);
354   },
355   set _currentNotifications(a) {
356     return this._setNotificationsForBrowser(this.tabbrowser.selectedBrowser, a);
357   },
359   _remove: function PopupNotifications_removeHelper(notification) {
360     // This notification may already be removed, in which case let's just fail
361     // silently.
362     let notifications = this._getNotificationsForBrowser(notification.browser);
363     if (!notifications)
364       return;
366     var index = notifications.indexOf(notification);
367     if (index == -1)
368       return;
370     notification.anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
372     // remove the notification
373     notifications.splice(index, 1);
374     this._fireCallback(notification, NOTIFICATION_EVENT_REMOVED);
375   },
377   /**
378    * Dismisses the notification without removing it.
379    */
380   _dismiss: function PopupNotifications_dismiss() {
381     let browser = this.panel.firstChild &&
382                   this.panel.firstChild.notification.browser;
383     this.panel.hidePopup();
384     if (browser)
385       browser.focus();
386   },
388   /**
389    * Hides the notification popup.
390    */
391   _hidePanel: function PopupNotifications_hide() {
392     this._ignoreDismissal = true;
393     this.panel.hidePopup();
394     this._ignoreDismissal = false;
395   },
397   /**
398    *
399    */
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
411       // in the document.
412       popupnotification.setAttribute("id", n.id + "-notification");
413       popupnotification.setAttribute("popupid", n.id);
414       popupnotification.setAttribute("closebuttoncommand", "PopupNotifications._dismiss();");
415       if (n.mainAction) {
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();");
421       }
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;
432           item.action = a;
434           popupnotification.appendChild(item);
435         }, this);
437         if (n.secondaryActions.length) {
438           let closeItemSeparator = doc.createElementNS(XUL_NS, "menuseparator");
439           popupnotification.appendChild(closeItemSeparator);
440         }
441       }
443       this.panel.appendChild(popupnotification);
444     }, this);
445   },
447   _showPanel: function PopupNotifications_showPanel(notificationsToShow, anchorElement) {
448     this.panel.hidden = false;
450     this._refreshPanel(notificationsToShow);
452     if (this.isPanelOpen && this._currentAnchorElement == anchorElement)
453       return;
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.)
458     this._hidePanel();
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
462     // the current tab.
463     let selectedTab = this.tabbrowser.selectedTab;
464     if (anchorElement) {
465       let bo = anchorElement.boxObject;
466       if (bo.height == 0 && bo.width == 0)
467         anchorElement = selectedTab; // hidden
468     } else {
469       anchorElement = selectedTab; // null
470     }
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);
480     }, this);
481   },
483   /**
484    * Updates the notification state in response to window activation or tab
485    * selection changes.
486    */
487   _update: function PopupNotifications_update(anchor) {
488     if (this.iconBox) {
489       // hide icons of the previous tab.
490       this._hideIcons();
491     }
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;
502       if (this.iconBox) {
503         this._showIcons(currentNotifications);
504         this.iconBox.hidden = false;
505       }
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;
511       });
512     }
514     if (notificationsToShow.length > 0) {
515       this._showPanel(notificationsToShow, anchorElement);
516     } else {
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.
522       this._dismiss();
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;
528     }
529   },
531   _showIcons: function PopupNotifications_showIcons(aCurrentNotifications) {
532     for (let notification of aCurrentNotifications) {
533       let anchorElm = notification.anchorElement;
534       if (anchorElm) {
535         anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
536       }
537     }
538   },
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);
544     }
545   },
547   /**
548    * Gets and sets notifications for the browser.
549    */
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.
554       notifications = [];
555       popupNotificationsMap.set(browser, notifications);
556     }
557     return notifications;
558   },
559   _setNotificationsForBrowser: function PopupNotifications_setNotifications(browser, notifications) {
560     popupNotificationsMap.set(browser, notifications);
561     return notifications;
562   },
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)
568       return;
570     if (type == "keypress" &&
571         !(event.charCode == Ci.nsIDOMKeyEvent.DOM_VK_SPACE ||
572           event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_RETURN))
573       return;
575     if (this._currentNotifications.length == 0)
576       return;
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)
586         n.dismissed = false;
587     });
589     // ...and then show them.
590     this._update(anchor);
591   },
593   _fireCallback: function PopupNotifications_fireCallback(n, event) {
594     if (n.options.eventCallback)
595       n.options.eventCallback.call(n, event);
596   },
598   _onPopupHidden: function PopupNotifications_onPopupHidden(event) {
599     if (event.target != this.panel || this._ignoreDismissal)
600       return;
602     let browser = this.panel.firstChild &&
603                   this.panel.firstChild.notification.browser;
604     if (!browser)
605       return;
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)
613         return;
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);
619       else {
620         notificationObj.dismissed = true;
621         this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED);
622       }
623     }, this);
625     while (this.panel.lastChild)
626       this.panel.removeChild(this.panel.lastChild);
628     this._update();
629   },
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;
637     let notificationEl;
638     let parent = target;
639     while (parent && (parent = target.ownerDocument.getBindingParent(parent)))
640       notificationEl = parent;
642     if (!notificationEl)
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);
652     this._update();
653   },
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);
664     this._update();
665   },
667   _notify: function PopupNotifications_notify(topic) {
668     Services.obs.notifyObservers(null, "PopupNotifications-" + topic, "");
669   },