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