/* 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/. */
"use strict";
// This is loaded into chrome windows with the subscript loader. If you need to
// define globals, wrap in a block to prevent leaking onto `window`.
{
MozElements.NotificationBox =
class NotificationBox {
/**
* Creates a new class to handle a notification box, but does not add any
* elements to the DOM until a notification has to be displayed.
*
* @param insertElementFn Called with the "notification-stack" element as an
* argument when the first notification has to be displayed.
* @param {Number} securityDelayMS - Delay in milliseconds until buttons are enabled to
* protect against click- and tapjacking.
*/
constructor(insertElementFn, securityDelayMS = 0) {
this._insertElementFn = insertElementFn;
this._securityDelayMS = securityDelayMS;
this._animating =
false;
this.currentNotification =
null;
}
get stack() {
if (!
this._stack) {
let stack = document.createXULElement(
"vbox");
stack._notificationBox =
this;
stack.className =
"notificationbox-stack";
stack.addEventListener(
"transitionend", event => {
if (
event.target.localName ==
"notification-message" &&
event.propertyName ==
"margin-top"
) {
this._finishAnimation();
}
});
this._stack = stack;
this._insertElementFn(stack);
}
return this._stack;
}
get _allowAnimation() {
return window.matchMedia(
"(prefers-reduced-motion: no-preference)")
.matches;
}
get allNotifications() {
// Don't create any DOM if no new notification has been added yet.
if (!
this._stack) {
return [];
}
var closedNotification =
this._closedNotification;
var notifications = [
...
this.stack.getElementsByTagName(
"notification-message"),
];
return notifications.filter(n => n != closedNotification);
}
getNotificationWithValue(aValue) {
var notifications =
this.allNotifications;
for (
var n = notifications.length - 1; n >= 0; n--) {
if (aValue == notifications[n].getAttribute(
"value")) {
return notifications[n];
}
}
return null;
}
/**
* Creates a <notification-message> element and shows it. The calling code can modify
* the element asynchronously to add features to the notification.
*
* aType
* String identifier that can uniquely identify the type of the notification.
* aNotification
* Object that contains any of the following properties, where only the
* priority must be specified:
* priority
* One of the PRIORITY_ constants. These determine the appearance of
* the notification based on severity (using the "type" attribute), and
* only the notification with the highest priority is displayed.
* label
* The main message text (as string), or object (with l10n-id, l10n-args),
* or a DocumentFragment containing elements to
* add as children of the notification's main <description> element.
* eventCallback
* This may be called with the "removed", "dismissed" or "disconnected"
* parameter:
* removed - notification has been removed
* dismissed - user dismissed notification
* disconnected - notification removed in any way
* aButtons
* Array of objects defining action buttons:
* {
* label:
* Label of the <button> element.
* accessKey:
* Access key character for the <button> element.
* "l10n-id"
* Localization id for the <button>, to be used instead of
* specifying a separate label and access key.
* callback:
* When the button is used, this is called with the arguments:
* 1. The <notification-message> element.
* 2. This button object definition.
* 3. The <button> element.
* 4. The "command" event.
* If the callback returns false, the notification is closed.
* link:
* A url to open when the button is clicked. The button is
* rendered like a link. The callback is called as well.
* supportPage:
* Used for a support page link. If no other properties are specified,
* defaults to a link with a 'Learn more' label.
* popup:
* If specified, the button will open the popup element with this
* ID, anchored to the button. This is alternative to "callback".
* telemetry:
* Specifies the key to add for the telemetry to trigger when the
* button is pressed. If not specified, then 'action' is used for
* a press on any button. Specify this only if you want to distinguish
* which button has been pressed in telemetry data.
* is:
* Defines a Custom Element name to use as the "is" value on
* button creation.
* }
* aDisableClickJackingDelay
* Optional boolean arg to disable clickjacking protections. By
* default the security delay is enabled.
*
* @returns {Promise<Object>} The <notification-message> element that is shown.
*/
async appendNotification(
aType,
aNotification,
aButtons,
aDisableClickJackingDelay =
false
) {
if (
aNotification.priority <
this.PRIORITY_SYSTEM ||
aNotification.priority >
this.PRIORITY_CRITICAL_HIGH
) {
throw new Error(
"Invalid notification priority " + aNotification.priority
);
}
MozXULElement.insertFTLIfNeeded(
"toolkit/global/notification.ftl");
// Create the Custom Element and connect it to the document immediately.
let newitem;
if (!customElements.get(
"notification-message")) {
// There's some weird timing stuff when this element is created at
// script load time, we don't need it until now anyway so be lazy.
// Wrapped in a try/catch to handle rare cases where we start creating
// a notification but then the window gets closed/goes away.
try {
await createNotificationMessageElement();
}
catch (err) {
console.warn(err);
throw err;
}
}
newitem = document.createElement(
"notification-message");
newitem.setAttribute(
"message-bar-type",
"infobar");
// Append or prepend notification, based on stack preference.
if (
this.stack.hasAttribute(
"prepend-notifications")) {
this.stack.prepend(newitem);
}
else {
this.stack.append(newitem);
}
if (aNotification.label) {
newitem.label = aNotification.label;
}
else if (newitem.messageText) {
// Can't use instanceof in case this was created from a different document:
if (
aNotification.label &&
typeof aNotification.label ==
"object" &&
aNotification.label.nodeType &&
aNotification.label.nodeType ==
aNotification.label.DOCUMENT_FRAGMENT_NODE
) {
newitem.messageText.appendChild(aNotification.label);
}
else if (
aNotification.label &&
typeof aNotification.label ==
"object" &&
"l10n-id" in aNotification.label
) {
let message = document.createElement(
"span");
document.l10n.setAttributes(
message,
aNotification.label[
"l10n-id"],
aNotification.label[
"l10n-args"]
);
newitem.messageText.appendChild(message);
}
else {
newitem.messageText.textContent = aNotification.label;
}
}
newitem.setAttribute(
"value", aType);
newitem.eventCallback = aNotification.eventCallback;
if (aButtons) {
newitem.setButtons(aButtons);
}
newitem.priority = aNotification.priority;
if (aNotification.priority ==
this.PRIORITY_SYSTEM) {
newitem.setAttribute(
"type",
"system");
}
else if (aNotification.priority >=
this.PRIORITY_CRITICAL_LOW) {
newitem.setAttribute(
"type",
"critical");
}
else if (aNotification.priority <=
this.PRIORITY_INFO_HIGH) {
newitem.setAttribute(
"type",
"info");
}
else {
newitem.setAttribute(
"type",
"warning");
}
// If clickjacking protection is not explicitly disabled, enable it.
// aDisableClickJackingDelay is per notification, this._securityDelayMS is
// global for the entire notification box.
if (!aDisableClickJackingDelay &&
this._securityDelayMS > 0) {
newitem._initClickJackingProtection(
this._securityDelayMS);
}
// Animate the notification.
newitem.style.display =
"block";
newitem.style.position =
"fixed";
newitem.style.top =
"100%";
newitem.style.marginTop =
"-15px";
newitem.style.opacity =
"0";
// Ensure the DOM has been created for the Lit-based notification-message
// element so that we add the .animated class + it animates as expected.
await newitem.updateComplete;
this._showNotification(newitem,
true);
// Fire event for accessibility APIs
var event = document.createEvent(
"Events");
event.initEvent(
"AlertActive",
true,
true);
newitem.dispatchEvent(event);
return newitem;
}
removeNotification(aItem, aSkipAnimation) {
if (!aItem.parentNode) {
return;
}
this.currentNotification = aItem;
this.removeCurrentNotification(aSkipAnimation);
}
_removeNotificationElement(aChild) {
let hadFocus = aChild.matches(
":focus-within");
if (aChild.eventCallback) {
aChild.eventCallback(
"removed");
}
aChild.remove();
// Make sure focus doesn't get lost (workaround for bug 570835).
if (hadFocus) {
Services.focus.moveFocus(
window,
this.stack,
Services.focus.MOVEFOCUS_FORWARD,
0
);
}
}
removeCurrentNotification(aSkipAnimation) {
this._showNotification(
this.currentNotification,
false, aSkipAnimation);
}
removeAllNotifications(aImmediate) {
var notifications =
this.allNotifications;
for (
var n = notifications.length - 1; n >= 0; n--) {
if (aImmediate) {
this._removeNotificationElement(notifications[n]);
}
else {
this.removeNotification(notifications[n]);
}
}
this.currentNotification =
null;
// Clean up any currently-animating notification; this is necessary
// if a notification was just opened and is still animating, but we
// want to close it *without* animating. This can even happen if
// animations get disabled (via prefers-reduced-motion) and this method
// is called immediately after an animated notification was displayed
// (although this case isn't very likely).
if (aImmediate || !
this._allowAnimation) {
this._finishAnimation();
}
}
removeTransientNotifications() {
var notifications =
this.allNotifications;
for (
var n = notifications.length - 1; n >= 0; n--) {
var notification = notifications[n];
if (notification.persistence) {
notification.persistence--;
}
else if (Date.now() > notification.timeout) {
this.removeNotification(notification,
true);
}
}
}
_showNotification(aNotification, aSlideIn, aSkipAnimation) {
this._finishAnimation();
let { marginTop, marginBottom } = getComputedStyle(aNotification);
let baseHeight = aNotification.getBoundingClientRect().height;
var height =
baseHeight + parseInt(marginTop, 10) + parseInt(marginBottom, 10);
var skipAnimation =
aSkipAnimation || baseHeight == 0 || !
this._allowAnimation;
aNotification.classList.toggle(
"animated", !skipAnimation);
if (aSlideIn) {
this.currentNotification = aNotification;
aNotification.style.removeProperty(
"display");
aNotification.style.removeProperty(
"position");
aNotification.style.removeProperty(
"top");
aNotification.style.removeProperty(
"margin-top");
aNotification.style.removeProperty(
"opacity");
if (skipAnimation) {
return;
}
}
else {
this._closedNotification = aNotification;
var notifications =
this.allNotifications;
var idx = notifications.length - 1;
this.currentNotification = idx >= 0 ? notifications[idx] :
null;
if (skipAnimation) {
this._removeNotificationElement(
this._closedNotification);
delete this._closedNotification;
return;
}
aNotification.style.marginTop = -height +
"px";
aNotification.style.opacity = 0;
}
this._animating =
true;
}
_finishAnimation() {
if (
this._animating) {
this._animating =
false;
if (
this._closedNotification) {
this._removeNotificationElement(
this._closedNotification);
delete this._closedNotification;
}
}
}
};
// These are defined on the instance prototype for backwards compatibility.
Object.assign(MozElements.NotificationBox.prototype, {
PRIORITY_SYSTEM: 0,
PRIORITY_INFO_LOW: 1,
PRIORITY_INFO_MEDIUM: 2,
PRIORITY_INFO_HIGH: 3,
PRIORITY_WARNING_LOW: 4,
PRIORITY_WARNING_MEDIUM: 5,
PRIORITY_WARNING_HIGH: 6,
PRIORITY_CRITICAL_LOW: 7,
PRIORITY_CRITICAL_MEDIUM: 8,
PRIORITY_CRITICAL_HIGH: 9,
});
async
function createNotificationMessageElement() {
document.createElement(
"moz-message-bar");
let MozMessageBar = await customElements.whenDefined(
"moz-message-bar");
class NotificationMessage
extends MozMessageBar {
static queries = {
...MozMessageBar.queries,
messageText:
".message",
messageImage:
".icon",
};
constructor() {
super();
this.persistence = 0;
this.priority = 0;
this.timeout = 0;
this.dismissable =
true;
// Variables used for security delay / clickjacking protection.
this._clickjackingDelayActive =
false;
this._securityDelayMS = 0;
this._delayTimer =
null;
this._focusHandler =
null;
this._buttons = [];
this.addEventListener(
"click",
this);
this.addEventListener(
"command",
this);
}
connectedCallback() {
super.connectedCallback();
this.#setStyles();
this.classList.add(
"infobar");
this.setAlertRole();
this.buttonContainer = document.createElement(
"span");
this.buttonContainer.classList.add(
"notification-button-container");
this.buttonContainer.setAttribute(
"slot",
"actions");
this.appendChild(
this.buttonContainer);
}
disconnectedCallback() {
super.disconnectedCallback();
if (
this.eventCallback) {
this.eventCallback(
"disconnected");
}
// Clean up clickjacking listeners if active.
this._uninitClickJackingProtection();
}
closeButtonTemplate() {
return super.closeButtonTemplate({ size:
"small" });
}
#setStyles() {
let style = document.createElement(
"link");
style.rel =
"stylesheet";
style.href =
"chrome://global/content/elements/infobar.css";
this.renderRoot.append(style);
}
get control() {
return this.closest(
".notificationbox-stack")._notificationBox;
}
close() {
if (!
this.parentNode) {
return;
}
this.control.removeNotification(
this);
}
setAlertRole() {
// Wait a little for this to render before setting the role for more
// consistent alerts to screen readers.
this.removeAttribute(
"role");
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
this.setAttribute(
"role",
"alert");
});
});
}
handleEvent(e) {
// If clickjacking delay is active, prevent any "click"/"command" from
// going through. Also restart the delay if the user tries to click too early.
if (
this._clickjackingDelayActive) {
// Only relevant if user clicked on the notification’s actual button/link area.
if (
e.type ===
"click" &&
(e.target.localName ===
"button" ||
e.target.classList.contains(
"text-link") ||
e.target.classList.contains(
"notification-link"))
) {
// Stop immediate action, restart the delay
e.stopPropagation();
e.preventDefault();
this._startClickJackingDelay();
return;
}
}
if (e.type ==
"click" && e.target.localName !=
"label") {
return;
}
if (
"buttonInfo" in e.target) {
let { buttonInfo } = e.target;
let { callback, popup } = buttonInfo;
if (popup) {
document
.getElementById(popup)
.openPopup(
e.originalTarget,
"after_start",
0,
0,
false,
false,
e
);
e.stopPropagation();
}
else if (callback) {
if (!callback(
this, buttonInfo, e.target, e)) {
this.close();
}
e.stopPropagation();
}
}
}
/**
* Changes the text of an existing notification. If the notification was
* created with a custom fragment, it will be overwritten with plain text
* or a localized message.
*
* @param {string | { "l10n-id": string, "l10n-args"?: string }} value
*/
set label(value) {
if (value &&
typeof value ==
"object" &&
"l10n-id" in value) {
this.messageL10nId = value[
"l10n-id"];
this.messageL10nArgs = value[
"l10n-args"];
}
else {
this.message = value;
}
this.setAlertRole();
}
setButtons(buttons) {
this._buttons = [];
for (let button of buttons) {
let link = button.link || button.supportPage;
let localeId = button[
"l10n-id"];
let buttonElem;
if (button.hasOwnProperty(
"supportPage")) {
buttonElem = document.createElement(
"a", {
is:
"moz-support-link",
});
buttonElem.classList.add(
"notification-link");
buttonElem.setAttribute(
"support-page", button.supportPage);
}
else if (link) {
buttonElem = document.createXULElement(
"label", {
is:
"text-link",
});
buttonElem.setAttribute(
"href", link);
buttonElem.classList.add(
"notification-link",
"text-link");
}
else {
buttonElem = document.createXULElement(
"button",
button.is ? { is: button.is } : {}
);
buttonElem.classList.add(
"notification-button",
"small-button",
"footer-button"
);
if (button.primary) {
buttonElem.classList.add(
"primary");
}
}
if (localeId) {
document.l10n.setAttributes(buttonElem, localeId);
}
else {
buttonElem.setAttribute(link ?
"value" :
"label", button.label);
if (
typeof button.accessKey ==
"string") {
buttonElem.setAttribute(
"accesskey", button.accessKey);
}
}
if (link) {
buttonElem.setAttribute(
"slot",
"support-link");
this.appendChild(buttonElem);
}
else {
this.buttonContainer.appendChild(buttonElem);
}
buttonElem.buttonInfo = button;
this._buttons.push(buttonElem);
}
}
dismiss() {
if (
this.eventCallback) {
this.eventCallback(
"dismissed");
}
super.dismiss();
}
/**
* Initialize clickjacking protection for this notification, disabling
* buttons initially and re-enabling them after a short delay. The delay
* restarts on window focus or if the user attempts to click during the
* disabled period.
*
* @param {Number} securityDelayMS - ClickJacking delay to apply
* (milliseconds).
*/
_initClickJackingProtection(securityDelayMS) {
if (
this._clickjackingDelayActive) {
return;
// Already enabled.
}
this._securityDelayMS = securityDelayMS;
// Attach a global focus handler so we can restart the delay when the window
// refocuses (e.g., user navigated away or used a popup).
this._focusHandler = event => {
// Only restart delay if the notification is still connected and this
// is actually a window focus.
if (
this.isConnected && event.target === window) {
this._startClickJackingDelay();
}
};
window.addEventListener(
"focus",
this._focusHandler,
true);
this._startClickJackingDelay();
}
/**
* Remove any event listeners or timers related to clickjacking protection.
*/
_uninitClickJackingProtection() {
window.removeEventListener(
"focus",
this._focusHandler,
true);
this._focusHandler =
null;
if (
this._delayTimer) {
clearTimeout(
this._delayTimer);
this._delayTimer =
null;
}
this._enableAllButtons();
this._clickjackingDelayActive =
false;
}
_startClickJackingDelay() {
this._clickjackingDelayActive =
true;
this._disableAllButtons();
if (
this._delayTimer) {
clearTimeout(
this._delayTimer);
}
this._delayTimer = setTimeout(() => {
this._clickjackingDelayActive =
false;
this._enableAllButtons();
this._delayTimer =
null;
},
this._securityDelayMS);
}
_disableAllButtons() {
for (let button of
this._buttons) {
button.disabled =
true;
}
}
_enableAllButtons() {
for (let button of
this._buttons) {
button.disabled =
false;
}
}
}
if (!customElements.get(
"notification-message")) {
customElements.define(
"notification-message", NotificationMessage);
}
}
}