/* 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 { PromiseUtils } from "resource://gre/modules/PromiseUtils.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 , return the parent . */ 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 element to use for notifications. The panel is * populated with 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; this.panel.addEventListener("popuphidden", this, true); 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"); this.window.addEventListener("unload", () => { Services.obs.removeObserver(this, "fullscreen-transition-start"); }); 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) { if (topic == "fullscreen-transition-start") { // 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 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 = PromiseUtils.defer(); 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. Extend // the security delay. if (this.window.isInFullScreenTransition) { 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 // to the inner mozbrowser