321 lines
9.5 KiB
JavaScript
321 lines
9.5 KiB
JavaScript
/* 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/. */
|
|
|
|
/* eslint-disable no-use-before-define */
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
|
|
RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs",
|
|
SpecialMessageActions:
|
|
"resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
|
|
});
|
|
|
|
const TYPES = {
|
|
UNIVERSAL: "universal",
|
|
GLOBAL: "global",
|
|
};
|
|
|
|
const FTL_FILES = [
|
|
"browser/newtab/asrouter.ftl",
|
|
"browser/defaultBrowserNotification.ftl",
|
|
"preview/termsOfUse.ftl",
|
|
];
|
|
|
|
class InfoBarNotification {
|
|
constructor(message, dispatch) {
|
|
this._dispatch = dispatch;
|
|
this.dispatchUserAction = this.dispatchUserAction.bind(this);
|
|
this.buttonCallback = this.buttonCallback.bind(this);
|
|
this.infobarCallback = this.infobarCallback.bind(this);
|
|
this.message = message;
|
|
this.notification = null;
|
|
}
|
|
|
|
/**
|
|
* Show the infobar notification and send an impression ping
|
|
*
|
|
* @param {object} browser Browser reference for the currently selected tab
|
|
*/
|
|
async showNotification(browser) {
|
|
let { content } = this.message;
|
|
let { gBrowser } = browser.ownerGlobal;
|
|
let doc = gBrowser.ownerDocument;
|
|
let notificationContainer;
|
|
if ([TYPES.GLOBAL, TYPES.UNIVERSAL].includes(content.type)) {
|
|
notificationContainer = browser.ownerGlobal.gNotificationBox;
|
|
} else {
|
|
notificationContainer = gBrowser.getNotificationBox(browser);
|
|
}
|
|
|
|
let priority = content.priority || notificationContainer.PRIORITY_SYSTEM;
|
|
|
|
this.notification = await notificationContainer.appendNotification(
|
|
this.message.id,
|
|
{
|
|
label: this.formatMessageConfig(doc, browser, content.text),
|
|
image: content.icon || "chrome://branding/content/icon64.png",
|
|
priority,
|
|
eventCallback: this.infobarCallback,
|
|
},
|
|
content.buttons.map(b => this.formatButtonConfig(b)),
|
|
false,
|
|
content.dismissable
|
|
);
|
|
// If InfoBar is universal, only record an impression for the first
|
|
// instance.
|
|
if (
|
|
content.type !== TYPES.UNIVERSAL ||
|
|
!InfoBar._universalInfobars.length
|
|
) {
|
|
this.addImpression();
|
|
}
|
|
|
|
if (content.type === TYPES.UNIVERSAL) {
|
|
InfoBar._universalInfobars.push({
|
|
box: browser.ownerGlobal.gNotificationBox,
|
|
notification: this.notification,
|
|
});
|
|
}
|
|
}
|
|
|
|
formatMessageConfig(doc, browser, content) {
|
|
const frag = doc.createDocumentFragment();
|
|
const parts = Array.isArray(content) ? content : [content];
|
|
for (const part of parts) {
|
|
let node;
|
|
if (typeof part === "string") {
|
|
node = doc.createTextNode(part);
|
|
// Handle embedded link
|
|
} else if (part.href) {
|
|
const a = doc.createElement("a");
|
|
a.href = part.href;
|
|
a.addEventListener("click", e => {
|
|
e.preventDefault();
|
|
lazy.SpecialMessageActions.handleAction(
|
|
{ type: "OPEN_URL", data: { args: a.href, where: part.where } },
|
|
browser
|
|
);
|
|
});
|
|
|
|
if (part.string_id) {
|
|
const l10n = lazy.RemoteL10n.createElement(doc, "span", {
|
|
content: {
|
|
string_id: part.string_id,
|
|
...(part.args && { args: part.args }),
|
|
},
|
|
});
|
|
a.appendChild(l10n);
|
|
} else {
|
|
a.textContent = part.raw || "";
|
|
}
|
|
node = a;
|
|
} else if (part.string_id) {
|
|
node = lazy.RemoteL10n.createElement(doc, "span", {
|
|
content: {
|
|
string_id: part.string_id,
|
|
...(part.args && { args: part.args }),
|
|
},
|
|
});
|
|
} else {
|
|
const text = part.raw !== null ? part.raw : String(part);
|
|
node = doc.createTextNode(text);
|
|
}
|
|
|
|
frag.appendChild(node);
|
|
}
|
|
|
|
return frag;
|
|
}
|
|
|
|
formatButtonConfig(button) {
|
|
let btnConfig = { callback: this.buttonCallback, ...button };
|
|
// notificationbox will set correct data-l10n-id attributes if passed in
|
|
// using the l10n-id key. Otherwise the `button.label` text is used.
|
|
if (button.label.string_id) {
|
|
btnConfig["l10n-id"] = button.label.string_id;
|
|
}
|
|
|
|
return btnConfig;
|
|
}
|
|
|
|
addImpression() {
|
|
// Record an impression in ASRouter for frequency capping
|
|
this._dispatch({ type: "IMPRESSION", data: this.message });
|
|
// Send a user impression telemetry ping
|
|
this.sendUserEventTelemetry("IMPRESSION");
|
|
}
|
|
|
|
/**
|
|
* Callback fired when a button in the infobar is clicked.
|
|
*
|
|
* @param {Element} notificationBox - The `<notification-message>` element representing the infobar.
|
|
* @param {Object} btnDescription - An object describing the button, includes the label, the action with an optional dismiss property, and primary button styling.
|
|
* @param {Element} target - The <button> DOM element that was clicked.
|
|
* @returns {boolean} Returns `false` to dismiss the infobar or `true` to keep it open.
|
|
*/
|
|
buttonCallback(notificationBox, btnDescription, target) {
|
|
this.dispatchUserAction(
|
|
btnDescription.action,
|
|
target.ownerGlobal.gBrowser.selectedBrowser
|
|
);
|
|
let isPrimary = target.classList.contains("primary");
|
|
let eventName = isPrimary
|
|
? "CLICK_PRIMARY_BUTTON"
|
|
: "CLICK_SECONDARY_BUTTON";
|
|
this.sendUserEventTelemetry(eventName);
|
|
|
|
// Prevent dismissal if dismiss property is set to 'false'
|
|
if (btnDescription.action?.dismiss === false) {
|
|
return true;
|
|
}
|
|
|
|
// Default, dismisses the Infobar
|
|
return false;
|
|
}
|
|
|
|
dispatchUserAction(action, selectedBrowser) {
|
|
this._dispatch({ type: "USER_ACTION", data: action }, selectedBrowser);
|
|
}
|
|
|
|
/**
|
|
* Called when interacting with the toolbar (but not through the buttons)
|
|
*/
|
|
infobarCallback(eventType) {
|
|
const wasUniversal =
|
|
InfoBar._activeInfobar?.message.content.type === TYPES.UNIVERSAL;
|
|
if (eventType === "removed") {
|
|
this.notification = null;
|
|
InfoBar._activeInfobar = null;
|
|
} else if (this.notification) {
|
|
this.sendUserEventTelemetry("DISMISSED");
|
|
this.notification = null;
|
|
InfoBar._activeInfobar = null;
|
|
}
|
|
// If one instance of universal infobar is removed, remove all instances and
|
|
// the new window observer
|
|
if (wasUniversal) {
|
|
this.removeUniversalInfobars();
|
|
}
|
|
}
|
|
|
|
removeUniversalInfobars() {
|
|
try {
|
|
Services.obs.removeObserver(InfoBar, "domwindowopened");
|
|
} catch (error) {
|
|
console.error(
|
|
"Error removing domwindowopened observer on InfoBar:",
|
|
error
|
|
);
|
|
}
|
|
InfoBar._universalInfobars.forEach(({ box, notification }) => {
|
|
box.removeNotification(notification);
|
|
});
|
|
InfoBar._universalInfobars = [];
|
|
}
|
|
|
|
sendUserEventTelemetry(event) {
|
|
const ping = {
|
|
message_id: this.message.id,
|
|
event,
|
|
};
|
|
this._dispatch({
|
|
type: "INFOBAR_TELEMETRY",
|
|
data: { action: "infobar_user_event", ...ping },
|
|
});
|
|
}
|
|
}
|
|
|
|
export const InfoBar = {
|
|
_activeInfobar: null,
|
|
_universalInfobars: [],
|
|
|
|
maybeLoadCustomElement(win) {
|
|
if (!win.customElements.get("remote-text")) {
|
|
Services.scriptloader.loadSubScript(
|
|
"chrome://browser/content/asrouter/components/remote-text.js",
|
|
win
|
|
);
|
|
}
|
|
},
|
|
|
|
maybeInsertFTL(win) {
|
|
FTL_FILES.forEach(path => win.MozXULElement.insertFTLIfNeeded(path));
|
|
},
|
|
|
|
async showNotificationAllWindows(notification) {
|
|
for (let win of Services.wm.getEnumerator(null)) {
|
|
this.maybeLoadCustomElement(win);
|
|
this.maybeInsertFTL(win);
|
|
const browser = win.gBrowser.selectedBrowser;
|
|
await notification.showNotification(browser);
|
|
}
|
|
},
|
|
|
|
async showInfoBarMessage(browser, message, dispatch, universalInNewWin) {
|
|
// Prevent stacking multiple infobars
|
|
if (this._activeInfobar && !universalInNewWin) {
|
|
return null;
|
|
}
|
|
|
|
// Check if this is the first instance of a universal infobar
|
|
const isFirstUniversal =
|
|
!universalInNewWin && message.content.type === TYPES.UNIVERSAL;
|
|
const win = browser?.ownerGlobal;
|
|
|
|
if (!win || lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
|
|
return null;
|
|
}
|
|
|
|
this.maybeLoadCustomElement(win);
|
|
this.maybeInsertFTL(win);
|
|
|
|
let notification = new InfoBarNotification(message, dispatch);
|
|
if (isFirstUniversal) {
|
|
await this.showNotificationAllWindows(notification);
|
|
Services.obs.addObserver(this, "domwindowopened");
|
|
} else {
|
|
await notification.showNotification(browser);
|
|
}
|
|
if (!universalInNewWin) {
|
|
this._activeInfobar = { message, dispatch };
|
|
// If the window closes before the user interacts with the active infobar,
|
|
// clear it
|
|
win.addEventListener(
|
|
"unload",
|
|
() => {
|
|
if (InfoBar._activeInfobar?.message === message) {
|
|
InfoBar._activeInfobar = null;
|
|
}
|
|
},
|
|
{ once: true }
|
|
);
|
|
}
|
|
|
|
return notification;
|
|
},
|
|
|
|
observe(aSubject, aTopic) {
|
|
const { message, dispatch } = this._activeInfobar;
|
|
if (
|
|
aTopic !== "domwindowopened" ||
|
|
message?.content.type !== TYPES.UNIVERSAL
|
|
) {
|
|
return;
|
|
}
|
|
if (aSubject.document.readyState === "complete") {
|
|
let browser = aSubject.gBrowser.selectedBrowser;
|
|
this.showInfoBarMessage(browser, message, dispatch, true);
|
|
} else {
|
|
aSubject.addEventListener(
|
|
"load",
|
|
() => {
|
|
let browser = aSubject.gBrowser.selectedBrowser;
|
|
this.showInfoBarMessage(browser, message, dispatch, true);
|
|
},
|
|
{ once: true }
|
|
);
|
|
}
|
|
},
|
|
};
|