/* 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, { ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", 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", "browser/profiles.ftl", "browser/termsofuse.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; const dismissPrefConfig = message?.content?.dismissOnPrefChange; // If set, these are the prefs to watch for changes to auto-dismiss the infobar. if (Array.isArray(dismissPrefConfig)) { this._dismissPrefs = dismissPrefConfig; } else if (dismissPrefConfig) { this._dismissPrefs = [dismissPrefConfig]; } else { this._dismissPrefs = []; } this._prefObserver = null; } /** * Ensure a hidden container of templates exists, and * inject the request links using hrefs from message.content.linkUrls. */ _ensureLinkTemplatesFor(doc, names) { let container = doc.getElementById("infobar-link-templates"); // We inject a hidden
of templates into the // document because Fluent’s DOM-overlay scans the page for those // placeholders. if (!container) { container = doc.createElement("div"); container.id = "infobar-link-templates"; container.hidden = true; doc.body.appendChild(container); } const linkUrls = this.message.content.linkUrls || {}; for (let name of names) { if (!container.querySelector(`a[data-l10n-name="${name}"]`)) { const a = doc.createElement("a"); a.dataset.l10nName = name; a.href = linkUrls[name]; container.appendChild(a); } } } /** * Async helper to render a Fluent string. If the translation contains ``, it will parse and inject the associated link contained * in the message. */ async _buildMessageFragment(doc, browser, stringId, args) { // Get the raw HTML translation const html = await lazy.RemoteL10n.formatLocalizableText({ string_id: stringId, ...(args && { args }), }); // If no inline anchors, just return a span if (!html.includes('data-l10n-name="')) { return lazy.RemoteL10n.createElement(doc, "span", { content: { string_id: stringId, ...(args && { args }) }, }); } // Otherwise parse it and set up a fragment const temp = new DOMParser().parseFromString(html, "text/html").body; const frag = doc.createDocumentFragment(); // Prepare templates const names = [...temp.querySelectorAll("a[data-l10n-name]")].map( a => a.dataset.l10nName ); this._ensureLinkTemplatesFor(doc, names); // Import each node and wire up any anchors it contains for (const node of temp.childNodes) { // Nodes from DOMParser belong to a different document, so importNode() // clones them into our target doc const importedNode = doc.importNode(node, true); if (importedNode.nodeType === Node.ELEMENT_NODE) { // collect this node if it's an anchor, and all child anchors const anchors = []; if (importedNode.matches("a[data-l10n-name]")) { anchors.push(importedNode); } anchors.push(...importedNode.querySelectorAll("a[data-l10n-name]")); const linkActions = this.message.content.linkActions || {}; for (const a of anchors) { const name = a.dataset.l10nName; const template = doc .getElementById("infobar-link-templates") .querySelector(`a[data-l10n-name="${name}"]`); if (!template) { continue; } a.href = template.href; a.addEventListener("click", e => { e.preventDefault(); // Open link URL try { lazy.SpecialMessageActions.handleAction( { type: "OPEN_URL", data: { args: a.href, where: args?.where || "tab" }, }, browser ); } catch (err) { console.error(`Error handling OPEN_URL action:`, err); } // Then fire the defined actions for that link, if applicable if (linkActions[name]) { try { lazy.SpecialMessageActions.handleAction( linkActions[name], browser ); } catch (err) { console.error( `Error handling ${linkActions[name]} action:`, err ); } if (linkActions[name].dismiss) { this.notification?.dismiss(); } } }); } } frag.appendChild(importedNode); } return frag; } /** * Displays the infobar notification in the specified browser and sends an impression ping. * Formats the message and buttons, and appends the notification. * For universal infobars, only records an impression for the first instance. * * @param {object} browser - The 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; let labelNode = await this.formatMessageConfig(doc, browser, content.text); this.notification = await notificationContainer.appendNotification( this.message.id, { label: labelNode, image: content.icon || "chrome://branding/content/icon64.png", priority, eventCallback: this.infobarCallback, style: content.style || {}, }, content.buttons.map(b => this.formatButtonConfig(b)), true, // Disables clickjacking protections content.dismissable ); // If the infobar is universal, only record an impression for the first // instance. if ( content.type !== TYPES.UNIVERSAL || !InfoBar._universalInfobars.length ) { this.addImpression(browser); } // Only add if the universal infobar is still active. Prevents race condition // where a notification could add itself after removeUniversalInfobars(). if ( content.type === TYPES.UNIVERSAL && InfoBar._activeInfobar?.message?.id === this.message.id ) { InfoBar._universalInfobars.push({ box: notificationContainer, notification: this.notification, }); } // After the notification exists, attach a pref observer if applicable. this._maybeAttachPrefObserver(); } _createLinkNode(doc, browser, { href, where = "tab", string_id, args, raw }) { const a = doc.createElement("a"); a.href = href; a.addEventListener("click", e => { e.preventDefault(); lazy.SpecialMessageActions.handleAction( { type: "OPEN_URL", data: { args: a.href, where } }, browser ); }); if (string_id) { // wrap a localized span inside const span = lazy.RemoteL10n.createElement(doc, "span", { content: { string_id, ...(args && { args }) }, }); a.appendChild(span); } else { a.textContent = raw || ""; } return a; } async formatMessageConfig(doc, browser, content) { const frag = doc.createDocumentFragment(); const parts = Array.isArray(content) ? content : [content]; for (const part of parts) { if (!part) { continue; } if (part.href) { frag.appendChild(this._createLinkNode(doc, browser, part)); continue; } if (part.string_id) { const subFrag = await this._buildMessageFragment( doc, browser, part.string_id, part.args ); frag.appendChild(subFrag); continue; } if (typeof part === "string") { frag.appendChild(doc.createTextNode(part)); continue; } if (part.raw && typeof part.raw === "string") { frag.appendChild(doc.createTextNode(part.raw)); } } 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; } handleImpressionAction(browser) { const ALLOWED_IMPRESSION_ACTIONS = ["SET_PREF"]; const impressionAction = this.message.content.impression_action; const actions = impressionAction.type === "MULTI_ACTION" ? impressionAction.data.actions : [impressionAction]; actions.forEach(({ type, data, once }) => { if (!ALLOWED_IMPRESSION_ACTIONS.includes(type)) { return; } let { messageImpressions } = lazy.ASRouter.state; // If we only want to perform the action on first impression, ensure no // impressions exist for this message. if (once && messageImpressions[this.message.id]?.length) { return; } data.onImpression = true; try { lazy.SpecialMessageActions.handleAction({ type, data }, browser); } catch (err) { console.error(`Error handling ${type} impression action:`, err); } }); } addImpression(browser) { // If the message has an impression action, handle it before dispatching the // impression. `this._dispatch` may be async and we want to ensure we have a // consistent impression count when handling impression actions that should // only occur once. if (this.message.content.impression_action) { this.handleImpressionAction(browser); } // 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 `` 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