diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/newtab/lib/CFRPageActions.jsm | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/lib/CFRPageActions.jsm')
-rw-r--r-- | browser/components/newtab/lib/CFRPageActions.jsm | 1049 |
1 files changed, 1049 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/CFRPageActions.jsm b/browser/components/newtab/lib/CFRPageActions.jsm new file mode 100644 index 0000000000..2051e5f5f7 --- /dev/null +++ b/browser/components/newtab/lib/CFRPageActions.jsm @@ -0,0 +1,1049 @@ +/* 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"; + +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://activity-stream/lib/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 + */ +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 = []; + + XPCOMUtils.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 + ); + } + + // 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 <esc> + _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; + 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; + 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 = new Date().getTime(earliestDate); + 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); + + switch (content.layout) { + case "icon_and_message": + const author = this.window.document.getElementById( + "cfr-notification-author" + ); + if (author.firstChild) { + author.firstChild.remove(); + } + 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(event) { + 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); + } + } + + async showPopup() { + const browser = this.window.gBrowser.selectedBrowser; + const message = RecommendationMap.get(browser); + const { content } = message; + let anchor; + + // A hacky way of setting the popup anchor outside the usual url bar icon box + // See https://searchfox.org/mozilla-central/rev/847b64cc28b74b44c379f9bff4f415b97da1c6d7/toolkit/modules/PopupNotifications.jsm#42 + //If the anchor has been moved to the overflow menu ('menu-panel') and an alt_anchor_id has been provided, we want to use the alt_anchor_id + + if ( + content.alt_anchor_id && + lazy.CustomizableUI.getWidget(content.anchor_id).areaType.includes( + "panel" + ) + ) { + anchor = this.window.document.getElementById(content.alt_anchor_id); + } else { + anchor = + this.window.document.getElementById(content.anchor_id) || + this.container; + } + browser.cfrpopupnotificationanchor = anchor; + + 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/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/PopupNotifications.sys.mjs#40 + 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 + ); +} + +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(); + } + }, +}; + +const EXPORTED_SYMBOLS = ["CFRPageActions", "PageAction"]; |