summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/PopupNotifications.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/modules/PopupNotifications.sys.mjs')
-rw-r--r--toolkit/modules/PopupNotifications.sys.mjs2030
1 files changed, 2030 insertions, 0 deletions
diff --git a/toolkit/modules/PopupNotifications.sys.mjs b/toolkit/modules/PopupNotifications.sys.mjs
new file mode 100644
index 0000000000..2f4893a2f6
--- /dev/null
+++ b/toolkit/modules/PopupNotifications.sys.mjs
@@ -0,0 +1,2030 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const NOTIFICATION_EVENT_DISMISSED = "dismissed";
+const NOTIFICATION_EVENT_REMOVED = "removed";
+const NOTIFICATION_EVENT_SHOWING = "showing";
+const NOTIFICATION_EVENT_SHOWN = "shown";
+const NOTIFICATION_EVENT_SWAPPING = "swapping";
+
+const ICON_SELECTOR = ".notification-anchor-icon";
+const ICON_ATTRIBUTE_SHOWING = "showing";
+const ICON_ANCHOR_ATTRIBUTE = "popupnotificationanchor";
+
+const PREF_SECURITY_DELAY = "security.notification_enable_delay";
+const FULLSCREEN_TRANSITION_TIME_SHOWN_OFFSET_MS = 2000;
+
+// Enumerated values for the POPUP_NOTIFICATION_STATS telemetry histogram.
+const TELEMETRY_STAT_OFFERED = 0;
+const TELEMETRY_STAT_ACTION_1 = 1;
+const TELEMETRY_STAT_ACTION_2 = 2;
+// const TELEMETRY_STAT_ACTION_3 = 3;
+const TELEMETRY_STAT_ACTION_LAST = 4;
+// const TELEMETRY_STAT_DISMISSAL_CLICK_ELSEWHERE = 5;
+const TELEMETRY_STAT_REMOVAL_LEAVE_PAGE = 6;
+// const TELEMETRY_STAT_DISMISSAL_CLOSE_BUTTON = 7;
+const TELEMETRY_STAT_OPEN_SUBMENU = 10;
+const TELEMETRY_STAT_LEARN_MORE = 11;
+
+const TELEMETRY_STAT_REOPENED_OFFSET = 20;
+const lazy = {};
+
+XPCOMUtils.defineLazyPreferenceGetter(lazy, "buttonDelay", PREF_SECURITY_DELAY);
+
+var popupNotificationsMap = new WeakMap();
+var gNotificationParents = new WeakMap();
+
+function getAnchorFromBrowser(aBrowser, aAnchorID) {
+ let attrPrefix = aAnchorID ? aAnchorID.replace("notification-icon", "") : "";
+ let anchor =
+ aBrowser.getAttribute(attrPrefix + ICON_ANCHOR_ATTRIBUTE) ||
+ aBrowser[attrPrefix + ICON_ANCHOR_ATTRIBUTE] ||
+ aBrowser.getAttribute(ICON_ANCHOR_ATTRIBUTE) ||
+ aBrowser[ICON_ANCHOR_ATTRIBUTE];
+ if (anchor) {
+ if (ChromeUtils.getClassName(anchor) == "XULElement") {
+ return anchor;
+ }
+ return aBrowser.ownerDocument.getElementById(anchor);
+ }
+ return null;
+}
+
+/**
+ * Given a DOM node inside a <popupnotification>, return the parent <popupnotification>.
+ */
+function getNotificationFromElement(aElement) {
+ return aElement.closest("popupnotification");
+}
+
+/**
+ * Notification object describes a single popup notification.
+ *
+ * @see PopupNotifications.show()
+ */
+function Notification(
+ id,
+ message,
+ anchorID,
+ mainAction,
+ secondaryActions,
+ browser,
+ owner,
+ options
+) {
+ this.id = id;
+ this.message = message;
+ this.anchorID = anchorID;
+ this.mainAction = mainAction;
+ this.secondaryActions = secondaryActions || [];
+ this.browser = browser;
+ this.owner = owner;
+ this.options = options || {};
+
+ this._dismissed = false;
+ // Will become a boolean when manually toggled by the user.
+ this._checkboxChecked = null;
+ this.wasDismissed = false;
+ this.recordedTelemetryStats = new Set();
+ this.isPrivate = PrivateBrowsingUtils.isWindowPrivate(
+ this.browser.ownerGlobal
+ );
+ this.timeCreated = this.owner.window.performance.now();
+}
+
+Notification.prototype = {
+ id: null,
+ message: null,
+ anchorID: null,
+ mainAction: null,
+ secondaryActions: null,
+ browser: null,
+ owner: null,
+ options: null,
+ timeShown: null,
+
+ /**
+ * Indicates whether the notification is currently dismissed.
+ */
+ set dismissed(value) {
+ this._dismissed = value;
+ if (value) {
+ // Keep the dismissal into account when recording telemetry.
+ this.wasDismissed = true;
+ }
+ },
+ get dismissed() {
+ return this._dismissed;
+ },
+
+ /**
+ * Removes the notification and updates the popup accordingly if needed.
+ */
+ remove: function Notification_remove() {
+ this.owner.remove(this);
+ },
+
+ get anchorElement() {
+ let iconBox = this.owner.iconBox;
+
+ let anchorElement = getAnchorFromBrowser(this.browser, this.anchorID);
+ if (!iconBox) {
+ return anchorElement;
+ }
+
+ if (!anchorElement && this.anchorID) {
+ anchorElement = iconBox.querySelector("#" + this.anchorID);
+ }
+
+ // Use a default anchor icon if it's available
+ if (!anchorElement) {
+ anchorElement =
+ iconBox.querySelector("#default-notification-icon") || iconBox;
+ }
+
+ return anchorElement;
+ },
+
+ reshow() {
+ this.owner._reshowNotifications(this.anchorElement, this.browser);
+ },
+
+ /**
+ * Adds a value to the specified histogram, that must be keyed by ID.
+ */
+ _recordTelemetry(histogramId, value) {
+ if (this.isPrivate && !this.options.recordTelemetryInPrivateBrowsing) {
+ // The reason why we don't record telemetry in private windows is because
+ // the available actions can be different from regular mode. The main
+ // difference is that all of the persistent permission options like
+ // "Always remember" aren't there, so they really need to be handled
+ // separately to avoid skewing results. For notifications with the same
+ // choices, there would be no reason not to record in private windows as
+ // well, but it's just simpler to use the same check for everything.
+ return;
+ }
+ let histogram = Services.telemetry.getKeyedHistogramById(histogramId);
+ histogram.add("(all)", value);
+ histogram.add(this.id, value);
+ },
+
+ /**
+ * Adds an enumerated value to the POPUP_NOTIFICATION_STATS histogram,
+ * ensuring that it is recorded at most once for each distinct Notification.
+ *
+ * Statistics for reopened notifications are recorded in separate buckets.
+ *
+ * @param value
+ * One of the TELEMETRY_STAT_ constants.
+ */
+ _recordTelemetryStat(value) {
+ if (this.wasDismissed) {
+ value += TELEMETRY_STAT_REOPENED_OFFSET;
+ }
+ if (!this.recordedTelemetryStats.has(value)) {
+ this.recordedTelemetryStats.add(value);
+ this._recordTelemetry("POPUP_NOTIFICATION_STATS", value);
+ }
+ },
+};
+
+/**
+ * The PopupNotifications object manages popup notifications for a given browser
+ * window.
+ * @param tabbrowser
+ * window's TabBrowser. Used to observe tab switching events and
+ * for determining the active browser element.
+ * @param panel
+ * The <xul:panel/> element to use for notifications. The panel is
+ * populated with <popupnotification> children and displayed it as
+ * needed.
+ * @param iconBox
+ * Reference to a container element that should be hidden or
+ * unhidden when notifications are hidden or shown. It should be the
+ * parent of anchor elements whose IDs are passed to show().
+ * It is used as a fallback popup anchor if notifications specify
+ * invalid or non-existent anchor IDs.
+ * @param options
+ * An optional object with the following optional properties:
+ * {
+ * shouldSuppress:
+ * If this function returns true, then all notifications are
+ * suppressed for this window. This state is checked on construction
+ * and when the "anchorVisibilityChange" method is called.
+ * getVisibleAnchorElement(anchorElement):
+ * A function which takes an anchor element as input and should return
+ * either the anchor if it's visible, a fallback anchor element, or if
+ * no fallback exists, a null element.
+ * }
+ */
+export function PopupNotifications(tabbrowser, panel, iconBox, options = {}) {
+ if (!tabbrowser) {
+ throw new Error("Invalid tabbrowser");
+ }
+ if (iconBox && ChromeUtils.getClassName(iconBox) != "XULElement") {
+ throw new Error("Invalid iconBox");
+ }
+ if (ChromeUtils.getClassName(panel) != "XULPopupElement") {
+ throw new Error("Invalid panel");
+ }
+
+ this._shouldSuppress = options.shouldSuppress || (() => false);
+ this._suppress = this._shouldSuppress();
+
+ this._getVisibleAnchorElement = options.getVisibleAnchorElement;
+
+ this.window = tabbrowser.ownerGlobal;
+ this.panel = panel;
+ this.tabbrowser = tabbrowser;
+ this.iconBox = iconBox;
+
+ // panel itself has a listener in the bubble phase and this listener
+ // needs to be called after that, so use bubble phase here.
+ this.panel.addEventListener("popuphidden", this);
+ this.panel.addEventListener("popuppositioned", this);
+ this.panel.classList.add("popup-notification-panel", "panel-no-padding");
+
+ // This listener will be attached to the chrome window whenever a notification
+ // is showing, to allow the user to dismiss notifications using the escape key.
+ this._handleWindowKeyPress = aEvent => {
+ if (aEvent.keyCode != aEvent.DOM_VK_ESCAPE) {
+ return;
+ }
+
+ // Esc key cancels the topmost notification, if there is one.
+ let notification = this.panel.firstElementChild;
+ if (!notification) {
+ return;
+ }
+
+ let doc = this.window.document;
+ let focusedElement = Services.focus.focusedElement;
+
+ // If the chrome window has a focused element, let it handle the ESC key instead.
+ if (
+ !focusedElement ||
+ focusedElement == doc.body ||
+ focusedElement == this.tabbrowser.selectedBrowser ||
+ // Ignore focused elements inside the notification.
+ notification.contains(focusedElement)
+ ) {
+ let escAction = notification.notification.options.escAction;
+ this._onButtonEvent(aEvent, escAction, "esc-press", notification);
+ // Without this preventDefault call, the event will be sent to the content page
+ // and our event listener might be called again after receiving a reply from
+ // the content process, which could accidentally dismiss another notification.
+ aEvent.preventDefault();
+ }
+ };
+
+ let documentElement = this.window.document.documentElement;
+ let locationBarHidden = documentElement
+ .getAttribute("chromehidden")
+ .includes("location");
+ let isFullscreen = !!this.window.document.fullscreenElement;
+
+ this.panel.setAttribute("followanchor", !locationBarHidden && !isFullscreen);
+
+ // There are no anchor icons in DOM fullscreen mode, but we would
+ // still like to show the popup notification. To avoid an infinite
+ // loop of showing and hiding, we have to disable followanchor
+ // (which hides the element without an anchor) in fullscreen.
+ this.window.addEventListener(
+ "MozDOMFullscreen:Entered",
+ () => {
+ this.panel.setAttribute("followanchor", "false");
+ },
+ true
+ );
+ this.window.addEventListener(
+ "MozDOMFullscreen:Exited",
+ () => {
+ this.panel.setAttribute("followanchor", !locationBarHidden);
+ },
+ true
+ );
+
+ Services.obs.addObserver(this, "fullscreen-transition-start");
+ Services.obs.addObserver(this, "pointer-lock-entered");
+
+ this.window.addEventListener("unload", () => {
+ Services.obs.removeObserver(this, "fullscreen-transition-start");
+ Services.obs.removeObserver(this, "pointer-lock-entered");
+ });
+
+ this.window.addEventListener("activate", this, true);
+ if (this.tabbrowser.tabContainer) {
+ this.tabbrowser.tabContainer.addEventListener("TabSelect", this, true);
+
+ this.tabbrowser.tabContainer.addEventListener("TabClose", aEvent => {
+ // If the tab was just closed and we have notifications associated with it,
+ // then the notifications were closed because of the tab removal. We need to
+ // record this event in telemetry and fire the removal callback.
+ this.nextRemovalReason = TELEMETRY_STAT_REMOVAL_LEAVE_PAGE;
+ let notifications = this._getNotificationsForBrowser(
+ aEvent.target.linkedBrowser
+ );
+ for (let notification of notifications) {
+ this._fireCallback(
+ notification,
+ NOTIFICATION_EVENT_REMOVED,
+ this.nextRemovalReason
+ );
+ notification._recordTelemetryStat(this.nextRemovalReason);
+ }
+ });
+ }
+}
+
+PopupNotifications.prototype = {
+ window: null,
+ panel: null,
+ tabbrowser: null,
+
+ _iconBox: null,
+ set iconBox(iconBox) {
+ // Remove the listeners on the old iconBox, if needed
+ if (this._iconBox) {
+ this._iconBox.removeEventListener("click", this);
+ this._iconBox.removeEventListener("keypress", this);
+ }
+ this._iconBox = iconBox;
+ if (iconBox) {
+ iconBox.addEventListener("click", this);
+ iconBox.addEventListener("keypress", this);
+ }
+ },
+ get iconBox() {
+ return this._iconBox;
+ },
+
+ observe(subject, topic) {
+ // These observers apply to all windows.
+ if (
+ topic == "fullscreen-transition-start" ||
+ topic == "pointer-lock-entered"
+ ) {
+ // Extend security delay if the panel is open.
+ if (this.isPanelOpen) {
+ let notification = this.panel.firstChild?.notification;
+ if (notification) {
+ this._extendSecurityDelay([notification]);
+ }
+ }
+ }
+ },
+
+ /**
+ * Retrieve one or many Notification object/s associated with the browser/ID pair.
+ * @param {string|string[]} id
+ * The Notification ID or an array of IDs to search for.
+ * @param [browser]
+ * The browser whose notifications should be searched. If null, the
+ * currently selected browser's notifications will be searched.
+ *
+ * @returns {Notification|Notification[]|null} If passed a single id, returns the corresponding Notification object, or null if no such
+ * notification exists.
+ * If passed an id array, returns an array of Notification objects which match the ids.
+ */
+ getNotification: function PopupNotifications_getNotification(id, browser) {
+ let notifications = this._getNotificationsForBrowser(
+ browser || this.tabbrowser.selectedBrowser
+ );
+ if (Array.isArray(id)) {
+ return notifications.filter(x => id.includes(x.id));
+ }
+ return notifications.find(x => x.id == id) || null;
+ },
+
+ /**
+ * Adds a new popup notification.
+ * @param browser
+ * The <xul:browser> element associated with the notification. Must not
+ * be null.
+ * @param id
+ * A unique ID that identifies the type of notification (e.g.
+ * "geolocation"). Only one notification with a given ID can be visible
+ * at a time. If a notification already exists with the given ID, it
+ * will be replaced.
+ * @param message
+ * A string containing the text to be displayed as the notification
+ * header. The string may optionally contain one or two "<>" as a
+ * placeholder which is later replaced by a host name or an addon name
+ * that is formatted to look bold, in which case the options.name
+ * property (as well as options.secondName if passing a "<>" and a "{}"
+ * placeholder) needs to be specified. "<>" will be considered as the
+ * first and "{}" as the second placeholder.
+ * @param anchorID
+ * The ID of the element that should be used as this notification
+ * popup's anchor. May be null, in which case the notification will be
+ * anchored to the iconBox.
+ * @param mainAction
+ * A JavaScript object literal describing the notification button's
+ * action. If present, it must have the following properties:
+ * - label (string): the button's label.
+ * - accessKey (string): the button's accessKey.
+ * - callback (function): a callback to be invoked when the button is
+ * pressed, is passed an object that contains the following fields:
+ * - checkboxChecked: (boolean) If the optional checkbox is checked.
+ * - source: (string): the source of the action that initiated the
+ * callback, either:
+ * - "button" if popup buttons were directly activated, or
+ * - "esc-press" if the user pressed the escape key, or
+ * - "menucommand" if a menu was activated.
+ * - [optional] dismiss (boolean): If this is true, the notification
+ * will be dismissed instead of removed after running the callback.
+ * - [optional] disabled (boolean): If this is true, the button
+ * will be disabled.
+ * If null, the notification will have a default "OK" action button
+ * that can be used to dismiss the popup and secondaryActions will be ignored.
+ * @param secondaryActions
+ * An optional JavaScript array describing the notification's alternate
+ * actions. The array should contain objects with the same properties
+ * as mainAction. These are used to populate the notification button's
+ * dropdown menu.
+ * @param options
+ * An options JavaScript object holding additional properties for the
+ * notification. The following properties are currently supported:
+ * persistence: An integer. The notification will not automatically
+ * dismiss for this many page loads.
+ * timeout: A time in milliseconds. The notification will not
+ * automatically dismiss before this time.
+ * persistWhileVisible:
+ * A boolean. If true, a visible notification will always
+ * persist across location changes.
+ * persistent: A boolean. If true, the notification will always
+ * persist even across tab and app changes (but not across
+ * location changes), until the user accepts or rejects
+ * the request. The notification will never be implicitly
+ * dismissed.
+ * dismissed: Whether the notification should be added as a dismissed
+ * notification. Dismissed notifications can be activated
+ * by clicking on their anchorElement.
+ * autofocus: Whether the notification should be autofocused on
+ * showing, stealing focus from any other focused element.
+ * eventCallback:
+ * Callback to be invoked when the notification changes
+ * state. The callback's first argument is a string
+ * identifying the state change:
+ * "dismissed": notification has been dismissed by the
+ * user (e.g. by clicking away or switching
+ * tabs)
+ * "removed": notification has been removed (due to
+ * location change or user action)
+ * "showing": notification is about to be shown
+ * (this can be fired multiple times as
+ * notifications are dismissed and re-shown)
+ * If the callback returns true, the notification
+ * will be dismissed.
+ * "shown": notification has been shown (this can be fired
+ * multiple times as notifications are dismissed
+ * and re-shown)
+ * "swapping": the docshell of the browser that created
+ * the notification is about to be swapped to
+ * another browser. A second parameter contains
+ * the browser that is receiving the docshell,
+ * so that the event callback can transfer stuff
+ * specific to this notification.
+ * If the callback returns true, the notification
+ * will be moved to the new browser.
+ * If the callback isn't implemented, returns false,
+ * or doesn't return any value, the notification
+ * will be removed.
+ * neverShow: Indicate that no popup should be shown for this
+ * notification. Useful for just showing the anchor icon.
+ * removeOnDismissal:
+ * Notifications with this parameter set to true will be
+ * removed when they would have otherwise been dismissed
+ * (i.e. any time the popup is closed due to user
+ * interaction).
+ * hideClose: Indicate that the little close button in the corner of
+ * the panel should be hidden.
+ * checkbox: An object that allows you to add a checkbox and
+ * control its behavior with these fields:
+ * label:
+ * (required) Label to be shown next to the checkbox.
+ * checked:
+ * (optional) Whether the checkbox should be checked
+ * by default. Defaults to false.
+ * checkedState:
+ * (optional) An object that allows you to customize
+ * the notification state when the checkbox is checked.
+ * disableMainAction:
+ * (optional) Whether the mainAction is disabled.
+ * Defaults to false.
+ * warningLabel:
+ * (optional) A (warning) text that is shown below the
+ * checkbox. Pass null to hide.
+ * uncheckedState:
+ * (optional) An object that allows you to customize
+ * the notification state when the checkbox is not checked.
+ * Has the same attributes as checkedState.
+ * popupIconClass:
+ * A string. A class (or space separated list of classes)
+ * that will be applied to the icon in the popup so that
+ * several notifications using the same panel can use
+ * different icons.
+ * popupIconURL:
+ * A string. URL of the image to be displayed in the popup.
+ * learnMoreURL:
+ * A string URL. Setting this property will make the
+ * prompt display a "Learn More" link that, when clicked,
+ * opens the URL in a new tab.
+ * displayURI:
+ * The nsIURI of the page the notification came
+ * from. If present, this will be displayed above the message.
+ * If the nsIURI represents a file, the path will be displayed,
+ * otherwise the hostPort will be displayed.
+ * name:
+ * An optional string formatted to look bold and used in the
+ * notifiation description header text. Usually a host name or
+ * addon name.
+ * secondName:
+ * An optional string formatted to look bold and used in the
+ * notification description header text. Usually a host name or
+ * addon name. This is similar to name, and only used in case
+ * where message contains a "<>" and a "{}" placeholder. "<>"
+ * is considered the first and "{}" is considered the second
+ * placeholder.
+ * escAction:
+ * An optional string indicating the action to take when the
+ * Esc key is pressed. This should be set to the name of the
+ * command to run. If not provided, "secondarybuttoncommand"
+ * will be used.
+ * extraAttr:
+ * An optional string value which will be given to the
+ * extraAttr attribute on the notification's anchorElement
+ * popupOptions:
+ * An optional object containing popup options passed to
+ * `openPopup()` when defined.
+ * recordTelemetryInPrivateBrowsing:
+ * An optional boolean indicating whether popup telemetry
+ * should be recorded in private browsing windows. By default,
+ * telemetry is NOT recorded in PBM, because the available
+ * options for persistent permission notifications are
+ * different between normal and PBM windows, potentially
+ * skewing the data. But for notifications that do not differ
+ * in PBM, this option can be used to ensure that popups in
+ * both PBM and normal windows record the same interactions.
+ * @returns the Notification object corresponding to the added notification.
+ */
+ show: function PopupNotifications_show(
+ browser,
+ id,
+ message,
+ anchorID,
+ mainAction,
+ secondaryActions,
+ options
+ ) {
+ function isInvalidAction(a) {
+ return (
+ !a || !(typeof a.callback == "function") || !a.label || !a.accessKey
+ );
+ }
+
+ if (!browser) {
+ throw new Error("PopupNotifications_show: invalid browser");
+ }
+ if (!id) {
+ throw new Error("PopupNotifications_show: invalid ID");
+ }
+ if (mainAction && isInvalidAction(mainAction)) {
+ throw new Error("PopupNotifications_show: invalid mainAction");
+ }
+ if (secondaryActions && secondaryActions.some(isInvalidAction)) {
+ throw new Error("PopupNotifications_show: invalid secondaryActions");
+ }
+
+ let notification = new Notification(
+ id,
+ message,
+ anchorID,
+ mainAction,
+ secondaryActions,
+ browser,
+ this,
+ options
+ );
+
+ if (options) {
+ let escAction = options.escAction;
+ if (
+ escAction != "buttoncommand" &&
+ escAction != "secondarybuttoncommand"
+ ) {
+ escAction = "secondarybuttoncommand";
+ }
+ notification.options.escAction = escAction;
+ }
+
+ if (options && options.dismissed) {
+ notification.dismissed = true;
+ }
+
+ let existingNotification = this.getNotification(id, browser);
+ if (existingNotification) {
+ this._remove(existingNotification);
+ }
+
+ let notifications = this._getNotificationsForBrowser(browser);
+ notifications.push(notification);
+
+ let isActiveBrowser = this._isActiveBrowser(browser);
+ let isActiveWindow = Services.focus.activeWindow == this.window;
+
+ if (isActiveBrowser) {
+ if (isActiveWindow) {
+ // Autofocus if the notification requests focus.
+ if (options && !options.dismissed && options.autofocus) {
+ this.panel.removeAttribute("noautofocus");
+ } else {
+ this.panel.setAttribute("noautofocus", "true");
+ }
+
+ // show panel now
+ this._update(
+ notifications,
+ new Set([notification.anchorElement]),
+ true
+ );
+ } else {
+ // indicate attention and update the icon if necessary
+ if (!notification.dismissed) {
+ this.window.getAttention();
+ }
+ this._updateAnchorIcons(
+ notifications,
+ this._getAnchorsForNotifications(
+ notifications,
+ notification.anchorElement
+ )
+ );
+ this._notify("backgroundShow");
+ }
+ } else {
+ // Notify observers that we're not showing the popup (useful for testing)
+ this._notify("backgroundShow");
+ }
+
+ return notification;
+ },
+
+ /**
+ * Returns true if the notification popup is currently being displayed.
+ */
+ get isPanelOpen() {
+ let panelState = this.panel.state;
+
+ return panelState == "showing" || panelState == "open";
+ },
+
+ /**
+ * Called by the consumer to indicate that the open panel should
+ * temporarily be hidden while the given panel is showing.
+ */
+ suppressWhileOpen(panel) {
+ this._hidePanel().catch(console.error);
+ panel.addEventListener("popuphidden", aEvent => {
+ this._update();
+ });
+ },
+
+ /**
+ * Called by the consumer to indicate that a browser's location has changed,
+ * so that we can update the active notifications accordingly.
+ */
+ locationChange: function PopupNotifications_locationChange(aBrowser) {
+ if (!aBrowser) {
+ throw new Error("PopupNotifications_locationChange: invalid browser");
+ }
+
+ let notifications = this._getNotificationsForBrowser(aBrowser);
+
+ this.nextRemovalReason = TELEMETRY_STAT_REMOVAL_LEAVE_PAGE;
+
+ notifications = notifications.filter(function (notification) {
+ // The persistWhileVisible option allows an open notification to persist
+ // across location changes
+ if (notification.options.persistWhileVisible && this.isPanelOpen) {
+ if (
+ "persistence" in notification.options &&
+ notification.options.persistence
+ ) {
+ notification.options.persistence--;
+ }
+ return true;
+ }
+
+ // The persistence option allows a notification to persist across multiple
+ // page loads
+ if (
+ "persistence" in notification.options &&
+ notification.options.persistence
+ ) {
+ notification.options.persistence--;
+ return true;
+ }
+
+ // The timeout option allows a notification to persist until a certain time
+ if (
+ "timeout" in notification.options &&
+ Date.now() <= notification.options.timeout
+ ) {
+ return true;
+ }
+
+ notification._recordTelemetryStat(this.nextRemovalReason);
+ this._fireCallback(
+ notification,
+ NOTIFICATION_EVENT_REMOVED,
+ this.nextRemovalReason
+ );
+ return false;
+ }, this);
+
+ this._setNotificationsForBrowser(aBrowser, notifications);
+
+ if (this._isActiveBrowser(aBrowser)) {
+ this.anchorVisibilityChange();
+ }
+ },
+
+ /**
+ * Called by the consumer to indicate that the visibility of the notification
+ * anchors may have changed, but the location has not changed. This also
+ * checks whether all notifications are suppressed for this window.
+ *
+ * Calling this method may result in the "showing" and "shown" events for
+ * visible notifications to be invoked even if the anchor has not changed.
+ */
+ anchorVisibilityChange() {
+ let suppress = this._shouldSuppress();
+ if (!suppress) {
+ // If notifications are not suppressed, always update the visibility.
+ this._suppress = false;
+ let notifications = this._getNotificationsForBrowser(
+ this.tabbrowser.selectedBrowser
+ );
+ this._update(
+ notifications,
+ this._getAnchorsForNotifications(
+ notifications,
+ getAnchorFromBrowser(this.tabbrowser.selectedBrowser)
+ )
+ );
+ return;
+ }
+
+ // Notifications are suppressed, ensure that the panel is hidden.
+ if (!this._suppress) {
+ this._suppress = true;
+ this._hidePanel().catch(console.error);
+ }
+ },
+
+ /**
+ * Removes one or many Notifications.
+ * @param {Notification|Notification[]} notification - The Notification object/s to remove.
+ * @param {Boolean} [isCancel] - Whether to signal, in the notification event, that removal
+ * should be treated as cancel. This is currently used to cancel permission requests
+ * when their Notifications are removed.
+ */
+ remove: function PopupNotifications_remove(notification, isCancel = false) {
+ let notificationArray = Array.isArray(notification)
+ ? notification
+ : [notification];
+ let activeBrowser;
+
+ notificationArray.forEach(n => {
+ this._remove(n, isCancel);
+ if (!activeBrowser && this._isActiveBrowser(n.browser)) {
+ activeBrowser = n.browser;
+ }
+ });
+
+ if (activeBrowser) {
+ let browserNotifications =
+ this._getNotificationsForBrowser(activeBrowser);
+ this._update(browserNotifications);
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "popuphidden":
+ this._onPopupHidden(aEvent);
+ break;
+ case "activate":
+ case "popuppositioned":
+ if (this.isPanelOpen) {
+ for (let elt of this.panel.children) {
+ elt.notification.timeShown = Math.max(
+ this.window.performance.now(),
+ elt.notification.timeShown ?? 0
+ );
+ }
+ break;
+ }
+ // fall through
+ case "TabSelect":
+ // setTimeout(..., 0) needed, otherwise openPopup from "activate" event
+ // handler results in the popup being hidden again for some reason...
+ this.window.setTimeout(() => {
+ this._suppress = this._shouldSuppress();
+ this._update();
+ }, 0);
+ break;
+ case "click":
+ case "keypress":
+ this._onIconBoxCommand(aEvent);
+ break;
+ }
+ },
+
+ // Utility methods
+
+ _ignoreDismissal: null,
+ _currentAnchorElement: null,
+
+ /**
+ * Gets notifications for the currently selected browser.
+ */
+ get _currentNotifications() {
+ return this.tabbrowser.selectedBrowser
+ ? this._getNotificationsForBrowser(this.tabbrowser.selectedBrowser)
+ : [];
+ },
+
+ _remove: function PopupNotifications_removeHelper(
+ notification,
+ isCancel = false
+ ) {
+ // This notification may already be removed, in which case let's just fail
+ // silently.
+ let notifications = this._getNotificationsForBrowser(notification.browser);
+ if (!notifications) {
+ return;
+ }
+
+ var index = notifications.indexOf(notification);
+ if (index == -1) {
+ return;
+ }
+
+ if (this._isActiveBrowser(notification.browser)) {
+ notification.anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
+ }
+
+ // remove the notification
+ notifications.splice(index, 1);
+ this._fireCallback(
+ notification,
+ NOTIFICATION_EVENT_REMOVED,
+ this.nextRemovalReason,
+ isCancel
+ );
+ },
+
+ /**
+ * Dismisses the notification without removing it.
+ *
+ * @param {Event} the event associated with the user interaction that
+ * caused the dismissal
+ * @param {boolean} whether to disable persistent status. Normally,
+ * persistent prompts can not be dismissed. You can
+ * use this argument to force dismissal.
+ */
+ _dismiss: function PopupNotifications_dismiss(
+ event,
+ disablePersistent = false
+ ) {
+ if (disablePersistent) {
+ let notificationEl = getNotificationFromElement(event.target);
+ if (notificationEl) {
+ notificationEl.notification.options.persistent = false;
+ }
+ }
+
+ let browser =
+ this.panel.firstElementChild &&
+ this.panel.firstElementChild.notification.browser;
+ this.panel.hidePopup();
+ if (browser) {
+ browser.focus();
+ }
+ },
+
+ /**
+ * Hides the notification popup.
+ */
+ _hidePanel: function PopupNotifications_hide() {
+ if (this.panel.state == "closed") {
+ return Promise.resolve();
+ }
+ if (this._ignoreDismissal) {
+ return this._ignoreDismissal.promise;
+ }
+ let deferred = Promise.withResolvers();
+ this._ignoreDismissal = deferred;
+ this.panel.hidePopup();
+ return deferred.promise;
+ },
+
+ /**
+ * Removes all notifications from the notification popup.
+ */
+ _clearPanel() {
+ let popupnotification;
+ while ((popupnotification = this.panel.lastElementChild)) {
+ this.panel.removeChild(popupnotification);
+
+ // If this notification was provided by the chrome document rather than
+ // created ad hoc, move it back to where we got it from.
+ let originalParent = gNotificationParents.get(popupnotification);
+ if (originalParent) {
+ popupnotification.notification = null;
+
+ // Re-hide the notification such that it isn't rendered in the chrome
+ // document. _refreshPanel will unhide it again when needed.
+ popupnotification.hidden = true;
+
+ originalParent.appendChild(popupnotification);
+ }
+ }
+ },
+
+ /**
+ * Formats the notification description message before we display it
+ * and splits it into three parts if the message contains "<>" as
+ * placeholder.
+ *
+ * param notification
+ * The Notification object which contains the message to format.
+ *
+ * @returns a Javascript object that has the following properties:
+ * start: A start label string containing the first part of the message.
+ * It may contain the whole string if the description message
+ * does not have "<>" as a placeholder. For example, local
+ * file URIs with description messages that don't display hostnames.
+ * name: A string that is formatted to look bold. It replaces the
+ * placeholder with the options.name property from the notification
+ * object which is usually an addon name or a host name.
+ * end: The last part of the description message.
+ */
+ _formatDescriptionMessage(n) {
+ let text = {};
+ let array = n.message.split(/<>|{}/);
+ text.start = array[0] || "";
+ text.name = n.options.name || "";
+ text.end = array[1] || "";
+ if (array.length == 3) {
+ text.secondName = n.options.secondName || "";
+ text.secondEnd = array[2] || "";
+
+ // name and secondName should be in logical positions. Swap them in case
+ // the second placeholder came before the first one in the original string.
+ if (n.message.indexOf("{}") < n.message.indexOf("<>")) {
+ let tmp = text.name;
+ text.name = text.secondName;
+ text.secondName = tmp;
+ }
+ } else if (array.length > 3) {
+ console.error(
+ "Unexpected array length encountered in " +
+ "_formatDescriptionMessage: ",
+ array.length
+ );
+ }
+ return text;
+ },
+
+ _refreshPanel: function PopupNotifications_refreshPanel(notificationsToShow) {
+ this._clearPanel();
+
+ notificationsToShow.forEach(function (n) {
+ let doc = this.window.document;
+
+ // Append "-notification" to the ID to try to avoid ID conflicts with other stuff
+ // in the document.
+ let popupnotificationID = n.id + "-notification";
+
+ // If the chrome document provides a popupnotification with this id, use
+ // that. Otherwise create it ad-hoc.
+ let popupnotification = doc.getElementById(popupnotificationID);
+ if (popupnotification) {
+ gNotificationParents.set(
+ popupnotification,
+ popupnotification.parentNode
+ );
+ } else {
+ popupnotification = doc.createXULElement("popupnotification");
+ }
+
+ // Create the notification description element.
+ let desc = this._formatDescriptionMessage(n);
+ popupnotification.setAttribute("label", desc.start);
+ popupnotification.setAttribute("name", desc.name);
+ popupnotification.setAttribute("endlabel", desc.end);
+ if ("secondName" in desc && "secondEnd" in desc) {
+ popupnotification.setAttribute("secondname", desc.secondName);
+ popupnotification.setAttribute("secondendlabel", desc.secondEnd);
+ } else {
+ popupnotification.removeAttribute("secondname");
+ popupnotification.removeAttribute("secondendlabel");
+ }
+
+ if (n.options.hintText) {
+ popupnotification.setAttribute("hinttext", n.options.hintText);
+ } else {
+ popupnotification.removeAttribute("hinttext");
+ }
+
+ popupnotification.setAttribute("id", popupnotificationID);
+ popupnotification.setAttribute("popupid", n.id);
+ popupnotification.setAttribute(
+ "oncommand",
+ "PopupNotifications._onCommand(event);"
+ );
+ popupnotification.setAttribute(
+ "closebuttoncommand",
+ `PopupNotifications._dismiss(event, true);`
+ );
+
+ popupnotification.toggleAttribute(
+ "hasicon",
+ !!(n.options.popupIconURL || n.options.popupIconClass)
+ );
+
+ if (n.mainAction) {
+ popupnotification.setAttribute("buttonlabel", n.mainAction.label);
+ popupnotification.setAttribute(
+ "buttonaccesskey",
+ n.mainAction.accessKey
+ );
+ popupnotification.setAttribute(
+ "buttoncommand",
+ "PopupNotifications._onButtonEvent(event, 'buttoncommand');"
+ );
+ popupnotification.setAttribute(
+ "dropmarkerpopupshown",
+ "PopupNotifications._onButtonEvent(event, 'dropmarkerpopupshown');"
+ );
+ popupnotification.setAttribute(
+ "learnmoreclick",
+ "PopupNotifications._onButtonEvent(event, 'learnmoreclick');"
+ );
+ popupnotification.setAttribute(
+ "menucommand",
+ "PopupNotifications._onMenuCommand(event);"
+ );
+ } else {
+ // Enable the default button to let the user close the popup if the close button is hidden
+ popupnotification.setAttribute(
+ "buttoncommand",
+ "PopupNotifications._onButtonEvent(event, 'buttoncommand');"
+ );
+ popupnotification.toggleAttribute("buttonhighlight", true);
+ popupnotification.removeAttribute("buttonlabel");
+ popupnotification.removeAttribute("buttonaccesskey");
+ popupnotification.removeAttribute("dropmarkerpopupshown");
+ popupnotification.removeAttribute("learnmoreclick");
+ popupnotification.removeAttribute("menucommand");
+ }
+
+ let classes = "popup-notification-icon";
+ if (n.options.popupIconClass) {
+ classes += " " + n.options.popupIconClass;
+ }
+ popupnotification.setAttribute("iconclass", classes);
+
+ if (n.options.popupIconURL) {
+ popupnotification.setAttribute("icon", n.options.popupIconURL);
+ } else {
+ popupnotification.removeAttribute("icon");
+ }
+
+ if (n.options.learnMoreURL) {
+ popupnotification.setAttribute("learnmoreurl", n.options.learnMoreURL);
+ } else {
+ popupnotification.removeAttribute("learnmoreurl");
+ }
+
+ if (n.options.displayURI) {
+ let uri;
+ try {
+ if (n.options.displayURI instanceof Ci.nsIFileURL) {
+ uri = n.options.displayURI.pathQueryRef;
+ } else {
+ try {
+ uri = n.options.displayURI.hostPort;
+ } catch (e) {
+ uri = n.options.displayURI.spec;
+ }
+ }
+ popupnotification.setAttribute("origin", uri);
+ } catch (e) {
+ console.error(e);
+ popupnotification.removeAttribute("origin");
+ }
+ } else {
+ popupnotification.removeAttribute("origin");
+ }
+
+ if (n.options.hideClose) {
+ popupnotification.setAttribute("closebuttonhidden", "true");
+ }
+
+ popupnotification.notification = n;
+ let menuitems = [];
+
+ if (n.mainAction && n.secondaryActions && n.secondaryActions.length) {
+ let telemetryStatId = TELEMETRY_STAT_ACTION_2;
+
+ let secondaryAction = n.secondaryActions[0];
+ popupnotification.setAttribute(
+ "secondarybuttonlabel",
+ secondaryAction.label
+ );
+ popupnotification.setAttribute(
+ "secondarybuttonaccesskey",
+ secondaryAction.accessKey
+ );
+ popupnotification.setAttribute(
+ "secondarybuttoncommand",
+ "PopupNotifications._onButtonEvent(event, 'secondarybuttoncommand');"
+ );
+
+ for (let i = 1; i < n.secondaryActions.length; i++) {
+ let action = n.secondaryActions[i];
+ let item = doc.createXULElement("menuitem");
+ item.setAttribute("label", action.label);
+ item.setAttribute("accesskey", action.accessKey);
+ item.notification = n;
+ item.action = action;
+
+ menuitems.push(item);
+
+ // We can only record a limited number of actions in telemetry. If
+ // there are more, the latest are all recorded in the last bucket.
+ item.action.telemetryStatId = telemetryStatId;
+ if (telemetryStatId < TELEMETRY_STAT_ACTION_LAST) {
+ telemetryStatId++;
+ }
+ }
+ popupnotification.setAttribute("secondarybuttonhidden", "false");
+ } else {
+ popupnotification.setAttribute("secondarybuttonhidden", "true");
+ }
+ popupnotification.setAttribute(
+ "dropmarkerhidden",
+ n.secondaryActions.length < 2 ? "true" : "false"
+ );
+
+ let checkbox = n.options.checkbox;
+ if (checkbox && checkbox.label) {
+ let checked =
+ n._checkboxChecked != null ? n._checkboxChecked : !!checkbox.checked;
+ popupnotification.checkboxState = {
+ checked,
+ label: checkbox.label,
+ };
+
+ if (checked) {
+ this._setNotificationUIState(
+ popupnotification,
+ checkbox.checkedState
+ );
+ } else {
+ this._setNotificationUIState(
+ popupnotification,
+ checkbox.uncheckedState
+ );
+ }
+ } else {
+ popupnotification.checkboxState = null;
+ // Reset the UI state to avoid previous state bleeding into this prompt.
+ this._setNotificationUIState(popupnotification);
+ }
+
+ this.panel.appendChild(popupnotification);
+
+ // The popupnotification may be hidden if we got it from the chrome
+ // document rather than creating it ad hoc.
+ popupnotification.show();
+
+ popupnotification.menupopup.textContent = "";
+ popupnotification.menupopup.append(...menuitems);
+ }, this);
+ },
+
+ _setNotificationUIState(notification, state = {}) {
+ let mainAction = notification.notification.mainAction;
+ if (
+ (mainAction && mainAction.disabled) ||
+ state.disableMainAction ||
+ notification.hasAttribute("invalidselection")
+ ) {
+ notification.setAttribute("mainactiondisabled", "true");
+ } else {
+ notification.removeAttribute("mainactiondisabled");
+ }
+ if (state.warningLabel) {
+ notification.setAttribute("warninglabel", state.warningLabel);
+ notification.removeAttribute("warninghidden");
+ } else {
+ notification.setAttribute("warninghidden", "true");
+ }
+ },
+
+ _extendSecurityDelay(notifications) {
+ let now = this.window.performance.now();
+ notifications.forEach(n => {
+ n.timeShown = now + FULLSCREEN_TRANSITION_TIME_SHOWN_OFFSET_MS;
+ });
+ },
+
+ _showPanel: function PopupNotifications_showPanel(
+ notificationsToShow,
+ anchorElement
+ ) {
+ this.panel.hidden = false;
+
+ notificationsToShow = notificationsToShow.filter(n => {
+ if (anchorElement != n.anchorElement) {
+ return false;
+ }
+
+ let dismiss = this._fireCallback(n, NOTIFICATION_EVENT_SHOWING);
+ if (dismiss) {
+ n.dismissed = true;
+ }
+ return !dismiss;
+ });
+ if (!notificationsToShow.length) {
+ return;
+ }
+
+ let notificationIds = notificationsToShow.map(n => n.id);
+
+ this._refreshPanel(notificationsToShow);
+
+ // The element the PopupNotification should anchor to might not be visible.
+ // Check its visibility using a callback that returns the same anchor
+ // element if its visible, or a fallback option that is visible.
+ // If no fallbacks are visible, it should return null.
+ if (this._getVisibleAnchorElement) {
+ anchorElement = this._getVisibleAnchorElement(anchorElement);
+ }
+ // In case _getVisibleAnchorElement provided a non-visible element.
+ if (!anchorElement?.checkVisibility()) {
+ // We only ever show notifications for the current browser,
+ // so we can just use the current tab.
+ anchorElement = this.tabbrowser.selectedTab;
+ if (!anchorElement?.checkVisibility()) {
+ // If we're in an entirely chromeless environment, set the anchorElement
+ // to null and let openPopup show the notification at (0,0) later.
+ anchorElement = null;
+ }
+ }
+
+ // Remember the time the notification was shown for the security delay.
+ notificationsToShow.forEach(
+ n =>
+ (n.timeShown = Math.max(
+ this.window.performance.now(),
+ n.timeShown ?? 0
+ ))
+ );
+
+ if (this.isPanelOpen && this._currentAnchorElement == anchorElement) {
+ notificationsToShow.forEach(function (n) {
+ this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
+ }, this);
+
+ // Make sure we update the noautohide attribute on the panel, in case it changed.
+ if (notificationsToShow.some(n => n.options.persistent)) {
+ this.panel.setAttribute("noautohide", "true");
+ } else {
+ this.panel.removeAttribute("noautohide");
+ }
+
+ // Let tests know that the panel was updated and what notifications it was
+ // updated with so that tests can wait for the correct notifications to be
+ // added.
+ let event = new this.window.CustomEvent("PanelUpdated", {
+ detail: notificationIds,
+ });
+ this.panel.dispatchEvent(event);
+ return;
+ }
+
+ // If the panel is already open but we're changing anchors, we need to hide
+ // it first. Otherwise it can appear in the wrong spot. (_hidePanel is
+ // safe to call even if the panel is already hidden.)
+ this._hidePanel().then(() => {
+ this._currentAnchorElement = anchorElement;
+
+ if (notificationsToShow.some(n => n.options.persistent)) {
+ this.panel.setAttribute("noautohide", "true");
+ } else {
+ this.panel.removeAttribute("noautohide");
+ }
+
+ notificationsToShow.forEach(function (n) {
+ // Record that the notification was actually displayed on screen.
+ // Notifications that were opened a second time or that were originally
+ // shown with "options.dismissed" will be recorded in a separate bucket.
+ n._recordTelemetryStat(TELEMETRY_STAT_OFFERED);
+ }, this);
+
+ // We're about to open the panel while in a full screen transition or
+ // during pointer lock. Extend the security delay to avoid clickjacking.
+ if (
+ this.window.isInFullScreenTransition ||
+ this.window.PointerLock?.isActive
+ ) {
+ this._extendSecurityDelay(notificationsToShow);
+ }
+
+ let target = this.panel;
+ if (target.parentNode) {
+ // NOTIFICATION_EVENT_SHOWN should be fired for the panel before
+ // anyone listening for popupshown on the panel gets run. Otherwise,
+ // the panel will not be initialized when the popupshown event
+ // listeners run.
+ // By targeting the panel's parent and using a capturing listener, we
+ // can have our listener called before others waiting for the panel to
+ // be shown (which probably expect the panel to be fully initialized)
+ target = target.parentNode;
+ }
+ if (this._popupshownListener) {
+ target.removeEventListener(
+ "popupshown",
+ this._popupshownListener,
+ true
+ );
+ }
+ this._popupshownListener = function (e) {
+ target.removeEventListener(
+ "popupshown",
+ this._popupshownListener,
+ true
+ );
+ this._popupshownListener = null;
+
+ notificationsToShow.forEach(function (n) {
+ this._fireCallback(n, NOTIFICATION_EVENT_SHOWN);
+ }, this);
+ // These notifications are used by tests to know when all the processing
+ // required to display the panel has happened.
+ this.panel.dispatchEvent(new this.window.CustomEvent("Shown"));
+ let event = new this.window.CustomEvent("PanelUpdated", {
+ detail: notificationIds,
+ });
+ this.panel.dispatchEvent(event);
+ };
+ this._popupshownListener = this._popupshownListener.bind(this);
+ target.addEventListener("popupshown", this._popupshownListener, true);
+
+ let popupOptions = notificationsToShow.findLast(
+ n => n.options?.popupOptions
+ )?.options?.popupOptions;
+ if (popupOptions) {
+ this.panel.openPopup(anchorElement, popupOptions);
+ } else {
+ this.panel.openPopup(anchorElement, "bottomleft topleft", 0, 0);
+ }
+ });
+ },
+
+ /**
+ * Updates the notification state in response to window activation or tab
+ * selection changes.
+ *
+ * @param notifications an array of Notification instances. if null,
+ * notifications will be retrieved off the current
+ * browser tab
+ * @param anchors is a XUL element or a Set of XUL elements that the
+ * notifications panel(s) will be anchored to.
+ * @param dismissShowing if true, dismiss any currently visible notifications
+ * if there are no notifications to show. Otherwise,
+ * currently displayed notifications will be left alone.
+ */
+ _update: function PopupNotifications_update(
+ notifications,
+ anchors = new Set(),
+ dismissShowing = false
+ ) {
+ if (ChromeUtils.getClassName(anchors) == "XULElement") {
+ anchors = new Set([anchors]);
+ }
+
+ if (!notifications) {
+ notifications = this._currentNotifications;
+ }
+
+ let haveNotifications = !!notifications.length;
+ if (!anchors.size && haveNotifications) {
+ anchors = this._getAnchorsForNotifications(notifications);
+ }
+
+ let useIconBox = !!this.iconBox;
+ if (useIconBox && anchors.size) {
+ for (let anchor of anchors) {
+ if (anchor.parentNode == this.iconBox) {
+ continue;
+ }
+ useIconBox = false;
+ break;
+ }
+ }
+
+ // Filter out notifications that have been dismissed, unless they are
+ // persistent. Also check if we should not show any notification.
+ let notificationsToShow = [];
+ if (!this._suppress) {
+ notificationsToShow = notifications.filter(
+ n => (!n.dismissed || n.options.persistent) && !n.options.neverShow
+ );
+ }
+
+ if (useIconBox) {
+ // Hide icons of the previous tab.
+ this._hideIcons();
+ }
+
+ if (haveNotifications) {
+ // Also filter out notifications that are for a different anchor.
+ notificationsToShow = notificationsToShow.filter(function (n) {
+ return anchors.has(n.anchorElement);
+ });
+
+ if (useIconBox) {
+ this._showIcons(notifications);
+ this.iconBox.hidden = false;
+ // Make sure that panels can only be attached to anchors of shown
+ // notifications inside an iconBox.
+ anchors = this._getAnchorsForNotifications(notificationsToShow);
+ } else if (anchors.size) {
+ this._updateAnchorIcons(notifications, anchors);
+ }
+ }
+
+ if (notificationsToShow.length) {
+ let anchorElement = anchors.values().next().value;
+ if (anchorElement) {
+ this._showPanel(notificationsToShow, anchorElement);
+ }
+
+ // Setup a capturing event listener on the whole window to catch the
+ // escape key while persistent notifications are visible.
+ this.window.addEventListener(
+ "keypress",
+ this._handleWindowKeyPress,
+ true
+ );
+ } else {
+ // Notify observers that we're not showing the popup (useful for testing)
+ this._notify("updateNotShowing");
+
+ // Close the panel if there are no notifications to show.
+ // When called from PopupNotifications.show() we should never close the
+ // panel, however. It may just be adding a dismissed notification, in
+ // which case we want to continue showing any existing notifications.
+ if (!dismissShowing) {
+ this._dismiss();
+ }
+
+ // Only hide the iconBox if we actually have no notifications (as opposed
+ // to not having any showable notifications)
+ if (!haveNotifications) {
+ if (useIconBox) {
+ this.iconBox.hidden = true;
+ } else if (anchors.size) {
+ for (let anchorElement of anchors) {
+ anchorElement.removeAttribute(ICON_ATTRIBUTE_SHOWING);
+ }
+ }
+ }
+
+ // Stop listening to keyboard events for notifications.
+ this.window.removeEventListener(
+ "keypress",
+ this._handleWindowKeyPress,
+ true
+ );
+ }
+ },
+
+ _updateAnchorIcons: function PopupNotifications_updateAnchorIcons(
+ notifications,
+ anchorElements
+ ) {
+ for (let anchorElement of anchorElements) {
+ anchorElement.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
+ }
+ },
+
+ _showIcons: function PopupNotifications_showIcons(aCurrentNotifications) {
+ for (let notification of aCurrentNotifications) {
+ let anchorElm = notification.anchorElement;
+ if (anchorElm) {
+ anchorElm.setAttribute(ICON_ATTRIBUTE_SHOWING, "true");
+
+ if (notification.options.extraAttr) {
+ anchorElm.setAttribute("extraAttr", notification.options.extraAttr);
+ } else {
+ anchorElm.removeAttribute("extraAttr");
+ }
+ }
+ }
+ },
+
+ _hideIcons: function PopupNotifications_hideIcons() {
+ let icons = this.iconBox.querySelectorAll(ICON_SELECTOR);
+ for (let icon of icons) {
+ icon.removeAttribute(ICON_ATTRIBUTE_SHOWING);
+ }
+ },
+
+ /**
+ * Gets and sets notifications for the browser.
+ */
+ _getNotificationsForBrowser: function PopupNotifications_getNotifications(
+ browser
+ ) {
+ let notifications = popupNotificationsMap.get(browser);
+ if (!notifications) {
+ // Initialize the WeakMap for the browser so callers can reference/manipulate the array.
+ notifications = [];
+ popupNotificationsMap.set(browser, notifications);
+ }
+ return notifications;
+ },
+ _setNotificationsForBrowser: function PopupNotifications_setNotifications(
+ browser,
+ notifications
+ ) {
+ popupNotificationsMap.set(browser, notifications);
+ return notifications;
+ },
+
+ _getAnchorsForNotifications:
+ function PopupNotifications_getAnchorsForNotifications(
+ notifications,
+ defaultAnchor
+ ) {
+ let anchors = new Set();
+ for (let notification of notifications) {
+ if (notification.anchorElement) {
+ anchors.add(notification.anchorElement);
+ }
+ }
+ if (defaultAnchor && !anchors.size) {
+ anchors.add(defaultAnchor);
+ }
+ return anchors;
+ },
+
+ _isActiveBrowser(browser) {
+ // We compare on frameLoader instead of just comparing the
+ // selectedBrowser and browser directly because browser tabs in
+ // Responsive Design Mode put the actual web content into a
+ // mozbrowser iframe and proxy property read/write and method
+ // calls from the tab to that iframe. This is so that attempts
+ // to reload the tab end up reloading the content in
+ // Responsive Design Mode, and not the Responsive Design Mode
+ // viewer itself.
+ //
+ // This means that PopupNotifications can come up from a browser
+ // in Responsive Design Mode, but the selectedBrowser will not match
+ // the browser being passed into this function, despite the browser
+ // actually being within the selected tab. We workaround this by
+ // comparing frameLoader instead, which is proxied from the outer
+ // <xul:browser> to the inner mozbrowser <iframe>.
+ return this.tabbrowser.selectedBrowser.frameLoader == browser.frameLoader;
+ },
+
+ _onIconBoxCommand: function PopupNotifications_onIconBoxCommand(event) {
+ // Left click, space or enter only
+ let type = event.type;
+ if (type == "click" && event.button != 0) {
+ return;
+ }
+
+ if (
+ type == "keypress" &&
+ !(
+ event.charCode == event.DOM_VK_SPACE ||
+ event.keyCode == event.DOM_VK_RETURN
+ )
+ ) {
+ return;
+ }
+
+ if (!this._currentNotifications.length) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ // Get the anchor that is the immediate child of the icon box
+ let anchor = event.target;
+ while (anchor && anchor.parentNode != this.iconBox) {
+ anchor = anchor.parentNode;
+ }
+
+ if (!anchor) {
+ return;
+ }
+
+ // If the panel is not closed, and the anchor is different, immediately mark all
+ // active notifications for the previous anchor as dismissed
+ if (this.panel.state != "closed" && anchor != this._currentAnchorElement) {
+ this._dismissOrRemoveCurrentNotifications();
+ }
+
+ // Avoid reshowing notifications that are already shown and have not been dismissed.
+ if (this.panel.state == "closed" || anchor != this._currentAnchorElement) {
+ // As soon as the panel is shown, focus the first element in the selected notification.
+ this.panel.addEventListener(
+ "popupshown",
+ () =>
+ this.window.document.commandDispatcher.advanceFocusIntoSubtree(
+ this.panel
+ ),
+ { once: true }
+ );
+
+ this._reshowNotifications(anchor);
+ } else {
+ // Focus the first element in the selected notification.
+ this.window.document.commandDispatcher.advanceFocusIntoSubtree(
+ this.panel
+ );
+ }
+ },
+
+ _reshowNotifications: function PopupNotifications_reshowNotifications(
+ anchor,
+ browser
+ ) {
+ // Mark notifications anchored to this anchor as un-dismissed
+ browser = browser || this.tabbrowser.selectedBrowser;
+ let notifications = this._getNotificationsForBrowser(browser);
+ notifications.forEach(function (n) {
+ if (n.anchorElement == anchor) {
+ n.dismissed = false;
+ }
+ });
+
+ if (this._isActiveBrowser(browser)) {
+ // ...and then show them.
+ this._update(notifications, anchor);
+ }
+ },
+
+ _swapBrowserNotifications:
+ function PopupNotifications_swapBrowserNoficications(
+ ourBrowser,
+ otherBrowser
+ ) {
+ // When swaping browser docshells (e.g. dragging tab to new window) we need
+ // to update our notification map.
+
+ let ourNotifications = this._getNotificationsForBrowser(ourBrowser);
+ let other = otherBrowser.ownerGlobal.PopupNotifications;
+ if (!other) {
+ if (ourNotifications.length) {
+ console.error(
+ "unable to swap notifications: otherBrowser doesn't support notifications"
+ );
+ }
+ return;
+ }
+ let otherNotifications = other._getNotificationsForBrowser(otherBrowser);
+ if (ourNotifications.length < 1 && otherNotifications.length < 1) {
+ // No notification to swap.
+ return;
+ }
+
+ otherNotifications = otherNotifications.filter(n => {
+ if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, ourBrowser)) {
+ n.browser = ourBrowser;
+ n.owner = this;
+ return true;
+ }
+ other._fireCallback(
+ n,
+ NOTIFICATION_EVENT_REMOVED,
+ this.nextRemovalReason
+ );
+ return false;
+ });
+
+ ourNotifications = ourNotifications.filter(n => {
+ if (this._fireCallback(n, NOTIFICATION_EVENT_SWAPPING, otherBrowser)) {
+ n.browser = otherBrowser;
+ n.owner = other;
+ return true;
+ }
+ this._fireCallback(
+ n,
+ NOTIFICATION_EVENT_REMOVED,
+ this.nextRemovalReason
+ );
+ return false;
+ });
+
+ this._setNotificationsForBrowser(otherBrowser, ourNotifications);
+ other._setNotificationsForBrowser(ourBrowser, otherNotifications);
+
+ if (otherNotifications.length) {
+ this._update(otherNotifications);
+ }
+ if (ourNotifications.length) {
+ other._update(ourNotifications);
+ }
+ },
+
+ _fireCallback: function PopupNotifications_fireCallback(n, event, ...args) {
+ try {
+ if (n.options.eventCallback) {
+ return n.options.eventCallback.call(n, event, ...args);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ return undefined;
+ },
+
+ _onPopupHidden: function PopupNotifications_onPopupHidden(event) {
+ if (event.target != this.panel) {
+ return;
+ }
+
+ // It's possible that a popupnotification set `aria-describedby` on the
+ // panel element in its eventCallback function. If so, we'll clear that out
+ // before showing the next notification.
+ this.panel.removeAttribute("aria-describedby");
+
+ // We may have removed the "noautofocus" attribute before showing the panel
+ // if the notification specified it wants to autofocus on first show.
+ // When the panel is closed, we have to restore the attribute to its default
+ // value, so we don't autofocus it if it's subsequently opened from a different code path.
+ this.panel.setAttribute("noautofocus", "true");
+
+ // Handle the case where the panel was closed programmatically.
+ if (this._ignoreDismissal) {
+ this._ignoreDismissal.resolve();
+ this._ignoreDismissal = null;
+ return;
+ }
+
+ this._dismissOrRemoveCurrentNotifications();
+
+ this._clearPanel();
+
+ this._update();
+ },
+
+ _dismissOrRemoveCurrentNotifications() {
+ let browser =
+ this.panel.firstElementChild &&
+ this.panel.firstElementChild.notification.browser;
+ if (!browser) {
+ return;
+ }
+
+ let notifications = this._getNotificationsForBrowser(browser);
+ // Mark notifications as dismissed and call dismissal callbacks
+ for (let nEl of this.panel.children) {
+ let notificationObj = nEl.notification;
+ // Never call a dismissal handler on a notification that's been removed.
+ if (!notifications.includes(notificationObj)) {
+ return;
+ }
+
+ // Record the time of the first notification dismissal if the main action
+ // was not triggered in the meantime.
+ let timeSinceShown =
+ this.window.performance.now() - notificationObj.timeShown;
+ if (
+ !notificationObj.wasDismissed &&
+ !notificationObj.recordedTelemetryMainAction
+ ) {
+ notificationObj._recordTelemetry(
+ "POPUP_NOTIFICATION_DISMISSAL_MS",
+ timeSinceShown
+ );
+ }
+
+ // Do not mark the notification as dismissed or fire NOTIFICATION_EVENT_DISMISSED
+ // if the notification is removed.
+ if (notificationObj.options.removeOnDismissal) {
+ notificationObj._recordTelemetryStat(this.nextRemovalReason);
+ this._remove(notificationObj);
+ } else {
+ notificationObj.dismissed = true;
+ this._fireCallback(notificationObj, NOTIFICATION_EVENT_DISMISSED);
+ }
+ }
+ },
+
+ _onCheckboxCommand(event) {
+ let notificationEl = getNotificationFromElement(event.originalTarget);
+ let checked = notificationEl.checkbox.checked;
+ let notification = notificationEl.notification;
+
+ // Save checkbox state to be able to persist it when re-opening the doorhanger.
+ notification._checkboxChecked = checked;
+
+ if (checked) {
+ this._setNotificationUIState(
+ notificationEl,
+ notification.options.checkbox.checkedState
+ );
+ } else {
+ this._setNotificationUIState(
+ notificationEl,
+ notification.options.checkbox.uncheckedState
+ );
+ }
+ event.stopPropagation();
+ },
+
+ _onCommand(event) {
+ // Ignore events from buttons as they are submitting and so don't need checks
+ if (event.originalTarget.localName == "button") {
+ return;
+ }
+ let notificationEl = getNotificationFromElement(event.target);
+
+ let notification = notificationEl.notification;
+ if (!notification.options.checkbox) {
+ this._setNotificationUIState(notificationEl);
+ return;
+ }
+
+ if (notificationEl.checkbox.checked) {
+ this._setNotificationUIState(
+ notificationEl,
+ notification.options.checkbox.checkedState
+ );
+ } else {
+ this._setNotificationUIState(
+ notificationEl,
+ notification.options.checkbox.uncheckedState
+ );
+ }
+ },
+
+ _onButtonEvent(event, type, source = "button", notificationEl = null) {
+ if (!notificationEl) {
+ notificationEl = getNotificationFromElement(event.originalTarget);
+ }
+
+ if (!notificationEl) {
+ throw new Error(
+ "PopupNotifications._onButtonEvent: couldn't find notification element"
+ );
+ }
+
+ if (!notificationEl.notification) {
+ throw new Error(
+ "PopupNotifications._onButtonEvent: couldn't find notification"
+ );
+ }
+
+ let notification = notificationEl.notification;
+
+ // Receiving a button event means the notification should have been shown.
+ // Make sure that timeShown is always set to ensure we don't break the
+ // security delay calculation below.
+ if (!notification.timeShown) {
+ console.warn(
+ "_onButtonEvent: notification.timeShown is unset. Setting to now.",
+ notification
+ );
+ notification.timeShown = this.window.performance.now();
+ }
+
+ if (type == "dropmarkerpopupshown") {
+ notification._recordTelemetryStat(TELEMETRY_STAT_OPEN_SUBMENU);
+ return;
+ }
+
+ if (type == "learnmoreclick") {
+ notification._recordTelemetryStat(TELEMETRY_STAT_LEARN_MORE);
+ return;
+ }
+
+ if (type == "buttoncommand") {
+ // Record the total timing of the main action since the notification was
+ // created, even if the notification was dismissed in the meantime.
+ let timeSinceCreated =
+ this.window.performance.now() - notification.timeCreated;
+ if (!notification.recordedTelemetryMainAction) {
+ notification.recordedTelemetryMainAction = true;
+ notification._recordTelemetry(
+ "POPUP_NOTIFICATION_MAIN_ACTION_MS",
+ timeSinceCreated
+ );
+ }
+ }
+
+ if (type == "buttoncommand" || type == "secondarybuttoncommand") {
+ if (Services.focus.activeWindow != this.window) {
+ Services.console.logStringMessage(
+ "PopupNotifications._onButtonEvent: " +
+ "Button click happened before the window was focused"
+ );
+ this.window.focus();
+ return;
+ }
+
+ let now = this.window.performance.now();
+ let timeSinceShown = now - notification.timeShown;
+ if (timeSinceShown < lazy.buttonDelay) {
+ Services.console.logStringMessage(
+ "PopupNotifications._onButtonEvent: " +
+ "Button click happened before the security delay: " +
+ timeSinceShown +
+ "ms"
+ );
+ notification.timeShown = Math.max(now, notification.timeShown);
+ return;
+ }
+ }
+
+ let action = notification.mainAction;
+ let telemetryStatId = TELEMETRY_STAT_ACTION_1;
+
+ if (type == "secondarybuttoncommand") {
+ action = notification.secondaryActions[0];
+ telemetryStatId = TELEMETRY_STAT_ACTION_2;
+ }
+
+ notification._recordTelemetryStat(telemetryStatId);
+
+ if (action) {
+ try {
+ action.callback.call(undefined, {
+ checkboxChecked: notificationEl.checkbox.checked,
+ source,
+ event,
+ });
+ } catch (error) {
+ console.error(error);
+ }
+
+ if (action.dismiss) {
+ this._dismiss();
+ return;
+ }
+ }
+
+ this._remove(notification);
+ this._update();
+ },
+
+ _onMenuCommand: function PopupNotifications_onMenuCommand(event) {
+ let target = event.originalTarget;
+ if (!target.action || !target.notification) {
+ throw new Error(
+ "menucommand target has no associated action/notification"
+ );
+ }
+
+ let notificationEl = getNotificationFromElement(target);
+ event.stopPropagation();
+
+ target.notification._recordTelemetryStat(target.action.telemetryStatId);
+
+ try {
+ target.action.callback.call(undefined, {
+ checkboxChecked: notificationEl.checkbox.checked,
+ source: "menucommand",
+ });
+ } catch (error) {
+ console.error(error);
+ }
+
+ if (target.action.dismiss) {
+ this._dismiss();
+ return;
+ }
+
+ this._remove(target.notification);
+ this._update();
+ },
+
+ _notify: function PopupNotifications_notify(topic) {
+ Services.obs.notifyObservers(null, "PopupNotifications-" + topic);
+ },
+};