/* 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/. */ // We use importESModule here instead of static import so that // the Karma test environment won't choke on this module. This // is because the Karma test environment already stubs out // XPCOMUtils and overrides importESModule to be a no-op (which // can't be done for a static import statement). // eslint-disable-next-line mozilla/use-static-import const { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", }); XPCOMUtils.defineLazyServiceGetter( lazy, "TrackingDBService", "@mozilla.org/tracking-db-service;1", "nsITrackingDBService" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "milestones", "browser.contentblocking.cfr-milestone.milestones", "[]", null, JSON.parse ); const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation"; const SUMO_BASE_URL = Services.urlFormatter.formatURLPref( "app.support.baseURL" ); const ADDONS_API_URL = "https://services.addons.mozilla.org/api/v4/addons/addon"; const DELAY_BEFORE_EXPAND_MS = 1000; const CATEGORY_ICONS = { cfrAddons: "webextensions-icon", cfrFeatures: "recommendations-icon", cfrHeartbeat: "highlights-icon", }; /** * A WeakMap from browsers to {host, recommendation} pairs. Recommendations are * defined in the ExtensionDoorhanger.schema.json. * * A recommendation is specific to a browser and host and is active until the * given browser is closed or the user navigates (within that browser) away from * the host. */ let RecommendationMap = new WeakMap(); /** * A WeakMap from windows to their CFR PageAction. */ let PageActionMap = new WeakMap(); /** * We need one PageAction for each window */ export class PageAction { constructor(win, dispatchCFRAction) { this.window = win; this.urlbar = win.gURLBar; // The global URLBar object this.urlbarinput = win.gURLBar.textbox; // The URLBar DOM node this.container = win.document.getElementById( "contextual-feature-recommendation" ); this.button = win.document.getElementById("cfr-button"); this.label = win.document.getElementById("cfr-label"); // This should NOT be use directly to dispatch message-defined actions attached to buttons. // Please use dispatchUserAction instead. this._dispatchCFRAction = dispatchCFRAction; this._popupStateChange = this._popupStateChange.bind(this); this._collapse = this._collapse.bind(this); this._cfrUrlbarButtonClick = this._cfrUrlbarButtonClick.bind(this); this._executeNotifierAction = this._executeNotifierAction.bind(this); this.dispatchUserAction = this.dispatchUserAction.bind(this); // Saved timeout IDs for scheduled state changes, so they can be cancelled this.stateTransitionTimeoutIDs = []; ChromeUtils.defineLazyGetter(this, "isDarkTheme", () => { try { return this.window.document.documentElement.hasAttribute( "lwt-toolbar-field-brighttext" ); } catch (e) { return false; } }); } addImpression(recommendation) { this._dispatchImpression(recommendation); // Only send an impression ping upon the first expansion. // Note that when the user clicks on the "show" button on the asrouter admin // page (both `bucket_id` and `id` will be set as null), we don't want to send // the impression ping in that case. if (!!recommendation.id && !!recommendation.content.bucket_id) { this._sendTelemetry({ message_id: recommendation.id, bucket_id: recommendation.content.bucket_id, event: "IMPRESSION", }); } } reloadL10n() { lazy.RemoteL10n.reloadL10n(); } async showAddressBarNotifier(recommendation, shouldExpand = false) { this.container.hidden = false; let notificationText = await this.getStrings( recommendation.content.notification_text ); this.label.value = notificationText; if (notificationText.attributes) { this.button.setAttribute( "tooltiptext", notificationText.attributes.tooltiptext ); // For a11y, we want the more descriptive text. this.container.setAttribute( "aria-label", notificationText.attributes.tooltiptext ); } this.container.setAttribute( "data-cfr-icon", CATEGORY_ICONS[recommendation.content.category] ); if (recommendation.content.active_color) { this.container.style.setProperty( "--cfr-active-color", recommendation.content.active_color ); } if (recommendation.content.active_text_color) { this.container.style.setProperty( "--cfr-active-text-color", recommendation.content.active_text_color ); } // Wait for layout to flush to avoid a synchronous reflow then calculate the // label width. We can safely get the width even though the recommendation is // collapsed; the label itself remains full width (with its overflow hidden) let [{ width }] = await this.window.promiseDocumentFlushed(() => this.label.getClientRects() ); this.urlbarinput.style.setProperty("--cfr-label-width", `${width}px`); this.container.addEventListener("click", this._cfrUrlbarButtonClick); // Collapse the recommendation on url bar focus in order to free up more // space to display and edit the url this.urlbar.addEventListener("focus", this._collapse); if (shouldExpand) { this._clearScheduledStateChanges(); // After one second, expand this._expand(DELAY_BEFORE_EXPAND_MS); this.addImpression(recommendation); } if (notificationText.attributes) { this.window.A11yUtils.announce({ raw: notificationText.attributes["a11y-announcement"], source: this.container, }); } } hideAddressBarNotifier() { this.container.hidden = true; this._clearScheduledStateChanges(); this.urlbarinput.removeAttribute("cfr-recommendation-state"); this.container.removeEventListener("click", this._cfrUrlbarButtonClick); this.urlbar.removeEventListener("focus", this._collapse); if (this.currentNotification) { this.window.PopupNotifications.remove(this.currentNotification); this.currentNotification = null; } } _expand(delay) { if (delay > 0) { this.stateTransitionTimeoutIDs.push( this.window.setTimeout(() => { this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded"); }, delay) ); } else { // Non-delayed state change overrides any scheduled state changes this._clearScheduledStateChanges(); this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded"); } } _collapse(delay) { if (delay > 0) { this.stateTransitionTimeoutIDs.push( this.window.setTimeout(() => { if ( this.urlbarinput.getAttribute("cfr-recommendation-state") === "expanded" ) { this.urlbarinput.setAttribute( "cfr-recommendation-state", "collapsed" ); } }, delay) ); } else { // Non-delayed state change overrides any scheduled state changes this._clearScheduledStateChanges(); if ( this.urlbarinput.getAttribute("cfr-recommendation-state") === "expanded" ) { this.urlbarinput.setAttribute("cfr-recommendation-state", "collapsed"); } } } _clearScheduledStateChanges() { while (this.stateTransitionTimeoutIDs.length) { // clearTimeout is safe even with invalid/expired IDs this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop()); } } // This is called when the popup closes as a result of interaction _outside_ // the popup, e.g. by hitting _popupStateChange(state) { if (state === "shown") { if (this._autoFocus) { this.window.document.commandDispatcher.advanceFocusIntoSubtree( this.currentNotification.owner.panel ); this._autoFocus = false; } } else if (state === "removed") { if (this.currentNotification) { this.window.PopupNotifications.remove(this.currentNotification); this.currentNotification = null; } } else if (state === "dismissed") { const message = RecommendationMap.get(this.currentNotification?.browser); this._sendTelemetry({ message_id: message?.id, bucket_id: message?.content.bucket_id, event: "DISMISS", }); this._collapse(); } } shouldShowDoorhanger(recommendation) { if (recommendation.content.layout === "chiclet_open_url") { return false; } return true; } dispatchUserAction(action) { this._dispatchCFRAction( { type: "USER_ACTION", data: action }, this.window.gBrowser.selectedBrowser ); } _dispatchImpression(message) { this._dispatchCFRAction({ type: "IMPRESSION", data: message }); } _sendTelemetry(ping) { const data = { action: "cfr_user_event", source: "CFR", ...ping }; if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) { data.is_private = true; } this._dispatchCFRAction({ type: "DOORHANGER_TELEMETRY", data, }); } _blockMessage(messageID) { this._dispatchCFRAction({ type: "BLOCK_MESSAGE_BY_ID", data: { id: messageID }, }); } maybeLoadCustomElement(win) { if (!win.customElements.get("remote-text")) { Services.scriptloader.loadSubScript( "resource://activity-stream/data/custom-elements/paragraph.js", win ); } } /** * getStrings - Handles getting the localized strings vs message overrides. * If string_id is not defined it assumes you passed in an override * message and it just returns it. * If subAttribute is provided, the string for it is returned. * @return A string. One of 1) passed in string 2) a String object with * attributes property if there are attributes 3) the sub attribute. */ async getStrings(string, subAttribute = "") { if (!string.string_id) { if (subAttribute) { if (string.attributes) { return string.attributes[subAttribute]; } console.error(`String ${string.value} does not contain any attributes`); return subAttribute; } if (typeof string.value === "string") { const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers stringWithAttributes.attributes = string.attributes; return stringWithAttributes; } return string; } const [localeStrings] = await lazy.RemoteL10n.l10n.formatMessages([ { id: string.string_id, args: string.args, }, ]); const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers if (localeStrings.attributes) { const attributes = localeStrings.attributes.reduce((acc, attribute) => { acc[attribute.name] = attribute.value; return acc; }, {}); mainString.attributes = attributes; } return subAttribute ? mainString.attributes[subAttribute] : mainString; } async _setAddonRating(document, content) { const footerFilledStars = this.window.document.getElementById( "cfr-notification-footer-filled-stars" ); const footerEmptyStars = this.window.document.getElementById( "cfr-notification-footer-empty-stars" ); const footerUsers = this.window.document.getElementById( "cfr-notification-footer-users" ); const rating = content.addon?.rating; if (rating) { const MAX_RATING = 5; const STARS_WIDTH = 16 * MAX_RATING; const calcWidth = stars => `${(stars / MAX_RATING) * STARS_WIDTH}px`; const filledWidth = rating <= MAX_RATING ? calcWidth(rating) : calcWidth(MAX_RATING); const emptyWidth = rating <= MAX_RATING ? calcWidth(MAX_RATING - rating) : calcWidth(0); footerFilledStars.style.width = filledWidth; footerEmptyStars.style.width = emptyWidth; const ratingString = await this.getStrings( { string_id: "cfr-doorhanger-extension-rating", args: { total: rating }, }, "tooltiptext" ); footerFilledStars.setAttribute("tooltiptext", ratingString); footerEmptyStars.setAttribute("tooltiptext", ratingString); } else { footerFilledStars.style.width = ""; footerEmptyStars.style.width = ""; footerFilledStars.removeAttribute("tooltiptext"); footerEmptyStars.removeAttribute("tooltiptext"); } const users = content.addon?.users; if (users) { footerUsers.setAttribute("value", users); footerUsers.hidden = false; } else { // Prevent whitespace around empty label from affecting other spacing footerUsers.hidden = true; footerUsers.removeAttribute("value"); } } _createElementAndAppend({ type, id }, parent) { let element = this.window.document.createXULElement(type); if (id) { element.setAttribute("id", id); } parent.appendChild(element); return element; } async _renderMilestonePopup(message, browser) { this.maybeLoadCustomElement(this.window); let { content, id } = message; let { primary, secondary } = content.buttons; let earliestDate = await lazy.TrackingDBService.getEarliestRecordedDate(); let timestamp = earliestDate ?? new Date().getTime(); let panelTitle = ""; let headerLabel = this.window.document.getElementById( "cfr-notification-header-label" ); let reachedMilestone = 0; let totalSaved = await lazy.TrackingDBService.sumAllEvents(); for (let milestone of lazy.milestones) { if (totalSaved >= milestone) { reachedMilestone = milestone; } } if (headerLabel.firstChild) { headerLabel.firstChild.remove(); } headerLabel.appendChild( lazy.RemoteL10n.createElement(this.window.document, "span", { content: message.content.heading_text, attributes: { blockedCount: reachedMilestone, date: timestamp, }, }) ); // Use the message layout as a CSS selector to hide different parts of the // notification template markup this.window.document .getElementById("contextual-feature-recommendation-notification") .setAttribute("data-notification-category", content.layout); this.window.document .getElementById("contextual-feature-recommendation-notification") .setAttribute("data-notification-bucket", content.bucket_id); let primaryBtnString = await this.getStrings(primary.label); let primaryActionCallback = () => { this.dispatchUserAction(primary.action); this._sendTelemetry({ message_id: id, bucket_id: content.bucket_id, event: "CLICK_BUTTON", }); RecommendationMap.delete(browser); // Invalidate the pref after the user interacts with the button. // We don't need to show the illustration in the privacy panel. Services.prefs.clearUserPref( "browser.contentblocking.cfr-milestone.milestone-shown-time" ); }; let secondaryBtnString = await this.getStrings(secondary[0].label); let secondaryActionsCallback = () => { this.dispatchUserAction(secondary[0].action); this._sendTelemetry({ message_id: id, bucket_id: content.bucket_id, event: "DISMISS", }); RecommendationMap.delete(browser); }; let mainAction = { label: primaryBtnString, accessKey: primaryBtnString.attributes.accesskey, callback: primaryActionCallback, }; let secondaryActions = [ { label: secondaryBtnString, accessKey: secondaryBtnString.attributes.accesskey, callback: secondaryActionsCallback, }, ]; // Actually show the notification this.currentNotification = this.window.PopupNotifications.show( browser, POPUP_NOTIFICATION_ID, panelTitle, "cfr", mainAction, secondaryActions, { hideClose: true, persistWhileVisible: true, recordTelemetryInPrivateBrowsing: content.show_in_private_browsing, } ); Services.prefs.setIntPref( "browser.contentblocking.cfr-milestone.milestone-achieved", reachedMilestone ); Services.prefs.setStringPref( "browser.contentblocking.cfr-milestone.milestone-shown-time", Date.now().toString() ); } // eslint-disable-next-line max-statements async _renderPopup(message, browser) { this.maybeLoadCustomElement(this.window); const { id, content } = message; const headerLabel = this.window.document.getElementById( "cfr-notification-header-label" ); const headerLink = this.window.document.getElementById( "cfr-notification-header-link" ); const headerImage = this.window.document.getElementById( "cfr-notification-header-image" ); const footerText = this.window.document.getElementById( "cfr-notification-footer-text" ); const footerLink = this.window.document.getElementById( "cfr-notification-footer-learn-more-link" ); const { primary, secondary } = content.buttons; let primaryActionCallback; let persistent = !!content.persistent_doorhanger; let options = { persistent, persistWhileVisible: persistent, recordTelemetryInPrivateBrowsing: content.show_in_private_browsing, }; let panelTitle; headerLabel.value = await this.getStrings(content.heading_text); if (content.info_icon) { headerLink.setAttribute( "href", SUMO_BASE_URL + content.info_icon.sumo_path ); headerImage.setAttribute( "tooltiptext", await this.getStrings(content.info_icon.label, "tooltiptext") ); } headerLink.onclick = () => this._sendTelemetry({ message_id: id, bucket_id: content.bucket_id, event: "RATIONALE", }); // Use the message layout as a CSS selector to hide different parts of the // notification template markup this.window.document .getElementById("contextual-feature-recommendation-notification") .setAttribute("data-notification-category", content.layout); this.window.document .getElementById("contextual-feature-recommendation-notification") .setAttribute("data-notification-bucket", content.bucket_id); const author = this.window.document.getElementById( "cfr-notification-author" ); if (author.firstChild) { author.firstChild.remove(); } switch (content.layout) { case "icon_and_message": //Clearing content and styles that may have been set by a prior addon_recommendation CFR this._setAddonRating(this.window.document, content); author.appendChild( lazy.RemoteL10n.createElement(this.window.document, "span", { content: content.text, }) ); primaryActionCallback = () => { this._blockMessage(id); this.dispatchUserAction(primary.action); this.hideAddressBarNotifier(); this._sendTelemetry({ message_id: id, bucket_id: content.bucket_id, event: "ENABLE", }); RecommendationMap.delete(browser); }; let getIcon = () => { if (content.icon_dark_theme && this.isDarkTheme) { return content.icon_dark_theme; } return content.icon; }; let learnMoreURL = content.learn_more ? SUMO_BASE_URL + content.learn_more : null; panelTitle = await this.getStrings(content.heading_text); options = { popupIconURL: getIcon(), popupIconClass: content.icon_class, learnMoreURL, ...options, }; break; default: const authorText = await this.getStrings({ string_id: "cfr-doorhanger-extension-author", args: { name: content.addon.author }, }); panelTitle = await this.getStrings(content.addon.title); await this._setAddonRating(this.window.document, content); if (footerText.firstChild) { footerText.firstChild.remove(); } if (footerText.lastChild) { footerText.lastChild.remove(); } // Main body content of the dropdown footerText.appendChild( lazy.RemoteL10n.createElement(this.window.document, "span", { content: content.text, }) ); footerLink.value = await this.getStrings({ string_id: "cfr-doorhanger-extension-learn-more-link", }); footerLink.setAttribute("href", content.addon.amo_url); footerLink.onclick = () => this._sendTelemetry({ message_id: id, bucket_id: content.bucket_id, event: "LEARN_MORE", }); footerText.appendChild(footerLink); options = { popupIconURL: content.addon.icon, popupIconClass: content.icon_class, name: authorText, ...options, }; primaryActionCallback = async () => { primary.action.data.url = // eslint-disable-next-line no-use-before-define await CFRPageActions._fetchLatestAddonVersion(content.addon.id); this._blockMessage(id); this.dispatchUserAction(primary.action); this.hideAddressBarNotifier(); this._sendTelemetry({ message_id: id, bucket_id: content.bucket_id, event: "INSTALL", }); RecommendationMap.delete(browser); }; } const primaryBtnStrings = await this.getStrings(primary.label); const mainAction = { label: primaryBtnStrings, accessKey: primaryBtnStrings.attributes.accesskey, callback: primaryActionCallback, }; let _renderSecondaryButtonAction = async (event, button) => { let label = await this.getStrings(button.label); let { attributes } = label; return { label, accessKey: attributes.accesskey, callback: () => { if (button.action) { this.dispatchUserAction(button.action); } else { this._blockMessage(id); this.hideAddressBarNotifier(); RecommendationMap.delete(browser); } this._sendTelemetry({ message_id: id, bucket_id: content.bucket_id, event, }); // We want to collapse if needed when we dismiss this._collapse(); }, }; }; // For each secondary action, define default telemetry event const defaultSecondaryEvent = ["DISMISS", "BLOCK", "MANAGE"]; const secondaryActions = await Promise.all( secondary.map((button, i) => { return _renderSecondaryButtonAction( button.event || defaultSecondaryEvent[i], button ); }) ); // If the recommendation button is focused, it was probably activated via // the keyboard. Therefore, focus the first element in the notification when // it appears. // We don't use the autofocus option provided by PopupNotifications.show // because it doesn't focus the first element; i.e. the user still has to // press tab once. That's not good enough, especially for screen reader // users. Instead, we handle this ourselves in _popupStateChange. this._autoFocus = this.window.document.activeElement === this.container; // Actually show the notification this.currentNotification = this.window.PopupNotifications.show( browser, POPUP_NOTIFICATION_ID, panelTitle, "cfr", mainAction, secondaryActions, { ...options, hideClose: true, eventCallback: this._popupStateChange, } ); } _executeNotifierAction(browser, message) { switch (message.content.layout) { case "chiclet_open_url": this._dispatchCFRAction( { type: "USER_ACTION", data: { type: "OPEN_URL", data: { args: message.content.action.url, where: message.content.action.where, }, }, }, this.window ); break; } this._blockMessage(message.id); this.hideAddressBarNotifier(); RecommendationMap.delete(browser); } /** * Respond to a user click on the recommendation by showing a doorhanger/ * popup notification or running the action defined in the message */ async _cfrUrlbarButtonClick() { const browser = this.window.gBrowser.selectedBrowser; if (!RecommendationMap.has(browser)) { // There's no recommendation for this browser, so the user shouldn't have // been able to click this.hideAddressBarNotifier(); return; } const message = RecommendationMap.get(browser); const { id, content } = message; this._sendTelemetry({ message_id: id, bucket_id: content.bucket_id, event: "CLICK_DOORHANGER", }); if (this.shouldShowDoorhanger(message)) { // The recommendation should remain either collapsed or expanded while the // doorhanger is showing this._clearScheduledStateChanges(browser, message); await this.showPopup(); } else { await this._executeNotifierAction(browser, message); } } _getVisibleElement(idOrEl) { const element = typeof idOrEl === "string" ? idOrEl && this.window.document.getElementById(idOrEl) : idOrEl; if (!element) { return null; // element doesn't exist at all } const { visibility, display } = this.window.getComputedStyle(element); if ( !this.window.isElementVisible(element) || visibility !== "visible" || display === "none" ) { // CSS rules like visibility: hidden or display: none. these result in // element being invisible and unclickable. return null; } let widget = lazy.CustomizableUI.getWidget(idOrEl); if ( widget && (this.window.CustomizationHandler.isCustomizing() || widget.areaType?.includes("panel")) ) { // The element is a customizable widget (a toolbar item, e.g. the // reload button or the downloads button). Widgets can be in various // areas, like the overflow panel or the customization palette. // Widgets in the palette are present in the chrome's DOM during // customization, but can't be used. return null; } return element; } async showPopup() { const browser = this.window.gBrowser.selectedBrowser; const message = RecommendationMap.get(browser); const { content } = message; // A hacky way of setting the popup anchor outside the usual url bar icon box // See https://searchfox.org/mozilla-central/rev/eb07633057d66ab25f9db4c5900eeb6913da7579/toolkit/modules/PopupNotifications.sys.mjs#44 browser.cfrpopupnotificationanchor = this._getVisibleElement(content.anchor_id) || this._getVisibleElement(content.alt_anchor_id) || this._getVisibleElement(this.button) || this._getVisibleElement(this.container); await this._renderPopup(message, browser); } async showMilestonePopup() { const browser = this.window.gBrowser.selectedBrowser; const message = RecommendationMap.get(browser); const { content } = message; // A hacky way of setting the popup anchor outside the usual url bar icon box // See https://searchfox.org/mozilla-central/rev/eb07633057d66ab25f9db4c5900eeb6913da7579/toolkit/modules/PopupNotifications.sys.mjs#44 browser.cfrpopupnotificationanchor = this.window.document.getElementById(content.anchor_id) || this.container; await this._renderMilestonePopup(message, browser); return true; } } function isHostMatch(browser, host) { return ( browser.documentURI.scheme.startsWith("http") && browser.documentURI.host === host ); } export const CFRPageActions = { // For testing purposes RecommendationMap, PageActionMap, /** * To be called from browser.js on a location change, passing in the browser * that's been updated */ updatePageActions(browser) { const win = browser.ownerGlobal; const pageAction = PageActionMap.get(win); if (!pageAction || browser !== win.gBrowser.selectedBrowser) { return; } if (RecommendationMap.has(browser)) { const recommendation = RecommendationMap.get(browser); if ( !recommendation.content.skip_address_bar_notifier && (isHostMatch(browser, recommendation.host) || // If there is no host associated we assume we're back on a tab // that had a CFR message so we should show it again !recommendation.host) ) { // The browser has a recommendation specified with this host, so show // the page action pageAction.showAddressBarNotifier(recommendation); } else if (!recommendation.content.persistent_doorhanger) { if (recommendation.retain) { // Keep the recommendation first time the user navigates away just in // case they will go back to the previous page pageAction.hideAddressBarNotifier(); recommendation.retain = false; } else { // The user has navigated away from the specified host in the given // browser, so the recommendation is no longer valid and should be removed RecommendationMap.delete(browser); pageAction.hideAddressBarNotifier(); } } } else { // There's no recommendation specified for this browser, so hide the page action pageAction.hideAddressBarNotifier(); } }, /** * Fetch the URL to the latest add-on xpi so the recommendation can download it. * @param id The add-on ID * @return A string for the URL that was fetched */ async _fetchLatestAddonVersion(id) { let url = null; try { const response = await fetch(`${ADDONS_API_URL}/${id}/`, { credentials: "omit", }); if (response.status !== 204 && response.ok) { const json = await response.json(); url = json.current_version.files[0].url; } } catch (e) { console.error( "Failed to get the latest add-on version for this recommendation" ); } return url; }, /** * Force a recommendation to be shown. Should only happen via the Admin page. * @param browser The browser for the recommendation * @param recommendation The recommendation to show * @param dispatchCFRAction A function to dispatch resulting actions to * @return Did adding the recommendation succeed? */ async forceRecommendation(browser, recommendation, dispatchCFRAction) { if (!browser) { return false; } // If we are forcing via the Admin page, the browser comes in a different format const win = browser.ownerGlobal; const { id, content } = recommendation; RecommendationMap.set(browser, { id, content, retain: true, }); if (!PageActionMap.has(win)) { PageActionMap.set(win, new PageAction(win, dispatchCFRAction)); } if (content.skip_address_bar_notifier) { if (recommendation.template === "milestone_message") { await PageActionMap.get(win).showMilestonePopup(); PageActionMap.get(win).addImpression(recommendation); } else { await PageActionMap.get(win).showPopup(); PageActionMap.get(win).addImpression(recommendation); } } else { await PageActionMap.get(win).showAddressBarNotifier(recommendation, true); } return true; }, /** * Add a recommendation specific to the given browser and host. * @param browser The browser for the recommendation * @param host The host for the recommendation * @param recommendation The recommendation to show * @param dispatchCFRAction A function to dispatch resulting actions to * @return Did adding the recommendation succeed? */ async addRecommendation(browser, host, recommendation, dispatchCFRAction) { if (!browser) { return false; } const win = browser.ownerGlobal; if ( browser !== win.gBrowser.selectedBrowser || // We can have recommendations without URL restrictions (host && !isHostMatch(browser, host)) ) { return false; } if (RecommendationMap.has(browser)) { // Don't replace an existing message return false; } const { id, content } = recommendation; if ( !content.show_in_private_browsing && lazy.PrivateBrowsingUtils.isWindowPrivate(win) ) { return false; } RecommendationMap.set(browser, { id, host, content, retain: true, }); if (!PageActionMap.has(win)) { PageActionMap.set(win, new PageAction(win, dispatchCFRAction)); } if (content.skip_address_bar_notifier) { if (recommendation.template === "milestone_message") { await PageActionMap.get(win).showMilestonePopup(); PageActionMap.get(win).addImpression(recommendation); } else { // Tracking protection messages await PageActionMap.get(win).showPopup(); PageActionMap.get(win).addImpression(recommendation); } } else { // Doorhanger messages await PageActionMap.get(win).showAddressBarNotifier(recommendation, true); } return true; }, /** * Clear all recommendations and hide all PageActions */ clearRecommendations() { // WeakMaps aren't iterable so we have to test all existing windows for (const win of Services.wm.getEnumerator("navigator:browser")) { if (win.closed || !PageActionMap.has(win)) { continue; } PageActionMap.get(win).hideAddressBarNotifier(); } // WeakMaps don't have a `clear` method PageActionMap = new WeakMap(); RecommendationMap = new WeakMap(); this.PageActionMap = PageActionMap; this.RecommendationMap = RecommendationMap; }, /** * Reload the l10n Fluent files for all PageActions */ reloadL10n() { for (const win of Services.wm.getEnumerator("navigator:browser")) { if (win.closed || !PageActionMap.has(win)) { continue; } PageActionMap.get(win).reloadL10n(); } }, };