Bug 1611178 [wpt PR 21377] - Update wpt metadata, a=testonly
[gecko.git] / toolkit / modules / PopupNotifications.jsm
blob30e443d918530585348cdcd927e954cf8dfb619b
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 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
8 const { PrivateBrowsingUtils } = ChromeUtils.import(
9   "resource://gre/modules/PrivateBrowsingUtils.jsm"
11 const { PromiseUtils } = ChromeUtils.import(
12   "resource://gre/modules/PromiseUtils.jsm"
15 const NOTIFICATION_EVENT_DISMISSED = "dismissed";
16 const NOTIFICATION_EVENT_REMOVED = "removed";
17 const NOTIFICATION_EVENT_SHOWING = "showing";
18 const NOTIFICATION_EVENT_SHOWN = "shown";
19 const NOTIFICATION_EVENT_SWAPPING = "swapping";
21 const ICON_SELECTOR = ".notification-anchor-icon";
22 const ICON_ATTRIBUTE_SHOWING = "showing";
23 const ICON_ANCHOR_ATTRIBUTE = "popupnotificationanchor";
25 const PREF_SECURITY_DELAY = "security.notification_enable_delay";
27 // Enumerated values for the POPUP_NOTIFICATION_STATS telemetry histogram.
28 const TELEMETRY_STAT_OFFERED = 0;
29 const TELEMETRY_STAT_ACTION_1 = 1;
30 const TELEMETRY_STAT_ACTION_2 = 2;
31 // const TELEMETRY_STAT_ACTION_3 = 3;
32 const TELEMETRY_STAT_ACTION_LAST = 4;
33 // const TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE = 5;
34 const TELEMETRY_STAT_REMOVAL_LEAVE_PAGE = 6;
35 // const TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON = 7;
36 const TELEMETRY_STAT_OPEN_SUBMENU = 10;
37 const TELEMETRY_STAT_LEARN_MORE = 11;
39 const TELEMETRY_STAT_REOPENED_OFFSET = 20;
41 var popupNotificationsMap = new WeakMap();
42 var gNotificationParents = new WeakMap();
44 function getAnchorFromBrowser(aBrowser, aAnchorID) {
45   let attrPrefix = aAnchorID ? aAnchorID.replace("notification-icon", "") : "";
46   let anchor =
47     aBrowser.getAttribute(attrPrefix + ICON_ANCHOR_ATTRIBUTE) ||
48     aBrowser[attrPrefix + ICON_ANCHOR_ATTRIBUTE] ||
49     aBrowser.getAttribute(ICON_ANCHOR_ATTRIBUTE) ||
50     aBrowser[ICON_ANCHOR_ATTRIBUTE];
51   if (anchor) {
52     if (ChromeUtils.getClassName(anchor) == "XULElement") {
53       return anchor;
54     }
55     return aBrowser.ownerDocument.getElementById(anchor);
56   }
57   return null;
60 /**
61  * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>.
62  */
63 function getNotificationFromElement(aElement) {
64   return aElement.closest("popupnotification");
67 /**
68  * Notification object describes a single popup notification.
69  *
70  * @see PopupNotifications.show()
71  */
72 function Notification(
73   id,
74   message,
75   anchorID,
76   mainAction,
77   secondaryActions,
78   browser,
79   owner,
80   options
81 ) {
82   this.id = id;
83   this.message = message;
84   this.anchorID = anchorID;
85   this.mainAction = mainAction;
86   this.secondaryActions = secondaryActions || [];
87   this.browser = browser;
88   this.owner = owner;
89   this.options = options || {};
91   this._dismissed = false;
92   // Will become a boolean when manually toggled by the user.
93   this._checkboxChecked = null;
94   this.wasDismissed = false;
95   this.recordedTelemetryStats = new Set();
96   this.isPrivate = PrivateBrowsingUtils.isWindowPrivate(
97     this.browser.ownerGlobal
98   );
99   this.timeCreated = this.owner.window.performance.now();
102 Notification.prototype = {
103   id: null,
104   message: null,
105   anchorID: null,
106   mainAction: null,
107   secondaryActions: null,
108   browser: null,
109   owner: null,
110   options: null,
111   timeShown: null,
113   /**
114    * Indicates whether the notification is currently dismissed.
115    */
116   set dismissed(value) {
117     this._dismissed = value;
118     if (value) {
119       // Keep the dismissal into account when recording telemetry.
120       this.wasDismissed = true;
121     }
122   },
123   get dismissed() {
124     return this._dismissed;
125   },
127   /**
128    * Removes the notification and updates the popup accordingly if needed.
129    */
130   remove: function Notification_remove() {
131     this.owner.remove(this);
132   },
134   get anchorElement() {
135     let iconBox = this.owner.iconBox;
137     let anchorElement = getAnchorFromBrowser(this.browser, this.anchorID);
138     if (!iconBox) {
139       return anchorElement;
140     }
142     if (!anchorElement && this.anchorID) {
143       anchorElement = iconBox.querySelector("#" + this.anchorID);
144     }
146     // Use a default anchor icon if it's available
147     if (!anchorElement) {
148       anchorElement =
149         iconBox.querySelector("#default-notification-icon") || iconBox;
150     }
152     return anchorElement;
153   },
155   reshow() {
156     this.owner._reshowNotifications(this.anchorElement, this.browser);
157   },
159   /**
160    * Adds a value to the specified histogram, that must be keyed by ID.
161    */
162   _recordTelemetry(histogramId, value) {
163     if (this.isPrivate) {
164       // The reason why we don't record telemetry in private windows is because
165       // the available actions can be different from regular mode. The main
166       // difference is that all of the persistent permission options like
167       // "Always remember" aren't there, so they really need to be handled
168       // separately to avoid skewing results. For notifications with the same
169       // choices, there would be no reason not to record in private windows as
170       // well, but it's just simpler to use the same check for everything.
171       return;
172     }
173     let histogram = Services.telemetry.getKeyedHistogramById(histogramId);
174     histogram.add("(all)", value);
175     histogram.add(this.id, value);
176   },
178   /**
179    * Adds an enumerated value to the POPUP_NOTIFICATION_STATS histogram,
180    * ensuring that it is recorded at most once for each distinct Notification.
181    *
182    * Statistics for reopened notifications are recorded in separate buckets.
183    *
184    * @param value
185    *        One of the TELEMETRY_STAT_ constants.
186    */
187   _recordTelemetryStat(value) {
188     if (this.wasDismissed) {
189       value += TELEMETRY_STAT_REOPENED_OFFSET;
190     }
191     if (!this.recordedTelemetryStats.has(value)) {
192       this.recordedTelemetryStats.add(value);
193       this._recordTelemetry("POPUP_NOTIFICATION_STATS", value);
194     }
195   },
199  * The PopupNotifications object manages popup notifications for a given browser
200  * window.
201  * @param tabbrowser
202  *        window's TabBrowser. Used to observe tab switching events and
203  *        for determining the active browser element.
204  * @param panel
205  *        The <xul:panel/> element to use for notifications. The panel is
206  *        populated with <popupnotification> children and displayed it as
207  *        needed.
208  * @param iconBox
209  *        Reference to a container element that should be hidden or
210  *        unhidden when notifications are hidden or shown. It should be the
211  *        parent of anchor elements whose IDs are passed to show().
212  *        It is used as a fallback popup anchor if notifications specify
213  *        invalid or non-existent anchor IDs.
214  * @param options
215  *        An optional object with the following optional properties:
216  *        {
217  *          shouldSuppress:
218  *            If this function returns true, then all notifications are
219  *            suppressed for this window. This state is checked on construction
220  *            and when the "anchorVisibilityChange" method is called.
221  *        }
222  */
223 function PopupNotifications(tabbrowser, panel, iconBox, options = {}) {
224   if (!tabbrowser) {
225     throw new Error("Invalid tabbrowser");
226   }
227   if (iconBox && ChromeUtils.getClassName(iconBox) != "XULElement") {
228     throw new Error("Invalid iconBox");
229   }
230   if (ChromeUtils.getClassName(panel) != "XULPopupElement") {
231     throw new Error("Invalid panel");
232   }
234   this._shouldSuppress = options.shouldSuppress || (() => false);
235   this._suppress = this._shouldSuppress();
237   this.window = tabbrowser.ownerGlobal;
238   this.panel = panel;
239   this.tabbrowser = tabbrowser;
240   this.iconBox = iconBox;
241   this.buttonDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY);
243   this.panel.addEventListener("popuphidden", this, true);
244   this.panel.classList.add("popup-notification-panel", "panel-no-padding");
246   // This listener will be attached to the chrome window whenever a notification
247   // is showing, to allow the user to dismiss notifications using the escape key.
248   this._handleWindowKeyPress = aEvent => {
249     if (aEvent.keyCode != aEvent.DOM_VK_ESCAPE) {
250       return;
251     }
253     // Esc key cancels the topmost notification, if there is one.
254     let notification = this.panel.firstElementChild;
255     if (!notification) {
256       return;
257     }
259     let doc = this.window.document;
260     let focusedElement = Services.focus.focusedElement;
262     // If the chrome window has a focused element, let it handle the ESC key instead.
263     if (
264       !focusedElement ||
265       focusedElement == doc.body ||
266       focusedElement == this.tabbrowser.selectedBrowser ||
267       // Ignore focused elements inside the notification.
268       notification.contains(focusedElement)
269     ) {
270       let escAction = notification.notification.options.escAction;
271       this._onButtonEvent(aEvent, escAction, "esc-press", notification);
272     }
273   };
275   let documentElement = this.window.document.documentElement;
276   let locationBarHidden = documentElement
277     .getAttribute("chromehidden")
278     .includes("location");
279   let isFullscreen = !!this.window.document.fullscreenElement;
281   this.panel.setAttribute("followanchor", !locationBarHidden && !isFullscreen);
283   // There are no anchor icons in DOM fullscreen mode, but we would
284   // still like to show the popup notification. To avoid an infinite
285   // loop of showing and hiding, we have to disable followanchor
286   // (which hides the element without an anchor) in fullscreen.
287   this.window.addEventListener(
288     "MozDOMFullscreen:Entered",
289     () => {
290       this.panel.setAttribute("followanchor", "false");
291     },
292     true
293   );
294   this.window.addEventListener(
295     "MozDOMFullscreen:Exited",
296     () => {
297       this.panel.setAttribute("followanchor", !locationBarHidden);
298     },
299     true
300   );
302   this.window.addEventListener("activate", this, true);
303   if (this.tabbrowser.tabContainer) {
304     this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true);
306     this.tabbrowser.tabContainer.addEventListener("TabClose", aEvent => {
307       // If the tab was just closed and we have notifications associated with it,
308       // then the notifications were closed because of the tab removal. We need to
309       // record this event in telemetry and fire the removal callback.
310       this.nextRemovalReason = TELEMETRY_STAT_REMOVAL_LEAVE_PAGE;
311       let notifications = this._getNotificationsForBrowser(
312         aEvent.target.linkedBrowser
313       );
314       for (let notification of notifications) {
315         this._fireCallback(
316           notification,
317           NOTIFICATION_EVENT_REMOVED,
318           this.nextRemovalReason
319         );
320         notification._recordTelemetryStat(this.nextRemovalReason);
321       }
322     });
323   }
326 PopupNotifications.prototype = {
327   window: null,
328   panel: null,
329   tabbrowser: null,
331   _iconBox: null,
332   set iconBox(iconBox) {
333     // Remove the listeners on the old iconBox, if needed
334     if (this._iconBox) {
335       this._iconBox.removeEventListener("click", this);
336       this._iconBox.removeEventListener("keypress", this);
337     }
338     this._iconBox = iconBox;
339     if (iconBox) {
340       iconBox.addEventListener("click", this);
341       iconBox.addEventListener("keypress", this);
342     }
343   },
344   get iconBox() {
345     return this._iconBox;
346   },
348   /**
349    * Retrieve one or many Notification object/s associated with the browser/ID pair.
350    * @param {string|string[]} id
351    *        The Notification ID or an array of IDs to search for.
352    * @param [browser]
353    *        The browser whose notifications should be searched. If null, the
354    *        currently selected browser's notifications will be searched.
355    *
356    * @returns {Notification|Notification[]|null} If passed a single id, returns the corresponding Notification object, or null if no such
357    *          notification exists.
358    *          If passed an id array, returns an array of Notification objects which match the ids.
359    */
360   getNotification: function PopupNotifications_getNotification(id, browser) {
361     let notifications = this._getNotificationsForBrowser(
362       browser || this.tabbrowser.selectedBrowser
363     );
364     if (Array.isArray(id)) {
365       return notifications.filter(x => id.includes(x.id));
366     }
367     return notifications.find(x => x.id == id) || null;
368   },
370   /**
371    * Adds a new popup notification.
372    * @param browser
373    *        The <xul:browser> element associated with the notification. Must not
374    *        be null.
375    * @param id
376    *        A unique ID that identifies the type of notification (e.g.
377    *        "geolocation"). Only one notification with a given ID can be visible
378    *        at a time. If a notification already exists with the given ID, it
379    *        will be replaced.
380    * @param message
381    *        A string containing the text to be displayed as the notification
382    *        header.  The string may optionally contain one or two "<>" as a
383    *        placeholder which is later replaced by a host name or an addon name
384    *        that is formatted to look bold, in which case the options.name
385    *        property (as well as options.secondName if passing a "<>" and a "{}"
386    *        placeholder) needs to be specified. "<>" will be considered as the
387    *        first and "{}" as the second placeholder.
388    * @param anchorID
389    *        The ID of the element that should be used as this notification
390    *        popup's anchor. May be null, in which case the notification will be
391    *        anchored to the iconBox.
392    * @param mainAction
393    *        A JavaScript object literal describing the notification button's
394    *        action. If present, it must have the following properties:
395    *          - label (string): the button's label.
396    *          - accessKey (string): the button's accessKey.
397    *          - callback (function): a callback to be invoked when the button is
398    *            pressed, is passed an object that contains the following fields:
399    *              - checkboxChecked: (boolean) If the optional checkbox is checked.
400    *              - source: (string): the source of the action that initiated the
401    *                callback, either:
402    *                - "button" if popup buttons were directly activated, or
403    *                - "esc-press" if the user pressed the escape key, or
404    *                - "menucommand" if a menu was activated.
405    *          - [optional] dismiss (boolean): If this is true, the notification
406    *            will be dismissed instead of removed after running the callback.
407    *          - [optional] disabled (boolean): If this is true, the button
408    *            will be disabled.
409    *          - [optional] disableHighlight (boolean): If this is true, the button
410    *            will not apply the default highlight style.
411    *        If null, the notification will have a default "OK" action button
412    *        that can be used to dismiss the popup and secondaryActions will be ignored.
413    * @param secondaryActions
414    *        An optional JavaScript array describing the notification's alternate
415    *        actions. The array should contain objects with the same properties
416    *        as mainAction. These are used to populate the notification button's
417    *        dropdown menu.
418    * @param options
419    *        An options JavaScript object holding additional properties for the
420    *        notification. The following properties are currently supported:
421    *        persistence: An integer. The notification will not automatically
422    *                     dismiss for this many page loads.
423    *        timeout:     A time in milliseconds. The notification will not
424    *                     automatically dismiss before this time.
425    *        persistWhileVisible:
426    *                     A boolean. If true, a visible notification will always
427    *                     persist across location changes.
428    *        persistent:  A boolean. If true, the notification will always
429    *                     persist even across tab and app changes (but not across
430    *                     location changes), until the user accepts or rejects
431    *                     the request. The notification will never be implicitly
432    *                     dismissed.
433    *        dismissed:   Whether the notification should be added as a dismissed
434    *                     notification. Dismissed notifications can be activated
435    *                     by clicking on their anchorElement.
436    *        autofocus:   Whether the notification should be autofocused on
437    *                     showing, stealing focus from any other focused element.
438    *        eventCallback:
439    *                     Callback to be invoked when the notification changes
440    *                     state. The callback's first argument is a string
441    *                     identifying the state change:
442    *                     "dismissed": notification has been dismissed by the
443    *                                  user (e.g. by clicking away or switching
444    *                                  tabs)
445    *                     "removed": notification has been removed (due to
446    *                                location change or user action)
447    *                     "showing": notification is about to be shown
448    *                                (this can be fired multiple times as
449    *                                 notifications are dismissed and re-shown)
450    *                                If the callback returns true, the notification
451    *                                will be dismissed.
452    *                     "shown": notification has been shown (this can be fired
453    *                              multiple times as notifications are dismissed
454    *                              and re-shown)
455    *                     "swapping": the docshell of the browser that created
456    *                                 the notification is about to be swapped to
457    *                                 another browser. A second parameter contains
458    *                                 the browser that is receiving the docshell,
459    *                                 so that the event callback can transfer stuff
460    *                                 specific to this notification.
461    *                                 If the callback returns true, the notification
462    *                                 will be moved to the new browser.
463    *                                 If the callback isn't implemented, returns false,
464    *                                 or doesn't return any value, the notification
465    *                                 will be removed.
466    *        neverShow:   Indicate that no popup should be shown for this
467    *                     notification. Useful for just showing the anchor icon.
468    *        removeOnDismissal:
469    *                     Notifications with this parameter set to true will be
470    *                     removed when they would have otherwise been dismissed
471    *                     (i.e. any time the popup is closed due to user
472    *                     interaction).
473    *        hideClose:   Indicate that the little close button in the corner of
474    *                     the panel should be hidden.
475    *        checkbox:    An object that allows you to add a checkbox and
476    *                     control its behavior with these fields:
477    *                       label:
478    *                         (required) Label to be shown next to the checkbox.
479    *                       checked:
480    *                         (optional) Whether the checkbox should be checked
481    *                         by default. Defaults to false.
482    *                       checkedState:
483    *                         (optional) An object that allows you to customize
484    *                         the notification state when the checkbox is checked.
485    *                           disableMainAction:
486    *                             (optional) Whether the mainAction is disabled.
487    *                             Defaults to false.
488    *                           warningLabel:
489    *                             (optional) A (warning) text that is shown below the
490    *                             checkbox. Pass null to hide.
491    *                       uncheckedState:
492    *                         (optional) An object that allows you to customize
493    *                         the notification state when the checkbox is not checked.
494    *                         Has the same attributes as checkedState.
495    *        popupIconClass:
496    *                     A string. A class (or space separated list of classes)
497    *                     that will be applied to the icon in the popup so that
498    *                     several notifications using the same panel can use
499    *                     different icons.
500    *        popupIconURL:
501    *                     A string. URL of the image to be displayed in the popup.
502    *                     Normally specified in CSS using list-style-image and the
503    *                     .popup-notification-icon[popupid=...] selector.
504    *        learnMoreURL:
505    *                     A string URL. Setting this property will make the
506    *                     prompt display a "Learn More" link that, when clicked,
507    *                     opens the URL in a new tab.
508    *        displayURI:
509    *                     The nsIURI of the page the notification came
510    *                     from. If present, this will be displayed above the message.
511    *                     If the nsIURI represents a file, the path will be displayed,
512    *                     otherwise the hostPort will be displayed.
513    *        name:
514    *                     An optional string formatted to look bold and used in the
515    *                     notifiation description header text. Usually a host name or
516    *                     addon name.
517    *        secondName:
518    *                     An optional string formatted to look bold and used in the
519    *                     notification description header text. Usually a host name or
520    *                     addon name. This is similar to name, and only used in case
521    *                     where message contains a "<>" and a "{}" placeholder. "<>"
522    *                     is considered the first and "{}" is considered the second
523    *                     placeholder.
524    *        escAction:
525    *                     An optional string indicating the action to take when the
526    *                     Esc key is pressed. This should be set to the name of the
527    *                     command to run. If not provided, "secondarybuttoncommand"
528    *                     will be used.
529    *        extraAttr:
530    *                     An optional string value which will be given to the
531    *                     extraAttr attribute on the notification's anchorElement
532    * @returns the Notification object corresponding to the added notification.
533    */
534   show: function PopupNotifications_show(
535     browser,
536     id,
537     message,
538     anchorID,
539     mainAction,
540     secondaryActions,
541     options
542   ) {
543     function isInvalidAction(a) {
544       return (
545         !a || !(typeof a.callback == "function") || !a.label || !a.accessKey
546       );
547     }
549     if (!browser) {
550       throw new Error("PopupNotifications_show: invalid browser");
551     }
552     if (!id) {
553       throw new Error("PopupNotifications_show: invalid ID");
554     }
555     if (mainAction && isInvalidAction(mainAction)) {
556       throw new Error("PopupNotifications_show: invalid mainAction");
557     }
558     if (secondaryActions && secondaryActions.some(isInvalidAction)) {
559       throw new Error("PopupNotifications_show: invalid secondaryActions");
560     }
562     let notification = new Notification(
563       id,
564       message,
565       anchorID,
566       mainAction,
567       secondaryActions,
568       browser,
569       this,
570       options
571     );
573     if (options) {
574       let escAction = options.escAction;
575       if (
576         escAction != "buttoncommand" &&
577         escAction != "secondarybuttoncommand"
578       ) {
579         escAction = "secondarybuttoncommand";
580       }
581       notification.options.escAction = escAction;
582     }
584     if (options && options.dismissed) {
585       notification.dismissed = true;
586     }
588     let existingNotification = this.getNotification(id, browser);
589     if (existingNotification) {
590       this._remove(existingNotification);
591     }
593     let notifications = this._getNotificationsForBrowser(browser);
594     notifications.push(notification);
596     let isActiveBrowser = this._isActiveBrowser(browser);
597     let isActiveWindow = Services.focus.activeWindow == this.window;
599     if (isActiveBrowser) {
600       if (isActiveWindow) {
601         // Autofocus if the notification requests focus.
602         if (options && !options.dismissed && options.autofocus) {
603           this.panel.removeAttribute("noautofocus");
604         } else {
605           this.panel.setAttribute("noautofocus", "true");
606         }
608         // show panel now
609         this._update(
610           notifications,
611           new Set([notification.anchorElement]),
612           true
613         );
614       } else {
615         // indicate attention and update the icon if necessary
616         if (!notification.dismissed) {
617           this.window.getAttention();
618         }
619         this._updateAnchorIcons(
620           notifications,
621           this._getAnchorsForNotifications(
622             notifications,
623             notification.anchorElement
624           )
625         );
626         this._notify("backgroundShow");
627       }
628     } else {
629       // Notify observers that we're not showing the popup (useful for testing)
630       this._notify("backgroundShow");
631     }
633     return notification;
634   },
636   /**
637    * Returns true if the notification popup is currently being displayed.
638    */
639   get isPanelOpen() {
640     let panelState = this.panel.state;
642     return panelState == "showing" || panelState == "open";
643   },
645   /**
646    * Called by the consumer to indicate that a browser's location has changed,
647    * so that we can update the active notifications accordingly.
648    */
649   locationChange: function PopupNotifications_locationChange(aBrowser) {
650     if (!aBrowser) {
651       throw new Error("PopupNotifications_locationChange: invalid browser");
652     }
654     let notifications = this._getNotificationsForBrowser(aBrowser);
656     this.nextRemovalReason = TELEMETRY_STAT_REMOVAL_LEAVE_PAGE;
658     notifications = notifications.filter(function(notification) {
659       // The persistWhileVisible option allows an open notification to persist
660       // across location changes
661       if (notification.options.persistWhileVisible && this.isPanelOpen) {
662         if (
663           "persistence" in notification.options &&
664           notification.options.persistence
665         ) {
666           notification.options.persistence--;
667         }
668         return true;
669       }
671       // The persistence option allows a notification to persist across multiple
672       // page loads
673       if (
674         "persistence" in notification.options &&
675         notification.options.persistence
676       ) {
677         notification.options.persistence--;
678         return true;
679       }
681       // The timeout option allows a notification to persist until a certain time
682       if (
683         "timeout" in notification.options &&
684         Date.now() <= notification.options.timeout
685       ) {
686         return true;
687       }
689       notification._recordTelemetryStat(this.nextRemovalReason);
690       this._fireCallback(
691         notification,
692         NOTIFICATION_EVENT_REMOVED,
693         this.nextRemovalReason
694       );
695       return false;
696     }, this);
698     this._setNotificationsForBrowser(aBrowser, notifications);
700     if (this._isActiveBrowser(aBrowser)) {
701       this.anchorVisibilityChange();
702     }
703   },
705   /**
706    * Called by the consumer to indicate that the visibility of the notification
707    * anchors may have changed, but the location has not changed. This also
708    * checks whether all notifications are suppressed for this window.
709    *
710    * Calling this method may result in the "showing" and "shown" events for
711    * visible notifications to be invoked even if the anchor has not changed.
712    */
713   anchorVisibilityChange() {
714     let suppress = this._shouldSuppress();
715     if (!suppress) {
716       // If notifications are not suppressed, always update the visibility.
717       this._suppress = false;
718       let notifications = this._getNotificationsForBrowser(
719         this.tabbrowser.selectedBrowser
720       );
721       this._update(
722         notifications,
723         this._getAnchorsForNotifications(
724           notifications,
725           getAnchorFromBrowser(this.tabbrowser.selectedBrowser)
726         )
727       );
728       return;
729     }
731     // Notifications are suppressed, ensure that the panel is hidden.
732     if (!this._suppress) {
733       this._suppress = true;
734       this._hidePanel().catch(Cu.reportError);
735     }
736   },
738   /**
739    * Removes one or many Notifications.
740    * @param {Notification|Notification[]} notification - The Notification object/s to remove.
741    * @param {Boolean} [isCancel] - Whether to signal,  in the notification event, that removal
742    *  should be treated as cancel. This is currently used to cancel permission requests
743    *  when their Notifications are removed.
744    */
745   remove: function PopupNotifications_remove(notification, isCancel = false) {
746     let notificationArray = Array.isArray(notification)
747       ? notification
748       : [notification];
749     let activeBrowser;
751     notificationArray.forEach(n => {
752       this._remove(n, isCancel);
753       if (!activeBrowser && this._isActiveBrowser(n.browser)) {
754         activeBrowser = n.browser;
755       }
756     });
758     if (activeBrowser) {
759       let browserNotifications = this._getNotificationsForBrowser(
760         activeBrowser
761       );
762       this._update(browserNotifications);
763     }
764   },
766   handleEvent(aEvent) {
767     switch (aEvent.type) {
768       case "popuphidden":
769         this._onPopupHidden(aEvent);
770         break;
771       case "activate":
772         if (this.isPanelOpen) {
773           for (let elt of this.panel.children) {
774             elt.notification.timeShown = this.window.performance.now();
775           }
776           break;
777         }
778       // fall through
779       case "TabSelect":
780         let self = this;
781         // setTimeout(..., 0) needed, otherwise openPopup from "activate" event
782         // handler results in the popup being hidden again for some reason...
783         this.window.setTimeout(function() {
784           self._update();
785         }, 0);
786         break;
787       case "click":
788       case "keypress":
789         this._onIconBoxCommand(aEvent);
790         break;
791     }
792   },
794   // Utility methods
796   _ignoreDismissal: null,
797   _currentAnchorElement: null,
799   /**
800    * Gets notifications for the currently selected browser.
801    */
802   get _currentNotifications() {
803     return this.tabbrowser.selectedBrowser
804       ? this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser)
805       : [];
806   },
808   _remove: function PopupNotifications_removeHelper(
809     notification,
810     isCancel = false
811   ) {
812     // This notification may already be removed, in which case let's just fail
813     // silently.
814     let notifications = this._getNotificationsForBrowser(notification.browser);
815     if (!notifications) {
816       return;
817     }
819     var index = notifications.indexOf(notification);
820     if (index == -1) {
821       return;
822     }
824     if (this._isActiveBrowser(notification.browser)) {
825       notification.anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
826     }
828     // remove the notification
829     notifications.splice(index, 1);
830     this._fireCallback(
831       notification,
832       NOTIFICATION_EVENT_REMOVED,
833       this.nextRemovalReason,
834       isCancel
835     );
836   },
838   /**
839    * Dismisses the notification without removing it.
840    *
841    * @param {Event} the event associated with the user interaction that
842    *                caused the dismissal
843    * @param {boolean} whether to disable persistent status. Normally,
844    *                  persistent prompts can not be dismissed. You can
845    *                  use this argument to force dismissal.
846    */
847   _dismiss: function PopupNotifications_dismiss(
848     event,
849     disablePersistent = false
850   ) {
851     if (disablePersistent) {
852       let notificationEl = getNotificationFromElement(event.target);
853       if (notificationEl) {
854         notificationEl.notification.options.persistent = false;
855       }
856     }
858     let browser =
859       this.panel.firstElementChild &&
860       this.panel.firstElementChild.notification.browser;
861     this.panel.hidePopup();
862     if (browser) {
863       browser.focus();
864     }
865   },
867   /**
868    * Hides the notification popup.
869    */
870   _hidePanel: function PopupNotifications_hide() {
871     if (this.panel.state == "closed") {
872       return Promise.resolve();
873     }
874     if (this._ignoreDismissal) {
875       return this._ignoreDismissal.promise;
876     }
877     let deferred = PromiseUtils.defer();
878     this._ignoreDismissal = deferred;
879     this.panel.hidePopup();
880     return deferred.promise;
881   },
883   /**
884    * Removes all notifications from the notification popup.
885    */
886   _clearPanel() {
887     let popupnotification;
888     while ((popupnotification = this.panel.lastElementChild)) {
889       this.panel.removeChild(popupnotification);
891       // If this notification was provided by the chrome document rather than
892       // created ad hoc, move it back to where we got it from.
893       let originalParent = gNotificationParents.get(popupnotification);
894       if (originalParent) {
895         popupnotification.notification = null;
897         // Re-hide the notification such that it isn't rendered in the chrome
898         // document. _refreshPanel will unhide it again when needed.
899         popupnotification.hidden = true;
901         originalParent.appendChild(popupnotification);
902       }
903     }
904   },
906   /**
907    * Formats the notification description message before we display it
908    * and splits it into three parts if the message contains "<>" as
909    * placeholder.
910    *
911    * param notification
912    *       The Notification object which contains the message to format.
913    *
914    * @returns a Javascript object that has the following properties:
915    * start: A start label string containing the first part of the message.
916    *        It may contain the whole string if the description message
917    *        does not have "<>" as a placeholder. For example, local
918    *        file URIs with description messages that don't display hostnames.
919    * name:  A string that is formatted to look bold. It replaces the
920    *        placeholder with the options.name property from the notification
921    *        object which is usually an addon name or a host name.
922    * end:   The last part of the description message.
923    */
924   _formatDescriptionMessage(n) {
925     let text = {};
926     let array = n.message.split(/<>|{}/);
927     text.start = array[0] || "";
928     text.name = n.options.name || "";
929     text.end = array[1] || "";
930     if (array.length == 3) {
931       text.secondName = n.options.secondName || "";
932       text.secondEnd = array[2] || "";
934       // name and secondName should be in logical positions.  Swap them in case
935       // the second placeholder came before the first one in the original string.
936       if (n.message.indexOf("{}") < n.message.indexOf("<>")) {
937         let tmp = text.name;
938         text.name = text.secondName;
939         text.secondName = tmp;
940       }
941     } else if (array.length > 3) {
942       Cu.reportError(
943         "Unexpected array length encountered in " +
944           "_formatDescriptionMessage: " +
945           array.length
946       );
947     }
948     return text;
949   },
951   _refreshPanel: function PopupNotifications_refreshPanel(notificationsToShow) {
952     this._clearPanel();
954     notificationsToShow.forEach(function(n) {
955       let doc = this.window.document;
957       // Append "-notification" to the ID to try to avoid ID conflicts with other stuff
958       // in the document.
959       let popupnotificationID = n.id + "-notification";
961       // If the chrome document provides a popupnotification with this id, use
962       // that. Otherwise create it ad-hoc.
963       let popupnotification = doc.getElementById(popupnotificationID);
964       if (popupnotification) {
965         gNotificationParents.set(
966           popupnotification,
967           popupnotification.parentNode
968         );
969       } else {
970         popupnotification = doc.createXULElement("popupnotification");
971       }
973       // Create the notification description element.
974       let desc = this._formatDescriptionMessage(n);
975       popupnotification.setAttribute("label", desc.start);
976       popupnotification.setAttribute("name", desc.name);
977       popupnotification.setAttribute("endlabel", desc.end);
978       if ("secondName" in desc && "secondEnd" in desc) {
979         popupnotification.setAttribute("secondname", desc.secondName);
980         popupnotification.setAttribute("secondendlabel", desc.secondEnd);
981       } else {
982         popupnotification.removeAttribute("secondname");
983         popupnotification.removeAttribute("secondendlabel");
984       }
986       popupnotification.setAttribute("id", popupnotificationID);
987       popupnotification.setAttribute("popupid", n.id);
988       popupnotification.setAttribute(
989         "oncommand",
990         "PopupNotifications._onCommand(event);"
991       );
992       popupnotification.setAttribute(
993         "closebuttoncommand",
994         `PopupNotifications._dismiss(event, true);`
995       );
996       if (n.mainAction) {
997         popupnotification.setAttribute("buttonlabel", n.mainAction.label);
998         popupnotification.setAttribute(
999           "buttonaccesskey",
1000           n.mainAction.accessKey
1001         );
1002         popupnotification.toggleAttribute(
1003           "buttonhighlight",
1004           !n.mainAction.disableHighlight
1005         );
1006         popupnotification.setAttribute(
1007           "buttoncommand",
1008           "PopupNotifications._onButtonEvent(event, 'buttoncommand');"
1009         );
1010         popupnotification.setAttribute(
1011           "dropmarkerpopupshown",
1012           "PopupNotifications._onButtonEvent(event, 'dropmarkerpopupshown');"
1013         );
1014         popupnotification.setAttribute(
1015           "learnmoreclick",
1016           "PopupNotifications._onButtonEvent(event, 'learnmoreclick');"
1017         );
1018         popupnotification.setAttribute(
1019           "menucommand",
1020           "PopupNotifications._onMenuCommand(event);"
1021         );
1022       } else {
1023         // Enable the default button to let the user close the popup if the close button is hidden
1024         popupnotification.setAttribute(
1025           "buttoncommand",
1026           "PopupNotifications._onButtonEvent(event, 'buttoncommand');"
1027         );
1028         popupnotification.toggleAttribute("buttonhighlight", true);
1029         popupnotification.removeAttribute("buttonlabel");
1030         popupnotification.removeAttribute("buttonaccesskey");
1031         popupnotification.removeAttribute("dropmarkerpopupshown");
1032         popupnotification.removeAttribute("learnmoreclick");
1033         popupnotification.removeAttribute("menucommand");
1034       }
1036       let classes = "popup-notification-icon";
1037       if (n.options.popupIconClass) {
1038         classes += " " + n.options.popupIconClass;
1039       }
1040       popupnotification.setAttribute("iconclass", classes);
1042       if (n.options.popupIconURL) {
1043         popupnotification.setAttribute("icon", n.options.popupIconURL);
1044       } else {
1045         popupnotification.removeAttribute("icon");
1046       }
1048       if (n.options.learnMoreURL) {
1049         popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL);
1050       } else {
1051         popupnotification.removeAttribute("learnmoreurl");
1052       }
1054       if (n.options.displayURI) {
1055         let uri;
1056         try {
1057           if (n.options.displayURI instanceof Ci.nsIFileURL) {
1058             uri = n.options.displayURI.pathQueryRef;
1059           } else {
1060             try {
1061               uri = n.options.displayURI.hostPort;
1062             } catch (e) {
1063               uri = n.options.displayURI.spec;
1064             }
1065           }
1066           popupnotification.setAttribute("origin", uri);
1067         } catch (e) {
1068           Cu.reportError(e);
1069           popupnotification.removeAttribute("origin");
1070         }
1071       } else {
1072         popupnotification.removeAttribute("origin");
1073       }
1075       if (n.options.hideClose) {
1076         popupnotification.setAttribute("closebuttonhidden", "true");
1077       }
1079       popupnotification.notification = n;
1080       let menuitems = [];
1082       if (n.mainAction && n.secondaryActions && n.secondaryActions.length) {
1083         let telemetryStatId = TELEMETRY_STAT_ACTION_2;
1085         let secondaryAction = n.secondaryActions[0];
1086         popupnotification.setAttribute(
1087           "secondarybuttonlabel",
1088           secondaryAction.label
1089         );
1090         popupnotification.setAttribute(
1091           "secondarybuttonaccesskey",
1092           secondaryAction.accessKey
1093         );
1094         popupnotification.setAttribute(
1095           "secondarybuttoncommand",
1096           "PopupNotifications._onButtonEvent(event, 'secondarybuttoncommand');"
1097         );
1099         for (let i = 1; i < n.secondaryActions.length; i++) {
1100           let action = n.secondaryActions[i];
1101           let item = doc.createXULElement("menuitem");
1102           item.setAttribute("label", action.label);
1103           item.setAttribute("accesskey", action.accessKey);
1104           item.notification = n;
1105           item.action = action;
1107           menuitems.push(item);
1109           // We can only record a limited number of actions in telemetry. If
1110           // there are more, the latest are all recorded in the last bucket.
1111           item.action.telemetryStatId = telemetryStatId;
1112           if (telemetryStatId < TELEMETRY_STAT_ACTION_LAST) {
1113             telemetryStatId++;
1114           }
1115         }
1116         popupnotification.setAttribute("secondarybuttonhidden", "false");
1117       } else {
1118         popupnotification.setAttribute("secondarybuttonhidden", "true");
1119       }
1120       popupnotification.setAttribute(
1121         "dropmarkerhidden",
1122         n.secondaryActions.length < 2 ? "true" : "false"
1123       );
1125       let checkbox = n.options.checkbox;
1126       if (checkbox && checkbox.label) {
1127         let checked =
1128           n._checkboxChecked != null ? n._checkboxChecked : !!checkbox.checked;
1129         popupnotification.checkboxState = {
1130           checked,
1131           label: checkbox.label,
1132         };
1134         if (checked) {
1135           this._setNotificationUIState(
1136             popupnotification,
1137             checkbox.checkedState
1138           );
1139         } else {
1140           this._setNotificationUIState(
1141             popupnotification,
1142             checkbox.uncheckedState
1143           );
1144         }
1145       } else {
1146         popupnotification.checkboxState = null;
1147         // Reset the UI state to avoid previous state bleeding into this prompt.
1148         this._setNotificationUIState(popupnotification);
1149       }
1151       this.panel.appendChild(popupnotification);
1153       // The popupnotification may be hidden if we got it from the chrome
1154       // document rather than creating it ad hoc.
1155       popupnotification.show();
1157       popupnotification.menupopup.textContent = "";
1158       popupnotification.menupopup.append(...menuitems);
1159     }, this);
1160   },
1162   _setNotificationUIState(notification, state = {}) {
1163     let mainAction = notification.notification.mainAction;
1164     if (
1165       (mainAction && mainAction.disabled) ||
1166       state.disableMainAction ||
1167       notification.hasAttribute("invalidselection")
1168     ) {
1169       notification.setAttribute("mainactiondisabled", "true");
1170     } else {
1171       notification.removeAttribute("mainactiondisabled");
1172     }
1173     if (state.warningLabel) {
1174       notification.setAttribute("warninglabel", state.warningLabel);
1175       notification.removeAttribute("warninghidden");
1176     } else {
1177       notification.setAttribute("warninghidden", "true");
1178     }
1179   },
1181   _showPanel: function PopupNotifications_showPanel(
1182     notificationsToShow,
1183     anchorElement
1184   ) {
1185     this.panel.hidden = false;
1187     notificationsToShow = notificationsToShow.filter(n => {
1188       if (anchorElement != n.anchorElement) {
1189         return false;
1190       }
1192       let dismiss = this._fireCallback(n, NOTIFICATION_EVENT_SHOWING);
1193       if (dismiss) {
1194         n.dismissed = true;
1195       }
1196       return !dismiss;
1197     });
1198     if (!notificationsToShow.length) {
1199       return;
1200     }
1201     let notificationIds = notificationsToShow.map(n => n.id);
1203     this._refreshPanel(notificationsToShow);
1205     function isNullOrHidden(elem) {
1206       if (!elem) {
1207         return true;
1208       }
1210       let anchorRect = elem.getBoundingClientRect();
1211       return anchorRect.width == 0 && anchorRect.height == 0;
1212     }
1214     // If the anchor element is hidden or null, fall back to the identity icon.
1215     if (isNullOrHidden(anchorElement)) {
1216       anchorElement = this.window.document.getElementById("identity-icon");
1218       if (isNullOrHidden(anchorElement)) {
1219         anchorElement = this.window.document.getElementById(
1220           "urlbar-search-button"
1221         );
1222       }
1224       // If the identity and search icons are not available in this window, use
1225       // the tab as the anchor. We only ever show notifications for the current
1226       // browser, so we can just use the current tab.
1227       if (isNullOrHidden(anchorElement)) {
1228         anchorElement = this.tabbrowser.selectedTab;
1230         // If we're in an entirely chromeless environment, set the anchorElement
1231         // to null and let openPopup show the notification at (0,0) later.
1232         if (isNullOrHidden(anchorElement)) {
1233           anchorElement = null;
1234         }
1235       }
1236     }
1238     if (this.isPanelOpen && this._currentAnchorElement == anchorElement) {
1239       notificationsToShow.forEach(function(n) {
1240         this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
1241       }, this);
1243       // Make sure we update the noautohide attribute on the panel, in case it changed.
1244       if (notificationsToShow.some(n => n.options.persistent)) {
1245         this.panel.setAttribute("noautohide", "true");
1246       } else {
1247         this.panel.removeAttribute("noautohide");
1248       }
1250       // Let tests know that the panel was updated and what notifications it was
1251       // updated with so that tests can wait for the correct notifications to be
1252       // added.
1253       let event = new this.window.CustomEvent("PanelUpdated", {
1254         detail: notificationIds,
1255       });
1256       this.panel.dispatchEvent(event);
1257       return;
1258     }
1260     // If the panel is already open but we're changing anchors, we need to hide
1261     // it first.  Otherwise it can appear in the wrong spot.  (_hidePanel is
1262     // safe to call even if the panel is already hidden.)
1263     this._hidePanel().then(() => {
1264       this._currentAnchorElement = anchorElement;
1266       if (notificationsToShow.some(n => n.options.persistent)) {
1267         this.panel.setAttribute("noautohide", "true");
1268       } else {
1269         this.panel.removeAttribute("noautohide");
1270       }
1272       notificationsToShow.forEach(function(n) {
1273         // Record that the notification was actually displayed on screen.
1274         // Notifications that were opened a second time or that were originally
1275         // shown with "options.dismissed" will be recorded in a separate bucket.
1276         n._recordTelemetryStat(TELEMETRY_STAT_OFFERED);
1277         // Remember the time the notification was shown for the security delay.
1278         n.timeShown = this.window.performance.now();
1279       }, this);
1281       let target = this.panel;
1282       if (target.parentNode) {
1283         // NOTIFICATION_EVENT_SHOWN should be fired for the panel before
1284         // anyone listening for popupshown on the panel gets run. Otherwise,
1285         // the panel will not be initialized when the popupshown event
1286         // listeners run.
1287         // By targeting the panel's parent and using a capturing listener, we
1288         // can have our listener called before others waiting for the panel to
1289         // be shown (which probably expect the panel to be fully initialized)
1290         target = target.parentNode;
1291       }
1292       if (this._popupshownListener) {
1293         target.removeEventListener(
1294           "popupshown",
1295           this._popupshownListener,
1296           true
1297         );
1298       }
1299       this._popupshownListener = function(e) {
1300         target.removeEventListener(
1301           "popupshown",
1302           this._popupshownListener,
1303           true
1304         );
1305         this._popupshownListener = null;
1307         notificationsToShow.forEach(function(n) {
1308           this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
1309         }, this);
1310         // These notifications are used by tests to know when all the processing
1311         // required to display the panel has happened.
1312         this.panel.dispatchEvent(new this.window.CustomEvent("Shown"));
1313         let event = new this.window.CustomEvent("PanelUpdated", {
1314           detail: notificationIds,
1315         });
1316         this.panel.dispatchEvent(event);
1317       };
1318       this._popupshownListener = this._popupshownListener.bind(this);
1319       target.addEventListener("popupshown", this._popupshownListener, true);
1321       this.panel.openPopup(anchorElement, "bottomcenter topleft", 0, 0);
1322     });
1323   },
1325   /**
1326    * Updates the notification state in response to window activation or tab
1327    * selection changes.
1328    *
1329    * @param notifications an array of Notification instances. if null,
1330    *                      notifications will be retrieved off the current
1331    *                      browser tab
1332    * @param anchors       is a XUL element or a Set of XUL elements that the
1333    *                      notifications panel(s) will be anchored to.
1334    * @param dismissShowing if true, dismiss any currently visible notifications
1335    *                       if there are no notifications to show. Otherwise,
1336    *                       currently displayed notifications will be left alone.
1337    */
1338   _update: function PopupNotifications_update(
1339     notifications,
1340     anchors = new Set(),
1341     dismissShowing = false
1342   ) {
1343     if (ChromeUtils.getClassName(anchors) == "XULElement") {
1344       anchors = new Set([anchors]);
1345     }
1347     if (!notifications) {
1348       notifications = this._currentNotifications;
1349     }
1351     let haveNotifications = !!notifications.length;
1352     if (!anchors.size && haveNotifications) {
1353       anchors = this._getAnchorsForNotifications(notifications);
1354     }
1356     let useIconBox = !!this.iconBox;
1357     if (useIconBox && anchors.size) {
1358       for (let anchor of anchors) {
1359         if (anchor.parentNode == this.iconBox) {
1360           continue;
1361         }
1362         useIconBox = false;
1363         break;
1364       }
1365     }
1367     // Filter out notifications that have been dismissed, unless they are
1368     // persistent. Also check if we should not show any notification.
1369     let notificationsToShow = [];
1370     if (!this._suppress) {
1371       notificationsToShow = notifications.filter(
1372         n => (!n.dismissed || n.options.persistent) && !n.options.neverShow
1373       );
1374     }
1376     if (useIconBox) {
1377       // Hide icons of the previous tab.
1378       this._hideIcons();
1379     }
1381     if (haveNotifications) {
1382       // Also filter out notifications that are for a different anchor.
1383       notificationsToShow = notificationsToShow.filter(function(n) {
1384         return anchors.has(n.anchorElement);
1385       });
1387       if (useIconBox) {
1388         this._showIcons(notifications);
1389         this.iconBox.hidden = false;
1390         // Make sure that panels can only be attached to anchors of shown
1391         // notifications inside an iconBox.
1392         anchors = this._getAnchorsForNotifications(notificationsToShow);
1393       } else if (anchors.size) {
1394         this._updateAnchorIcons(notifications, anchors);
1395       }
1396     }
1398     if (notificationsToShow.length) {
1399       let anchorElement = anchors.values().next().value;
1400       if (anchorElement) {
1401         this._showPanel(notificationsToShow, anchorElement);
1402       }
1404       // Setup a capturing event listener on the whole window to catch the
1405       // escape key while persistent notifications are visible.
1406       this.window.addEventListener(
1407         "keypress",
1408         this._handleWindowKeyPress,
1409         true
1410       );
1411     } else {
1412       // Notify observers that we're not showing the popup (useful for testing)
1413       this._notify("updateNotShowing");
1415       // Close the panel if there are no notifications to show.
1416       // When called from PopupNotifications.show() we should never close the
1417       // panel, however. It may just be adding a dismissed notification, in
1418       // which case we want to continue showing any existing notifications.
1419       if (!dismissShowing) {
1420         this._dismiss();
1421       }
1423       // Only hide the iconBox if we actually have no notifications (as opposed
1424       // to not having any showable notifications)
1425       if (!haveNotifications) {
1426         if (useIconBox) {
1427           this.iconBox.hidden = true;
1428         } else if (anchors.size) {
1429           for (let anchorElement of anchors) {
1430             anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
1431           }
1432         }
1433       }
1435       // Stop listening to keyboard events for notifications.
1436       this.window.removeEventListener(
1437         "keypress",
1438         this._handleWindowKeyPress,
1439         true
1440       );
1441     }
1442   },
1444   _updateAnchorIcons: function PopupNotifications_updateAnchorIcons(
1445     notifications,
1446     anchorElements
1447   ) {
1448     for (let anchorElement of anchorElements) {
1449       anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
1450     }
1451   },
1453   _showIcons: function PopupNotifications_showIcons(aCurrentNotifications) {
1454     for (let notification of aCurrentNotifications) {
1455       let anchorElm = notification.anchorElement;
1456       if (anchorElm) {
1457         anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
1459         if (notification.options.extraAttr) {
1460           anchorElm.setAttribute("extraAttr", notification.options.extraAttr);
1461         }
1462       }
1463     }
1464   },
1466   _hideIcons: function PopupNotifications_hideIcons() {
1467     let icons = this.iconBox.querySelectorAll(ICON_SELECTOR);
1468     for (let icon of icons) {
1469       icon.removeAttribute(ICON_ATTRIBUTE_SHOWING);
1470     }
1471   },
1473   /**
1474    * Gets and sets notifications for the browser.
1475    */
1476   _getNotificationsForBrowser: function PopupNotifications_getNotifications(
1477     browser
1478   ) {
1479     let notifications = popupNotificationsMap.get(browser);
1480     if (!notifications) {
1481       // Initialize the WeakMap for the browser so callers can reference/manipulate the array.
1482       notifications = [];
1483       popupNotificationsMap.set(browser, notifications);
1484     }
1485     return notifications;
1486   },
1487   _setNotificationsForBrowser: function PopupNotifications_setNotifications(
1488     browser,
1489     notifications
1490   ) {
1491     popupNotificationsMap.set(browser, notifications);
1492     return notifications;
1493   },
1495   _getAnchorsForNotifications: function PopupNotifications_getAnchorsForNotifications(
1496     notifications,
1497     defaultAnchor
1498   ) {
1499     let anchors = new Set();
1500     for (let notification of notifications) {
1501       if (notification.anchorElement) {
1502         anchors.add(notification.anchorElement);
1503       }
1504     }
1505     if (defaultAnchor && !anchors.size) {
1506       anchors.add(defaultAnchor);
1507     }
1508     return anchors;
1509   },
1511   _isActiveBrowser(browser) {
1512     // We compare on frameLoader instead of just comparing the
1513     // selectedBrowser and browser directly because browser tabs in
1514     // Responsive Design Mode put the actual web content into a
1515     // mozbrowser iframe and proxy property read/write and method
1516     // calls from the tab to that iframe. This is so that attempts
1517     // to reload the tab end up reloading the content in
1518     // Responsive Design Mode, and not the Responsive Design Mode
1519     // viewer itself.
1520     //
1521     // This means that PopupNotifications can come up from a browser
1522     // in Responsive Design Mode, but the selectedBrowser will not match
1523     // the browser being passed into this function, despite the browser
1524     // actually being within the selected tab. We workaround this by
1525     // comparing frameLoader instead, which is proxied from the outer
1526     // <xul:browser> to the inner mozbrowser <iframe>.
1527     return this.tabbrowser.selectedBrowser.frameLoader == browser.frameLoader;
1528   },
1530   _onIconBoxCommand: function PopupNotifications_onIconBoxCommand(event) {
1531     // Left click, space or enter only
1532     let type = event.type;
1533     if (type == "click" && event.button != 0) {
1534       return;
1535     }
1537     if (
1538       type == "keypress" &&
1539       !(
1540         event.charCode == event.DOM_VK_SPACE ||
1541         event.keyCode == event.DOM_VK_RETURN
1542       )
1543     ) {
1544       return;
1545     }
1547     if (!this._currentNotifications.length) {
1548       return;
1549     }
1551     event.stopPropagation();
1553     // Get the anchor that is the immediate child of the icon box
1554     let anchor = event.target;
1555     while (anchor && anchor.parentNode != this.iconBox) {
1556       anchor = anchor.parentNode;
1557     }
1559     if (!anchor) {
1560       return;
1561     }
1563     // If the panel is not closed, and the anchor is different, immediately mark all
1564     // active notifications for the previous anchor as dismissed
1565     if (this.panel.state != "closed" && anchor != this._currentAnchorElement) {
1566       this._dismissOrRemoveCurrentNotifications();
1567     }
1569     // Avoid reshowing notifications that are already shown and have not been dismissed.
1570     if (this.panel.state == "closed" || anchor != this._currentAnchorElement) {
1571       // As soon as the panel is shown, focus the first element in the selected notification.
1572       this.panel.addEventListener(
1573         "popupshown",
1574         () =>
1575           this.window.document.commandDispatcher.advanceFocusIntoSubtree(
1576             this.panel
1577           ),
1578         { once: true }
1579       );
1581       this._reshowNotifications(anchor);
1582     } else {
1583       // Focus the first element in the selected notification.
1584       this.window.document.commandDispatcher.advanceFocusIntoSubtree(
1585         this.panel
1586       );
1587     }
1588   },
1590   _reshowNotifications: function PopupNotifications_reshowNotifications(
1591     anchor,
1592     browser
1593   ) {
1594     // Mark notifications anchored to this anchor as un-dismissed
1595     browser = browser || this.tabbrowser.selectedBrowser;
1596     let notifications = this._getNotificationsForBrowser(browser);
1597     notifications.forEach(function(n) {
1598       if (n.anchorElement == anchor) {
1599         n.dismissed = false;
1600       }
1601     });
1603     if (this._isActiveBrowser(browser)) {
1604       // ...and then show them.
1605       this._update(notifications, anchor);
1606     }
1607   },
1609   _swapBrowserNotifications: function PopupNotifications_swapBrowserNoficications(
1610     ourBrowser,
1611     otherBrowser
1612   ) {
1613     // When swaping browser docshells (e.g. dragging tab to new window) we need
1614     // to update our notification map.
1616     let ourNotifications = this._getNotificationsForBrowser(ourBrowser);
1617     let other = otherBrowser.ownerGlobal.PopupNotifications;
1618     if (!other) {
1619       if (ourNotifications.length) {
1620         Cu.reportError(
1621           "unable to swap notifications: otherBrowser doesn't support notifications"
1622         );
1623       }
1624       return;
1625     }
1626     let otherNotifications = other._getNotificationsForBrowser(otherBrowser);
1627     if (ourNotifications.length < 1 && otherNotifications.length < 1) {
1628       // No notification to swap.
1629       return;
1630     }
1632     otherNotifications = otherNotifications.filter(n => {
1633       if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, ourBrowser)) {
1634         n.browser = ourBrowser;
1635         n.owner = this;
1636         return true;
1637       }
1638       other._fireCallback(
1639         n,
1640         NOTIFICATION_EVENT_REMOVED,
1641         this.nextRemovalReason
1642       );
1643       return false;
1644     });
1646     ourNotifications = ourNotifications.filter(n => {
1647       if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, otherBrowser)) {
1648         n.browser = otherBrowser;
1649         n.owner = other;
1650         return true;
1651       }
1652       this._fireCallback(n, NOTIFICATION_EVENT_REMOVED, this.nextRemovalReason);
1653       return false;
1654     });
1656     this._setNotificationsForBrowser(otherBrowser, ourNotifications);
1657     other._setNotificationsForBrowser(ourBrowser, otherNotifications);
1659     if (otherNotifications.length) {
1660       this._update(otherNotifications);
1661     }
1662     if (ourNotifications.length) {
1663       other._update(ourNotifications);
1664     }
1665   },
1667   _fireCallback: function PopupNotifications_fireCallback(n, event, ...args) {
1668     try {
1669       if (n.options.eventCallback) {
1670         return n.options.eventCallback.call(n, event, ...args);
1671       }
1672     } catch (error) {
1673       Cu.reportError(error);
1674     }
1675     return undefined;
1676   },
1678   _onPopupHidden: function PopupNotifications_onPopupHidden(event) {
1679     if (event.target != this.panel) {
1680       return;
1681     }
1683     // We may have removed the "noautofocus" attribute before showing the panel
1684     // if the notification specified it wants to autofocus on first show.
1685     // When the panel is closed, we have to restore the attribute to its default
1686     // value, so we don't autofocus it if it's subsequently opened from a different code path.
1687     this.panel.setAttribute("noautofocus", "true");
1689     // Handle the case where the panel was closed programmatically.
1690     if (this._ignoreDismissal) {
1691       this._ignoreDismissal.resolve();
1692       this._ignoreDismissal = null;
1693       return;
1694     }
1696     this._dismissOrRemoveCurrentNotifications();
1698     this._clearPanel();
1700     this._update();
1701   },
1703   _dismissOrRemoveCurrentNotifications() {
1704     let browser =
1705       this.panel.firstElementChild &&
1706       this.panel.firstElementChild.notification.browser;
1707     if (!browser) {
1708       return;
1709     }
1711     let notifications = this._getNotificationsForBrowser(browser);
1712     // Mark notifications as dismissed and call dismissal callbacks
1713     for (let nEl of this.panel.children) {
1714       let notificationObj = nEl.notification;
1715       // Never call a dismissal handler on a notification that's been removed.
1716       if (!notifications.includes(notificationObj)) {
1717         return;
1718       }
1720       // Record the time of the first notification dismissal if the main action
1721       // was not triggered in the meantime.
1722       let timeSinceShown =
1723         this.window.performance.now() - notificationObj.timeShown;
1724       if (
1725         !notificationObj.wasDismissed &&
1726         !notificationObj.recordedTelemetryMainAction
1727       ) {
1728         notificationObj._recordTelemetry(
1729           "POPUP_NOTIFICATION_DISMISSAL_MS",
1730           timeSinceShown
1731         );
1732       }
1734       // Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED
1735       // if the notification is removed.
1736       if (notificationObj.options.removeOnDismissal) {
1737         notificationObj._recordTelemetryStat(this.nextRemovalReason);
1738         this._remove(notificationObj);
1739       } else {
1740         notificationObj.dismissed = true;
1741         this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED);
1742       }
1743     }
1744   },
1746   _onCheckboxCommand(event) {
1747     let notificationEl = getNotificationFromElement(event.originalTarget);
1748     let checked = notificationEl.checkbox.checked;
1749     let notification = notificationEl.notification;
1751     // Save checkbox state to be able to persist it when re-opening the doorhanger.
1752     notification._checkboxChecked = checked;
1754     if (checked) {
1755       this._setNotificationUIState(
1756         notificationEl,
1757         notification.options.checkbox.checkedState
1758       );
1759     } else {
1760       this._setNotificationUIState(
1761         notificationEl,
1762         notification.options.checkbox.uncheckedState
1763       );
1764     }
1765     event.stopPropagation();
1766   },
1768   _onCommand(event) {
1769     // Ignore events from buttons as they are submitting and so don't need checks
1770     if (event.originalTarget.localName == "button") {
1771       return;
1772     }
1773     let notificationEl = getNotificationFromElement(event.target);
1775     let notification = notificationEl.notification;
1776     if (!notification.options.checkbox) {
1777       this._setNotificationUIState(notificationEl);
1778       return;
1779     }
1781     if (notificationEl.checkbox.checked) {
1782       this._setNotificationUIState(
1783         notificationEl,
1784         notification.options.checkbox.checkedState
1785       );
1786     } else {
1787       this._setNotificationUIState(
1788         notificationEl,
1789         notification.options.checkbox.uncheckedState
1790       );
1791     }
1792   },
1794   _onButtonEvent(event, type, source = "button", notificationEl = null) {
1795     if (!notificationEl) {
1796       notificationEl = getNotificationFromElement(event.originalTarget);
1797     }
1799     if (!notificationEl) {
1800       throw new Error(
1801         "PopupNotifications._onButtonEvent: couldn't find notification element"
1802       );
1803     }
1805     if (!notificationEl.notification) {
1806       throw new Error(
1807         "PopupNotifications._onButtonEvent: couldn't find notification"
1808       );
1809     }
1811     let notification = notificationEl.notification;
1813     if (type == "dropmarkerpopupshown") {
1814       notification._recordTelemetryStat(TELEMETRY_STAT_OPEN_SUBMENU);
1815       return;
1816     }
1818     if (type == "learnmoreclick") {
1819       notification._recordTelemetryStat(TELEMETRY_STAT_LEARN_MORE);
1820       return;
1821     }
1823     if (type == "buttoncommand") {
1824       // Record the total timing of the main action since the notification was
1825       // created, even if the notification was dismissed in the meantime.
1826       let timeSinceCreated =
1827         this.window.performance.now() - notification.timeCreated;
1828       if (!notification.recordedTelemetryMainAction) {
1829         notification.recordedTelemetryMainAction = true;
1830         notification._recordTelemetry(
1831           "POPUP_NOTIFICATION_MAIN_ACTION_MS",
1832           timeSinceCreated
1833         );
1834       }
1835     }
1837     if (type == "buttoncommand" || type == "secondarybuttoncommand") {
1838       if (Services.focus.activeWindow != this.window) {
1839         Services.console.logStringMessage(
1840           "PopupNotifications._onButtonEvent: " +
1841             "Button click happened before the window was focused"
1842         );
1843         this.window.focus();
1844         return;
1845       }
1847       let timeSinceShown =
1848         this.window.performance.now() - notification.timeShown;
1849       if (timeSinceShown < this.buttonDelay) {
1850         Services.console.logStringMessage(
1851           "PopupNotifications._onButtonEvent: " +
1852             "Button click happened before the security delay: " +
1853             timeSinceShown +
1854             "ms"
1855         );
1856         return;
1857       }
1858     }
1860     let action = notification.mainAction;
1861     let telemetryStatId = TELEMETRY_STAT_ACTION_1;
1863     if (type == "secondarybuttoncommand") {
1864       action = notification.secondaryActions[0];
1865       telemetryStatId = TELEMETRY_STAT_ACTION_2;
1866     }
1868     notification._recordTelemetryStat(telemetryStatId);
1870     if (action) {
1871       try {
1872         action.callback.call(undefined, {
1873           checkboxChecked: notificationEl.checkbox.checked,
1874           source,
1875           event,
1876         });
1877       } catch (error) {
1878         Cu.reportError(error);
1879       }
1881       if (action.dismiss) {
1882         this._dismiss();
1883         return;
1884       }
1885     }
1887     this._remove(notification);
1888     this._update();
1889   },
1891   _onMenuCommand: function PopupNotifications_onMenuCommand(event) {
1892     let target = event.originalTarget;
1893     if (!target.action || !target.notification) {
1894       throw new Error(
1895         "menucommand target has no associated action/notification"
1896       );
1897     }
1899     let notificationEl = getNotificationFromElement(target);
1900     event.stopPropagation();
1902     target.notification._recordTelemetryStat(target.action.telemetryStatId);
1904     try {
1905       target.action.callback.call(undefined, {
1906         checkboxChecked: notificationEl.checkbox.checked,
1907         source: "menucommand",
1908       });
1909     } catch (error) {
1910       Cu.reportError(error);
1911     }
1913     if (target.action.dismiss) {
1914       this._dismiss();
1915       return;
1916     }
1918     this._remove(target.notification);
1919     this._update();
1920   },
1922   _notify: function PopupNotifications_notify(topic) {
1923     Services.obs.notifyObservers(null, "PopupNotifications-" + topic);
1924   },