From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../components/asrouter/modules/ASRouter.sys.mjs | 2079 +++++++++++++++++++ .../asrouter/modules/ASRouterDefaultConfig.sys.mjs | 64 + .../asrouter/modules/ASRouterNewTabHook.sys.mjs | 117 ++ .../ASRouterParentProcessMessageHandler.sys.mjs | 171 ++ .../asrouter/modules/ASRouterPreferences.sys.mjs | 241 +++ .../asrouter/modules/ASRouterTargeting.sys.mjs | 1308 ++++++++++++ .../modules/ASRouterTriggerListeners.sys.mjs | 1439 ++++++++++++++ .../asrouter/modules/ActorConstants.sys.mjs | 49 + .../asrouter/modules/CFRMessageProvider.sys.mjs | 820 ++++++++ .../asrouter/modules/CFRPageActions.sys.mjs | 1086 ++++++++++ .../asrouter/modules/FeatureCallout.sys.mjs | 2100 ++++++++++++++++++++ .../asrouter/modules/FeatureCalloutBroker.sys.mjs | 215 ++ .../modules/FeatureCalloutMessages.sys.mjs | 1299 ++++++++++++ .../components/asrouter/modules/InfoBar.sys.mjs | 169 ++ .../modules/MessagingExperimentConstants.sys.mjs | 37 + .../asrouter/modules/MomentsPageHub.sys.mjs | 171 ++ .../modules/OnboardingMessageProvider.sys.mjs | 1414 +++++++++++++ .../asrouter/modules/PageEventManager.sys.mjs | 135 ++ .../asrouter/modules/PanelTestProvider.sys.mjs | 771 +++++++ .../components/asrouter/modules/RemoteL10n.sys.mjs | 249 +++ .../components/asrouter/modules/Spotlight.sys.mjs | 78 + .../asrouter/modules/ToastNotification.sys.mjs | 138 ++ .../asrouter/modules/ToolbarBadgeHub.sys.mjs | 308 +++ .../asrouter/modules/ToolbarPanelHub.sys.mjs | 544 +++++ 24 files changed, 15002 insertions(+) create mode 100644 browser/components/asrouter/modules/ASRouter.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterPreferences.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterTargeting.sys.mjs create mode 100644 browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs create mode 100644 browser/components/asrouter/modules/ActorConstants.sys.mjs create mode 100644 browser/components/asrouter/modules/CFRMessageProvider.sys.mjs create mode 100644 browser/components/asrouter/modules/CFRPageActions.sys.mjs create mode 100644 browser/components/asrouter/modules/FeatureCallout.sys.mjs create mode 100644 browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs create mode 100644 browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs create mode 100644 browser/components/asrouter/modules/InfoBar.sys.mjs create mode 100644 browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs create mode 100644 browser/components/asrouter/modules/MomentsPageHub.sys.mjs create mode 100644 browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs create mode 100644 browser/components/asrouter/modules/PageEventManager.sys.mjs create mode 100644 browser/components/asrouter/modules/PanelTestProvider.sys.mjs create mode 100644 browser/components/asrouter/modules/RemoteL10n.sys.mjs create mode 100644 browser/components/asrouter/modules/Spotlight.sys.mjs create mode 100644 browser/components/asrouter/modules/ToastNotification.sys.mjs create mode 100644 browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs create mode 100644 browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs (limited to 'browser/components/asrouter/modules') diff --git a/browser/components/asrouter/modules/ASRouter.sys.mjs b/browser/components/asrouter/modules/ASRouter.sys.mjs new file mode 100644 index 0000000000..f6657a39b9 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouter.sys.mjs @@ -0,0 +1,2079 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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, AppConstants and RemoteSettings, 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" +); + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ASRouterPreferences: + "resource:///modules/asrouter/ASRouterPreferences.sys.mjs", + ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + ASRouterTriggerListeners: + "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs", + AttributionCode: "resource:///modules/AttributionCode.sys.mjs", + Downloader: "resource://services-settings/Attachments.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + FeatureCalloutBroker: + "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs", + InfoBar: "resource:///modules/asrouter/InfoBar.sys.mjs", + KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs", + MacAttribution: "resource:///modules/MacAttribution.sys.mjs", + MomentsPageHub: "resource:///modules/asrouter/MomentsPageHub.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PanelTestProvider: "resource:///modules/asrouter/PanelTestProvider.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", + TARGETING_PREFERENCES: + "resource:///modules/asrouter/ASRouterPreferences.sys.mjs", + Utils: "resource://services-settings/Utils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + Spotlight: "resource:///modules/asrouter/Spotlight.sys.mjs", + ToastNotification: "resource:///modules/asrouter/ToastNotification.sys.mjs", + ToolbarBadgeHub: "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs", + ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"], +}); +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("ASRouter"); +}); +import { actionCreators as ac } from "resource://activity-stream/common/Actions.sys.mjs"; +import { MESSAGING_EXPERIMENTS_DEFAULT_FEATURES } from "resource:///modules/asrouter/MessagingExperimentConstants.sys.mjs"; +import { CFRMessageProvider } from "resource:///modules/asrouter/CFRMessageProvider.sys.mjs"; +import { OnboardingMessageProvider } from "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs"; +import { CFRPageActions } from "resource:///modules/asrouter/CFRPageActions.sys.mjs"; + +// List of hosts for endpoints that serve router messages. +// Key is allowed host, value is a name for the endpoint host. +const DEFAULT_ALLOWLIST_HOSTS = { + "activity-stream-icons.services.mozilla.com": "production", +}; +// Max possible impressions cap for any message +const MAX_MESSAGE_LIFETIME_CAP = 100; + +const LOCAL_MESSAGE_PROVIDERS = { + OnboardingMessageProvider, + CFRMessageProvider, +}; +const STARTPAGE_VERSION = "6"; + +// Remote Settings +const RS_MAIN_BUCKET = "main"; +const RS_COLLECTION_L10N = "ms-language-packs"; // "ms" stands for Messaging System +const RS_PROVIDERS_WITH_L10N = ["cfr"]; +const RS_FLUENT_VERSION = "v1"; +const RS_FLUENT_RECORD_PREFIX = `cfr-${RS_FLUENT_VERSION}`; +const RS_DOWNLOAD_MAX_RETRIES = 2; +// This is the list of providers for which we want to cache the targeting +// expression result and reuse between calls. Cache duration is defined in +// ASRouterTargeting where evaluation takes place. +const JEXL_PROVIDER_CACHE = new Set(); + +// To observe the app locale change notification. +const TOPIC_INTL_LOCALE_CHANGED = "intl:app-locales-changed"; +const TOPIC_EXPERIMENT_ENROLLMENT_CHANGED = "nimbus:enrollments-updated"; +// To observe the pref that controls if ASRouter should use the remote Fluent files for l10n. +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +// Experiment groups that need to report the reach event in Messaging-Experiments. +// If you're adding new groups to it, make sure they're also added in the +// `messaging_experiments.reach.objects` defined in "toolkit/components/telemetry/Events.yaml" +const REACH_EVENT_GROUPS = [ + "cfr", + "moments-page", + "infobar", + "spotlight", + "featureCallout", +]; +const REACH_EVENT_CATEGORY = "messaging_experiments"; +const REACH_EVENT_METHOD = "reach"; + +export const MessageLoaderUtils = { + STARTPAGE_VERSION, + REMOTE_LOADER_CACHE_KEY: "RemoteLoaderCache", + _errors: [], + + reportError(e) { + console.error(e); + this._errors.push({ + timestamp: new Date(), + error: { message: e.toString(), stack: e.stack }, + }); + }, + + get errors() { + const errors = this._errors; + this._errors = []; + return errors; + }, + + /** + * _localLoader - Loads messages for a local provider (i.e. one that lives in mozilla central) + * + * @param {obj} provider An AS router provider + * @param {Array} provider.messages An array of messages + * @returns {Array} the array of messages + */ + _localLoader(provider) { + return provider.messages; + }, + + async _remoteLoaderCache(storage) { + let allCached; + try { + allCached = + (await storage.get(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY)) || {}; + } catch (e) { + // istanbul ignore next + MessageLoaderUtils.reportError(e); + // istanbul ignore next + allCached = {}; + } + return allCached; + }, + + /** + * _remoteLoader - Loads messages for a remote provider + * + * @param {obj} provider An AS router provider + * @param {string} provider.url An endpoint that returns an array of messages as JSON + * @param {obj} options.storage A storage object with get() and set() methods for caching. + * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched + */ + async _remoteLoader(provider, options) { + let remoteMessages = []; + if (provider.url) { + const allCached = await MessageLoaderUtils._remoteLoaderCache( + options.storage + ); + const cached = allCached[provider.id]; + let etag; + + if ( + cached && + cached.url === provider.url && + cached.version === STARTPAGE_VERSION + ) { + const { lastFetched, messages } = cached; + if ( + !MessageLoaderUtils.shouldProviderUpdate({ + ...provider, + lastUpdated: lastFetched, + }) + ) { + // Cached messages haven't expired, return early. + return messages; + } + etag = cached.etag; + remoteMessages = messages; + } + + let headers = new Headers(); + if (etag) { + headers.set("If-None-Match", etag); + } + + let response; + try { + response = await fetch(provider.url, { + headers, + credentials: "omit", + }); + } catch (e) { + MessageLoaderUtils.reportError(e); + } + if ( + response && + response.ok && + response.status >= 200 && + response.status < 400 + ) { + let jsonResponse; + try { + jsonResponse = await response.json(); + } catch (e) { + MessageLoaderUtils.reportError(e); + return remoteMessages; + } + if (jsonResponse && jsonResponse.messages) { + remoteMessages = jsonResponse.messages.map(msg => ({ + ...msg, + provider_url: provider.url, + })); + + // Cache the results if this isn't a preview URL. + if (provider.updateCycleInMs > 0) { + etag = response.headers.get("ETag"); + const cacheInfo = { + messages: remoteMessages, + etag, + lastFetched: Date.now(), + version: STARTPAGE_VERSION, + }; + + options.storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, { + ...allCached, + [provider.id]: cacheInfo, + }); + } + } else { + MessageLoaderUtils.reportError( + `No messages returned from ${provider.url}.` + ); + } + } else if (response) { + MessageLoaderUtils.reportError( + `Invalid response status ${response.status} from ${provider.url}.` + ); + } + } + return remoteMessages; + }, + + /** + * _remoteSettingsLoader - Loads messages for a RemoteSettings provider + * + * Note: + * 1). The "cfr" provider requires the Fluent file for l10n, so there is + * another file downloading phase for those two providers after their messages + * are successfully fetched from Remote Settings. Currently, they share the same + * attachment of the record "${RS_FLUENT_RECORD_PREFIX}-${locale}" in the + * "ms-language-packs" collection. E.g. for "en-US" with version "v1", + * the Fluent file is attched to the record with ID "cfr-v1-en-US". + * + * 2). The Remote Settings downloader is able to detect the duplicate download + * requests for the same attachment and ignore the redundent requests automatically. + * + * @param {object} provider An AS router provider + * @param {string} provider.id The id of the provider + * @param {string} provider.collection Remote Settings collection name + * @param {object} options + * @param {function} options.dispatchCFRAction Action handler function + * @returns {Promise} Resolves with an array of messages, or an + * empty array if none could be fetched + */ + async _remoteSettingsLoader(provider, options) { + let messages = []; + if (provider.collection) { + try { + messages = await MessageLoaderUtils._getRemoteSettingsMessages( + provider.collection + ); + if (!messages.length) { + MessageLoaderUtils._handleRemoteSettingsUndesiredEvent( + "ASR_RS_NO_MESSAGES", + provider.id, + options.dispatchCFRAction + ); + } else if ( + RS_PROVIDERS_WITH_L10N.includes(provider.id) && + lazy.RemoteL10n.isLocaleSupported(MessageLoaderUtils.locale) + ) { + const recordId = `${RS_FLUENT_RECORD_PREFIX}-${MessageLoaderUtils.locale}`; + const kinto = new lazy.KintoHttpClient(lazy.Utils.SERVER_URL); + const record = await kinto + .bucket(RS_MAIN_BUCKET) + .collection(RS_COLLECTION_L10N) + .getRecord(recordId); + if (record && record.data) { + const downloader = new lazy.Downloader( + RS_MAIN_BUCKET, + RS_COLLECTION_L10N, + "browser", + "newtab" + ); + // Await here in order to capture the exceptions for reporting. + await downloader.downloadToDisk(record.data, { + retries: RS_DOWNLOAD_MAX_RETRIES, + }); + lazy.RemoteL10n.reloadL10n(); + } else { + MessageLoaderUtils._handleRemoteSettingsUndesiredEvent( + "ASR_RS_NO_MESSAGES", + RS_COLLECTION_L10N, + options.dispatchCFRAction + ); + } + } + } catch (e) { + MessageLoaderUtils._handleRemoteSettingsUndesiredEvent( + "ASR_RS_ERROR", + provider.id, + options.dispatchCFRAction + ); + MessageLoaderUtils.reportError(e); + } + } + return messages; + }, + + /** + * Fetch messages from a given collection in Remote Settings. + * + * @param {string} collection The remote settings collection identifier + * @returns {Promise} Resolves with an array of messages + */ + _getRemoteSettingsMessages(collection) { + return RemoteSettings(collection).get(); + }, + + /** + * Return messages from active Nimbus experiments and rollouts. + * + * @param {object} provider A messaging experiments provider. + * @param {string[]?} provider.featureIds + * An optional array of Nimbus feature IDs to check for + * enrollments. If not provided, we will fall back to the + * set of default features. Otherwise, if provided and + * empty, we will not ingest messages from any features. + * + * @return {object[]} The list of messages from active enrollments, as well as + * the messages defined in unenrolled branches so that they + * reach events can be recorded (if we record reach events + * for that feature). + */ + async _experimentsAPILoader(provider) { + // Allow tests to override the set of featureIds + const featureIds = Array.isArray(provider.featureIds) + ? provider.featureIds + : MESSAGING_EXPERIMENTS_DEFAULT_FEATURES; + let experiments = []; + for (const featureId of featureIds) { + const featureAPI = lazy.NimbusFeatures[featureId]; + const experimentData = lazy.ExperimentAPI.getExperimentMetaData({ + featureId, + }); + + // We are not enrolled in any experiment or rollout for this feature, so + // we can skip the feature. + if ( + !experimentData && + !lazy.ExperimentAPI.getRolloutMetaData({ featureId }) + ) { + continue; + } + + const featureValue = featureAPI.getAllVariables(); + + // If the value is a multi-message config, add each message in the + // messages array. Cache the Nimbus feature ID on each message, because + // there is not a 1-1 correspondance between templates and features. + // This is used when recording expose events (see |sendTriggerMessage|). + const messages = + featureValue?.template === "multi" && + Array.isArray(featureValue.messages) + ? featureValue.messages + : [featureValue]; + for (const message of messages) { + if (message?.id) { + message._nimbusFeature = featureId; + experiments.push(message); + } + } + + // Add Reach messages from unenrolled sibling branches, provided we are + // recording Reach events for this feature. If we are in a rollout, we do + // not have sibling branches. + if (!REACH_EVENT_GROUPS.includes(featureId) || !experimentData) { + continue; + } + + // Check other sibling branches for triggers, add them to the return array + // if found any. The `forReachEvent` label is used to identify those + // branches so that they would only be used to record the Reach event. + const branches = + (await lazy.ExperimentAPI.getAllBranches(experimentData.slug)) || []; + for (const branch of branches) { + let branchValue = branch[featureId].value; + if (!branchValue || branch.slug === experimentData.branch.slug) { + continue; + } + const branchMessages = + branchValue?.template === "multi" && + Array.isArray(branchValue.messages) + ? branchValue.messages + : [branchValue]; + for (const message of branchMessages) { + if (!message?.trigger) { + continue; + } + experiments.push({ + forReachEvent: { sent: false, group: featureId }, + experimentSlug: experimentData.slug, + branchSlug: branch.slug, + ...message, + }); + } + } + } + + return experiments; + }, + + _handleRemoteSettingsUndesiredEvent(event, providerId, dispatchCFRAction) { + if (dispatchCFRAction) { + dispatchCFRAction( + ac.ASRouterUserEvent({ + action: "asrouter_undesired_event", + event, + message_id: "n/a", + event_context: providerId, + }) + ); + } + }, + + /** + * _getMessageLoader - return the right loading function given the provider's type + * + * @param {obj} provider An AS Router provider + * @returns {func} A loading function + */ + _getMessageLoader(provider) { + switch (provider.type) { + case "remote": + return this._remoteLoader; + case "remote-settings": + return this._remoteSettingsLoader; + case "remote-experiments": + return this._experimentsAPILoader; + case "local": + default: + return this._localLoader; + } + }, + + /** + * shouldProviderUpdate - Given the current time, should a provider update its messages? + * + * @param {any} provider An AS Router provider + * @param {int} provider.updateCycleInMs The number of milliseconds we should wait between updates + * @param {Date} provider.lastUpdated If the provider has been updated, the time the last update occurred + * @param {Date} currentTime The time we should check against. (defaults to Date.now()) + * @returns {bool} Should an update happen? + */ + shouldProviderUpdate(provider, currentTime = Date.now()) { + return ( + !(provider.lastUpdated >= 0) || + currentTime - provider.lastUpdated > provider.updateCycleInMs + ); + }, + + async _loadDataForProvider(provider, options) { + const loader = this._getMessageLoader(provider); + let messages = await loader(provider, options); + // istanbul ignore if + if (!messages) { + messages = []; + MessageLoaderUtils.reportError( + new Error( + `Tried to load messages for ${provider.id} but the result was not an Array.` + ) + ); + } + + return { messages }; + }, + + /** + * loadMessagesForProvider - Load messages for a provider, given the provider's type. + * + * @param {obj} provider An AS Router provider + * @param {string} provider.type An AS Router provider type (defaults to "local") + * @param {obj} options.storage A storage object with get() and set() methods for caching. + * @param {func} options.dispatchCFRAction dispatch an action the main AS Store + * @returns {obj} Returns an object with .messages (an array of messages) and .lastUpdated (the time the messages were updated) + */ + async loadMessagesForProvider(provider, options) { + let { messages } = await this._loadDataForProvider(provider, options); + // Filter out messages we temporarily want to exclude + if (provider.exclude && provider.exclude.length) { + messages = messages.filter( + message => !provider.exclude.includes(message.id) + ); + } + const lastUpdated = Date.now(); + return { + messages: messages + .map(messageData => { + const message = { + weight: 100, + ...messageData, + groups: messageData.groups || [], + provider: provider.id, + }; + + return message; + }) + .filter(message => message.weight > 0), + lastUpdated, + errors: MessageLoaderUtils.errors, + }; + }, + + /** + * cleanupCache - Removes cached data of removed providers. + * + * @param {Array} providers A list of activer AS Router providers + */ + async cleanupCache(providers, storage) { + const ids = providers.filter(p => p.type === "remote").map(p => p.id); + const cache = await MessageLoaderUtils._remoteLoaderCache(storage); + let dirty = false; + for (let id in cache) { + if (!ids.includes(id)) { + delete cache[id]; + dirty = true; + } + } + if (dirty) { + await storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, cache); + } + }, + + /** + * The locale to use for RemoteL10n. + * + * This may map the app's actual locale into something that RemoteL10n + * supports. + */ + get locale() { + const localeMap = { + "ja-JP-macos": "ja-JP-mac", + + // While it's not a valid locale, "und" is commonly observed on + // Linux platforms. Per l10n team, it's reasonable to fallback to + // "en-US", therefore, we should allow the fetch for it. + und: "en-US", + }; + + const locale = Services.locale.appLocaleAsBCP47; + return localeMap[locale] ?? locale; + }, +}; + +/** + * @class _ASRouter - Keeps track of all messages, UI surfaces, and + * handles blocking, rotation, etc. Inspecting ASRouter.state will + * tell you what the current displayed message is in all UI surfaces. + * + * Note: This is written as a constructor rather than just a plain object + * so that it can be more easily unit tested. + */ +export class _ASRouter { + constructor(localProviders = LOCAL_MESSAGE_PROVIDERS) { + this.initialized = false; + this.clearChildMessages = null; + this.clearChildProviders = null; + this.updateAdminState = null; + this.sendTelemetry = null; + this.dispatchCFRAction = null; + this._storage = null; + this._resetInitialization(); + this._state = { + providers: [], + messageBlockList: [], + messageImpressions: {}, + screenImpressions: {}, + messages: [], + groups: [], + errors: [], + localeInUse: Services.locale.appLocaleAsBCP47, + }; + this._experimentChangedListeners = new Map(); + this._triggerHandler = this._triggerHandler.bind(this); + this._localProviders = localProviders; + this.blockMessageById = this.blockMessageById.bind(this); + this.unblockMessageById = this.unblockMessageById.bind(this); + this.handleMessageRequest = this.handleMessageRequest.bind(this); + this.addImpression = this.addImpression.bind(this); + this.addScreenImpression = this.addScreenImpression.bind(this); + this._handleTargetingError = this._handleTargetingError.bind(this); + this.onPrefChange = this.onPrefChange.bind(this); + this._onLocaleChanged = this._onLocaleChanged.bind(this); + this.isUnblockedMessage = this.isUnblockedMessage.bind(this); + this.unblockAll = this.unblockAll.bind(this); + this.forceWNPanel = this.forceWNPanel.bind(this); + this._onExperimentEnrollmentsUpdated = + this._onExperimentEnrollmentsUpdated.bind(this); + this.forcePBWindow = this.forcePBWindow.bind(this); + Services.telemetry.setEventRecordingEnabled(REACH_EVENT_CATEGORY, true); + this.messagesEnabledInAutomation = []; + } + + async onPrefChange(prefName) { + if (lazy.TARGETING_PREFERENCES.includes(prefName)) { + let invalidMessages = []; + // Notify all tabs of messages that have become invalid after pref change + const context = this._getMessagesContext(); + const targetingContext = new lazy.TargetingContext(context); + + for (const msg of this.state.messages.filter(this.isUnblockedMessage)) { + if (!msg.targeting) { + continue; + } + const isMatch = await targetingContext.evalWithDefault(msg.targeting); + if (!isMatch) { + invalidMessages.push(msg.id); + } + } + this.clearChildMessages(invalidMessages); + } else { + // Update message providers and fetch new messages on pref change + this._loadLocalProviders(); + let invalidProviders = await this._updateMessageProviders(); + if (invalidProviders.length) { + this.clearChildProviders(invalidProviders); + } + await this.loadMessagesFromAllProviders(); + // Any change in user prefs can disable or enable groups + await this.setState(state => ({ + groups: state.groups.map(this._checkGroupEnabled), + })); + } + } + + // Fetch and decode the message provider pref JSON, and update the message providers + async _updateMessageProviders() { + lazy.ASRouterPreferences.console.debug("entering updateMessageProviders"); + + const previousProviders = this.state.providers; + const providers = await Promise.all( + [ + // If we have added a `preview` provider, hold onto it + ...previousProviders.filter(p => p.id === "preview"), + // The provider should be enabled and not have a user preference set to false + ...lazy.ASRouterPreferences.providers.filter( + p => + p.enabled && + lazy.ASRouterPreferences.getUserPreference(p.id) !== false + ), + ].map(async _provider => { + // make a copy so we don't modify the source of the pref + const provider = { ..._provider }; + + if (provider.type === "local" && !provider.messages) { + // Get the messages from the local message provider + const localProvider = this._localProviders[provider.localProvider]; + provider.messages = []; + if (localProvider) { + provider.messages = await localProvider.getMessages(); + } + } + if (provider.type === "remote" && provider.url) { + provider.url = provider.url.replace( + /%STARTPAGE_VERSION%/g, + STARTPAGE_VERSION + ); + provider.url = Services.urlFormatter.formatURL(provider.url); + } + if (provider.id === "messaging-experiments") { + // By default, the messaging-experiments provider lacks a featureIds + // property, so fall back to the list of default features. + if (!provider.featureIds) { + provider.featureIds = MESSAGING_EXPERIMENTS_DEFAULT_FEATURES; + } + } + // Reset provider update timestamp to force message refresh + provider.lastUpdated = undefined; + return provider; + }) + ); + + const providerIDs = providers.map(p => p.id); + let invalidProviders = []; + + // Clear old messages for providers that are no longer enabled + for (const prevProvider of previousProviders) { + if (!providerIDs.includes(prevProvider.id)) { + invalidProviders.push(prevProvider.id); + } + } + + return this.setState(prevState => ({ + providers, + // Clear any messages from removed providers + messages: [ + ...prevState.messages.filter(message => + providerIDs.includes(message.provider) + ), + ], + })).then(() => invalidProviders); + } + + get state() { + return this._state; + } + + set state(value) { + throw new Error( + "Do not modify this.state directy. Instead, call this.setState(newState)" + ); + } + + /** + * _resetInitialization - adds the following to the instance: + * .initialized {bool} Has AS Router been initialized? + * .waitForInitialized {Promise} A promise that resolves when initializion is complete + * ._finishInitializing {func} A function that, when called, resolves the .waitForInitialized + * promise and sets .initialized to true. + * @memberof _ASRouter + */ + _resetInitialization() { + this.initialized = false; + this.initializing = false; + this.waitForInitialized = new Promise(resolve => { + this._finishInitializing = () => { + this.initialized = true; + this.initializing = false; + resolve(); + }; + }); + } + + /** + * Check all provided groups are enabled. + * @param groups Set of groups to verify + * @returns bool + */ + hasGroupsEnabled(groups = []) { + return this.state.groups + .filter(({ id }) => groups.includes(id)) + .every(({ enabled }) => enabled); + } + + /** + * Verify that the provider block the message through the `exclude` field + * @param message Message to verify + * @returns bool + */ + isExcludedByProvider(message) { + const provider = this.state.providers.find(p => p.id === message.provider); + if (!provider) { + return true; + } + if (provider.exclude) { + return provider.exclude.includes(message.id); + } + return false; + } + + /** + * Takes a group and sets the correct `enabled` state based on message config + * and user preferences + * + * @param {GroupConfig} group + * @returns {GroupConfig} + */ + _checkGroupEnabled(group) { + return { + ...group, + enabled: + group.enabled && + // And if defined user preferences are true. If multiple prefs are + // defined then at least one has to be enabled. + (Array.isArray(group.userPreferences) + ? group.userPreferences.some(pref => + lazy.ASRouterPreferences.getUserPreference(pref) + ) + : true), + }; + } + + /** + * Fetch all message groups and update Router.state.groups. + * There are two cases to consider: + * 1. The provider needs to update as determined by the update cycle + * 2. Some pref change occured which could invalidate one of the existing + * groups. + */ + async loadAllMessageGroups() { + const provider = this.state.providers.find( + p => + p.id === "message-groups" && MessageLoaderUtils.shouldProviderUpdate(p) + ); + let remoteMessages = null; + if (provider) { + const { messages } = await MessageLoaderUtils._loadDataForProvider( + provider, + { + storage: this._storage, + dispatchCFRAction: this.dispatchCFRAction, + } + ); + remoteMessages = messages; + } + await this.setState(state => ({ + // If fetching remote messages fails we default to existing state.groups. + groups: (remoteMessages || state.groups).map(this._checkGroupEnabled), + })); + } + + /** + * loadMessagesFromAllProviders - Loads messages from all providers if they require updates. + * Checks the .lastUpdated field on each provider to see if updates are needed + * @param toUpdate An optional list of providers to update. This overrides + * the checks to determine which providers to update. + * @memberof _ASRouter + */ + async loadMessagesFromAllProviders(toUpdate = undefined) { + const needsUpdate = Array.isArray(toUpdate) + ? toUpdate + : this.state.providers.filter(provider => + MessageLoaderUtils.shouldProviderUpdate(provider) + ); + lazy.ASRouterPreferences.console.debug( + "entering loadMessagesFromAllProviders" + ); + + await this.loadAllMessageGroups(); + // Don't do extra work if we don't need any updates + if (needsUpdate.length) { + let newState = { messages: [], providers: [] }; + for (const provider of this.state.providers) { + if (needsUpdate.includes(provider)) { + const { messages, lastUpdated, errors } = + await MessageLoaderUtils.loadMessagesForProvider(provider, { + storage: this._storage, + dispatchCFRAction: this.dispatchCFRAction, + }); + newState.providers.push({ ...provider, lastUpdated, errors }); + newState.messages = [...newState.messages, ...messages]; + } else { + // Skip updating this provider's messages if no update is required + let messages = this.state.messages.filter( + msg => msg.provider === provider.id + ); + newState.providers.push(provider); + newState.messages = [...newState.messages, ...messages]; + } + } + + // Some messages have triggers that require us to initalise trigger listeners + const unseenListeners = new Set(lazy.ASRouterTriggerListeners.keys()); + for (const { trigger } of newState.messages) { + if (trigger && lazy.ASRouterTriggerListeners.has(trigger.id)) { + lazy.ASRouterTriggerListeners.get(trigger.id).init( + this._triggerHandler, + trigger.params, + trigger.patterns + ); + unseenListeners.delete(trigger.id); + } + } + // We don't need these listeners, but they may have previously been + // initialised, so uninitialise them + for (const triggerID of unseenListeners) { + lazy.ASRouterTriggerListeners.get(triggerID).uninit(); + } + + await this.setState(newState); + await this.cleanupImpressions(); + } + + await this._fireMessagesLoadedTrigger(); + + return this.state; + } + + async _fireMessagesLoadedTrigger() { + const win = Services.wm.getMostRecentBrowserWindow() ?? null; + const browser = win?.gBrowser?.selectedBrowser ?? null; + // pass skipLoadingMessages to avoid infinite recursion. pass browser and + // window into context so messages that may need a window or browser can + // target accordingly. + await this.sendTriggerMessage( + { + id: "messagesLoaded", + browser, + context: { browser, browserWindow: win }, + }, + true + ); + } + + async _maybeUpdateL10nAttachment() { + const { localeInUse } = this.state.localeInUse; + const newLocale = Services.locale.appLocaleAsBCP47; + if (newLocale !== localeInUse) { + const providers = [...this.state.providers]; + let needsUpdate = false; + providers.forEach(provider => { + if (RS_PROVIDERS_WITH_L10N.includes(provider.id)) { + // Force to refresh the messages as well as the attachment. + provider.lastUpdated = undefined; + needsUpdate = true; + } + }); + if (needsUpdate) { + await this.setState({ + localeInUse: newLocale, + providers, + }); + await this.loadMessagesFromAllProviders(); + } + } + return this.state; + } + + async _onLocaleChanged(subject, topic, data) { + await this._maybeUpdateL10nAttachment(); + } + + observe(aSubject, aTopic, aPrefName) { + switch (aPrefName) { + case USE_REMOTE_L10N_PREF: + CFRPageActions.reloadL10n(); + break; + } + } + + toWaitForInitFunc(func) { + return (...args) => this.waitForInitialized.then(() => func(...args)); + } + + /** + * init - Initializes the MessageRouter. + * + * @param {obj} parameters parameters to initialize ASRouter + * @memberof _ASRouter + */ + async init({ + storage, + sendTelemetry, + clearChildMessages, + clearChildProviders, + updateAdminState, + dispatchCFRAction, + }) { + if (this.initializing || this.initialized) { + return null; + } + this.initializing = true; + this._storage = storage; + this.ALLOWLIST_HOSTS = this._loadAllowHosts(); + this.clearChildMessages = this.toWaitForInitFunc(clearChildMessages); + this.clearChildProviders = this.toWaitForInitFunc(clearChildProviders); + // NOTE: This is only necessary to sync devtools when devtools is active. + this.updateAdminState = this.toWaitForInitFunc(updateAdminState); + this.sendTelemetry = sendTelemetry; + this.dispatchCFRAction = this.toWaitForInitFunc(dispatchCFRAction); + + lazy.ASRouterPreferences.init(); + lazy.ASRouterPreferences.addListener(this.onPrefChange); + lazy.ToolbarBadgeHub.init(this.waitForInitialized, { + handleMessageRequest: this.handleMessageRequest, + addImpression: this.addImpression, + blockMessageById: this.blockMessageById, + unblockMessageById: this.unblockMessageById, + sendTelemetry: this.sendTelemetry, + }); + lazy.ToolbarPanelHub.init(this.waitForInitialized, { + getMessages: this.handleMessageRequest, + sendTelemetry: this.sendTelemetry, + }); + lazy.MomentsPageHub.init(this.waitForInitialized, { + handleMessageRequest: this.handleMessageRequest, + addImpression: this.addImpression, + blockMessageById: this.blockMessageById, + sendTelemetry: this.sendTelemetry, + }); + + this._loadLocalProviders(); + + const messageBlockList = + (await this._storage.get("messageBlockList")) || []; + const messageImpressions = + (await this._storage.get("messageImpressions")) || {}; + const groupImpressions = + (await this._storage.get("groupImpressions")) || {}; + const screenImpressions = + (await this._storage.get("screenImpressions")) || {}; + const previousSessionEnd = + (await this._storage.get("previousSessionEnd")) || 0; + + await this.setState({ + messageBlockList, + groupImpressions, + messageImpressions, + screenImpressions, + previousSessionEnd, + ...(lazy.ASRouterPreferences.specialConditions || {}), + initialized: false, + }); + await this._updateMessageProviders(); + await this.loadMessagesFromAllProviders(); + await MessageLoaderUtils.cleanupCache(this.state.providers, storage); + + lazy.SpecialMessageActions.blockMessageById = this.blockMessageById; + Services.obs.addObserver(this._onLocaleChanged, TOPIC_INTL_LOCALE_CHANGED); + Services.obs.addObserver( + this._onExperimentEnrollmentsUpdated, + TOPIC_EXPERIMENT_ENROLLMENT_CHANGED + ); + Services.prefs.addObserver(USE_REMOTE_L10N_PREF, this); + // sets .initialized to true and resolves .waitForInitialized promise + this._finishInitializing(); + return this.state; + } + + uninit() { + this._storage.set("previousSessionEnd", Date.now()); + + this.clearChildMessages = null; + this.clearChildProviders = null; + this.updateAdminState = null; + this.sendTelemetry = null; + this.dispatchCFRAction = null; + + lazy.ASRouterPreferences.removeListener(this.onPrefChange); + lazy.ASRouterPreferences.uninit(); + lazy.ToolbarPanelHub.uninit(); + lazy.ToolbarBadgeHub.uninit(); + lazy.MomentsPageHub.uninit(); + + // Uninitialise all trigger listeners + for (const listener of lazy.ASRouterTriggerListeners.values()) { + listener.uninit(); + } + Services.obs.removeObserver( + this._onLocaleChanged, + TOPIC_INTL_LOCALE_CHANGED + ); + Services.obs.removeObserver( + this._onExperimentEnrollmentsUpdated, + TOPIC_EXPERIMENT_ENROLLMENT_CHANGED + ); + Services.prefs.removeObserver(USE_REMOTE_L10N_PREF, this); + // If we added any CFR recommendations, they need to be removed + CFRPageActions.clearRecommendations(); + this._resetInitialization(); + } + + setState(callbackOrObj) { + lazy.ASRouterPreferences.console.debug( + "in setState, callbackOrObj = ", + callbackOrObj + ); + lazy.ASRouterPreferences.console.trace(); + const newState = + typeof callbackOrObj === "function" + ? callbackOrObj(this.state) + : callbackOrObj; + this._state = { + ...this.state, + ...newState, + }; + if (lazy.ASRouterPreferences.devtoolsEnabled) { + return this.updateTargetingParameters().then(state => { + this.updateAdminState(state); + return state; + }); + } + return Promise.resolve(this.state); + } + + updateTargetingParameters() { + return this.getTargetingParameters( + lazy.ASRouterTargeting.Environment, + this._getMessagesContext() + ).then(targetingParameters => ({ + ...this.state, + providerPrefs: lazy.ASRouterPreferences.providers, + userPrefs: lazy.ASRouterPreferences.getAllUserPreferences(), + targetingParameters, + errors: this.errors, + devtoolsEnabled: lazy.ASRouterPreferences.devtoolsEnabled, + })); + } + + getMessageById(id) { + return this.state.messages.find(message => message.id === id); + } + + _loadLocalProviders() { + // If we're in ASR debug mode add the local test providers + if (lazy.ASRouterPreferences.devtoolsEnabled) { + this._localProviders = { + ...this._localProviders, + PanelTestProvider: lazy.PanelTestProvider, + }; + } + } + + /** + * Used by ASRouter Admin returns all ASRouterTargeting.Environment + * and ASRouter._getMessagesContext parameters and values + */ + async getTargetingParameters(environment, localContext) { + // Resolve objects that may contain promises. + async function resolve(object) { + if (typeof object === "object" && object !== null) { + if (Array.isArray(object)) { + return Promise.all(object.map(async item => resolve(await item))); + } + + if (object instanceof Date) { + return object; + } + + const target = {}; + const promises = Object.entries(object).map(async ([key, value]) => { + try { + let resolvedValue = await resolve(await value); + return [key, resolvedValue]; + } catch (error) { + lazy.ASRouterPreferences.console.debug( + `getTargetingParameters: Error resolving ${key}: `, + error + ); + throw error; + } + }); + for (const { status, value } of await Promise.allSettled(promises)) { + if (status === "fulfilled") { + const [key, resolvedValue] = value; + target[key] = resolvedValue; + } + } + return target; + } + + return object; + } + + const targetingParameters = { + ...(await resolve(environment)), + ...(await resolve(localContext)), + }; + + return targetingParameters; + } + + _handleTargetingError(error, message) { + console.error(error); + this.dispatchCFRAction( + ac.ASRouterUserEvent({ + message_id: message.id, + action: "asrouter_undesired_event", + event: "TARGETING_EXPRESSION_ERROR", + event_context: {}, + }) + ); + } + + // Return an object containing targeting parameters used to select messages + _getMessagesContext() { + const { messageImpressions, previousSessionEnd, screenImpressions } = + this.state; + + return { + get messageImpressions() { + return messageImpressions; + }, + get previousSessionEnd() { + return previousSessionEnd; + }, + get screenImpressions() { + return screenImpressions; + }, + }; + } + + async evaluateExpression({ expression, context }) { + const targetingContext = new lazy.TargetingContext(context); + let evaluationStatus; + try { + evaluationStatus = { + result: await targetingContext.evalWithDefault(expression), + success: true, + }; + } catch (e) { + evaluationStatus = { result: e.message, success: false }; + } + return Promise.resolve({ evaluationStatus }); + } + + unblockAll() { + return this.setState({ messageBlockList: [] }); + } + + isUnblockedMessage(message) { + const { state } = this; + return ( + !state.messageBlockList.includes(message.id) && + (!message.campaign || + !state.messageBlockList.includes(message.campaign)) && + this.hasGroupsEnabled(message.groups) && + !this.isExcludedByProvider(message) + ); + } + + // Work out if a message can be shown based on its and its provider's frequency caps. + isBelowFrequencyCaps(message) { + const { messageImpressions, groupImpressions } = this.state; + const impressionsForMessage = messageImpressions[message.id]; + + const _belowItemFrequencyCap = this._isBelowItemFrequencyCap( + message, + impressionsForMessage, + MAX_MESSAGE_LIFETIME_CAP + ); + if (!_belowItemFrequencyCap) { + lazy.ASRouterPreferences.console.debug( + `isBelowFrequencyCaps: capped by item: `, + message, + "impressions =", + impressionsForMessage + ); + } + + const _belowGroupFrequencyCaps = message.groups.every(messageGroup => { + const belowThisGroupCap = this._isBelowItemFrequencyCap( + this.state.groups.find(({ id }) => id === messageGroup), + groupImpressions[messageGroup] + ); + + if (!belowThisGroupCap) { + lazy.ASRouterPreferences.console.debug( + `isBelowFrequencyCaps: ${message.id} capped by group ${messageGroup}` + ); + } else { + lazy.ASRouterPreferences.console.debug( + `isBelowFrequencyCaps: ${message.id} allowed by group ${messageGroup}, groupImpressions = `, + groupImpressions + ); + } + + return belowThisGroupCap; + }); + + return _belowItemFrequencyCap && _belowGroupFrequencyCaps; + } + + // Helper for isBelowFrecencyCaps - work out if the frequency cap for the given + // item has been exceeded or not + _isBelowItemFrequencyCap(item, impressions, maxLifetimeCap = Infinity) { + if (item && item.frequency && impressions && impressions.length) { + if ( + item.frequency.lifetime && + impressions.length >= Math.min(item.frequency.lifetime, maxLifetimeCap) + ) { + lazy.ASRouterPreferences.console.debug( + `${item.id} capped by lifetime (${item.frequency.lifetime})` + ); + + return false; + } + if (item.frequency.custom) { + const now = Date.now(); + for (const setting of item.frequency.custom) { + let { period } = setting; + const impressionsInPeriod = impressions.filter(t => now - t < period); + if (impressionsInPeriod.length >= setting.cap) { + lazy.ASRouterPreferences.console.debug( + `${item.id} capped by impressions (${impressionsInPeriod.length}) in period (${period}) >= ${setting.cap}` + ); + return false; + } + } + } + } + return true; + } + + async _extraTemplateStrings(originalMessage) { + let extraTemplateStrings; + let localProvider = this._findProvider(originalMessage.provider); + if (localProvider && localProvider.getExtraAttributes) { + extraTemplateStrings = await localProvider.getExtraAttributes(); + } + + return extraTemplateStrings; + } + + _findProvider(providerID) { + return this._localProviders[ + this.state.providers.find(i => i.id === providerID).localProvider + ]; + } + + routeCFRMessage(message, browser, trigger, force = false) { + if (!message) { + return { message: {} }; + } + + // filter out messages we want to exclude from tests + if ( + message.skip_in_tests && + // `this.messagesEnabledInAutomation` should be stubbed in tests + !this.messagesEnabledInAutomation?.includes(message.id) && + (Cu.isInAutomation || + Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") || + Services.env.get("MOZ_AUTOMATION")) + ) { + lazy.log.debug( + `Skipping message ${message.id} because ${message.skip_in_tests}` + ); + return { message: {} }; + } + + switch (message.template) { + case "whatsnew_panel_message": + if (force) { + lazy.ToolbarPanelHub.forceShowMessage(browser, message); + } + break; + case "cfr_doorhanger": + case "milestone_message": + if (force) { + CFRPageActions.forceRecommendation( + browser, + message, + this.dispatchCFRAction + ); + } else { + CFRPageActions.addRecommendation( + browser, + trigger.param && trigger.param.host, + message, + this.dispatchCFRAction + ); + } + break; + case "cfr_urlbar_chiclet": + if (force) { + CFRPageActions.forceRecommendation( + browser, + message, + this.dispatchCFRAction + ); + } else { + CFRPageActions.addRecommendation( + browser, + null, + message, + this.dispatchCFRAction + ); + } + break; + case "toolbar_badge": + lazy.ToolbarBadgeHub.registerBadgeNotificationListener(message, { + force, + }); + break; + case "update_action": + lazy.MomentsPageHub.executeAction(message); + break; + case "infobar": + lazy.InfoBar.showInfoBarMessage( + browser, + message, + this.dispatchCFRAction + ); + break; + case "spotlight": + lazy.Spotlight.showSpotlightDialog( + browser, + message, + this.dispatchCFRAction + ); + break; + case "feature_callout": + // featureCalloutCheck only comes from within FeatureCallout, where it + // is used to request a matching message. It is not a real trigger. + // pdfJsFeatureCalloutCheck is used for PDF.js feature callouts, which + // are managed by the trigger listener itself. + switch (trigger.id) { + case "featureCalloutCheck": + case "pdfJsFeatureCalloutCheck": + case "newtabFeatureCalloutCheck": + break; + default: + lazy.FeatureCalloutBroker.showFeatureCallout(browser, message); + } + break; + case "toast_notification": + lazy.ToastNotification.showToastNotification( + message, + this.dispatchCFRAction + ); + break; + } + + return { message }; + } + + addScreenImpression(screen) { + lazy.ASRouterPreferences.console.debug( + `entering addScreenImpression for ${screen.id}` + ); + + const time = Date.now(); + + let screenImpressions = { ...this.state.screenImpressions }; + screenImpressions[screen.id] = time; + + this.setState({ screenImpressions }); + lazy.ASRouterPreferences.console.debug( + screen.id, + `screen impression added, screenImpressions[screen.id]: `, + screenImpressions[screen.id] + ); + this._storage.set("screenImpressions", screenImpressions); + } + + addImpression(message) { + lazy.ASRouterPreferences.console.debug( + `entering addImpression for ${message.id}` + ); + + const groupsWithFrequency = this.state.groups?.filter( + ({ frequency, id }) => frequency && message.groups?.includes(id) + ); + // We only need to store impressions for messages that have frequency, or + // that have providers that have frequency + if (message.frequency || groupsWithFrequency.length) { + const time = Date.now(); + return this.setState(state => { + const messageImpressions = this._addImpressionForItem( + state.messageImpressions, + message, + "messageImpressions", + time + ); + // Initialize this with state.groupImpressions, and then assign the + // newly-updated copy to it during each iteration so that + // all the changes get captured and either returned or passed into the + // _addImpressionsForItem call on the next iteration. + let { groupImpressions } = state; + for (const group of groupsWithFrequency) { + groupImpressions = this._addImpressionForItem( + groupImpressions, + group, + "groupImpressions", + time + ); + } + + return { messageImpressions, groupImpressions }; + }); + } + return Promise.resolve(); + } + + // Helper for addImpression - calculate the updated impressions object for the given + // item, then store it and return it + _addImpressionForItem(currentImpressions, item, impressionsString, time) { + // The destructuring here is to avoid mutating passed parameters + // (see https://redux.js.org/recipes/structuring-reducers/prerequisite-concepts#immutable-data-management) + const impressions = { ...currentImpressions }; + if (item.frequency) { + impressions[item.id] = [...(impressions[item.id] ?? []), time]; + + lazy.ASRouterPreferences.console.debug( + item.id, + "impression added, impressions[item.id]: ", + impressions[item.id] + ); + + this._storage.set(impressionsString, impressions); + } + return impressions; + } + + /** + * getLongestPeriod + * + * @param {obj} item Either an ASRouter message or an ASRouter provider + * @returns {int|null} if the item has custom frequency caps, the longest period found in the list of caps. + if the item has no custom frequency caps, null + * @memberof _ASRouter + */ + getLongestPeriod(item) { + if (!item.frequency || !item.frequency.custom) { + return null; + } + return item.frequency.custom.sort((a, b) => b.period - a.period)[0].period; + } + + /** + * cleanupImpressions - this function cleans up obsolete impressions whenever + * messages are refreshed or fetched. It will likely need to be more sophisticated in the future, + * but the current behaviour for when both message impressions and provider impressions are + * cleared is as follows (where `item` is either `message` or `provider`): + * + * 1. If the item id for a list of item impressions no longer exists in the ASRouter state, it + * will be cleared. + * 2. If the item has time-bound frequency caps but no lifetime cap, any item impressions older + * than the longest time period will be cleared. + */ + cleanupImpressions() { + return this.setState(state => { + const messageImpressions = this._cleanupImpressionsForItems( + state, + state.messages, + "messageImpressions" + ); + const groupImpressions = this._cleanupImpressionsForItems( + state, + state.groups, + "groupImpressions" + ); + return { messageImpressions, groupImpressions }; + }); + } + + /** _cleanupImpressionsForItems - Helper for cleanupImpressions - calculate the updated + /* impressions object for the given items, then store it and return it + * + * @param {obj} state Reference to ASRouter internal state + * @param {array} items Can be messages, providers or groups that we count impressions for + * @param {string} impressionsString Key name for entry in state where impressions are stored + */ + _cleanupImpressionsForItems(state, items, impressionsString) { + const impressions = { ...state[impressionsString] }; + let needsUpdate = false; + Object.keys(impressions).forEach(id => { + const [item] = items.filter(x => x.id === id); + // Don't keep impressions for items that no longer exist + if (!item || !item.frequency || !Array.isArray(impressions[id])) { + lazy.ASRouterPreferences.console.debug( + "_cleanupImpressionsForItem: removing impressions for deleted or changed item: ", + item + ); + lazy.ASRouterPreferences.console.trace(); + delete impressions[id]; + needsUpdate = true; + return; + } + if (!impressions[id].length) { + return; + } + // If we don't want to store impressions older than the longest period + if (item.frequency.custom && !item.frequency.lifetime) { + lazy.ASRouterPreferences.console.debug( + "_cleanupImpressionsForItem: removing impressions older than longest period for item: ", + item + ); + const now = Date.now(); + impressions[id] = impressions[id].filter( + t => now - t < this.getLongestPeriod(item) + ); + needsUpdate = true; + } + }); + if (needsUpdate) { + this._storage.set(impressionsString, impressions); + } + return impressions; + } + + handleMessageRequest({ + messages: candidates, + triggerId, + triggerParam, + triggerContext, + template, + provider, + ordered = false, + returnAll = false, + }) { + let shouldCache; + lazy.ASRouterPreferences.console.debug( + "in handleMessageRequest, arguments = ", + Array.from(arguments) // eslint-disable-line prefer-rest-params + ); + lazy.ASRouterPreferences.console.trace(); + const messages = + candidates || + this.state.messages.filter(m => { + if (provider && m.provider !== provider) { + lazy.ASRouterPreferences.console.debug(m.id, " filtered by provider"); + return false; + } + if (template && m.template !== template) { + lazy.ASRouterPreferences.console.debug(m.id, " filtered by template"); + return false; + } + if (triggerId && !m.trigger) { + lazy.ASRouterPreferences.console.debug(m.id, " filtered by trigger"); + return false; + } + if (triggerId && m.trigger.id !== triggerId) { + lazy.ASRouterPreferences.console.debug( + m.id, + " filtered by triggerId" + ); + return false; + } + if (!this.isUnblockedMessage(m)) { + lazy.ASRouterPreferences.console.debug( + m.id, + " filtered because blocked" + ); + return false; + } + if (!this.isBelowFrequencyCaps(m)) { + lazy.ASRouterPreferences.console.debug( + m.id, + " filtered because capped" + ); + return false; + } + + if (shouldCache !== false) { + shouldCache = JEXL_PROVIDER_CACHE.has(m.provider); + } + + return true; + }); + + if (!messages.length) { + return returnAll ? messages : null; + } + + const context = this._getMessagesContext(); + + // Find a message that matches the targeting context as well as the trigger context (if one is provided) + // If no trigger is provided, we should find a message WITHOUT a trigger property defined. + return lazy.ASRouterTargeting.findMatchingMessage({ + messages, + trigger: triggerId && { + id: triggerId, + param: triggerParam, + context: triggerContext, + }, + context, + onError: this._handleTargetingError, + ordered, + shouldCache, + returnAll, + }); + } + + setMessageById({ id, ...data }, force, browser) { + return this.routeCFRMessage(this.getMessageById(id), browser, data, force); + } + + blockMessageById(idOrIds) { + lazy.ASRouterPreferences.console.debug( + "blockMessageById called, idOrIds = ", + idOrIds + ); + lazy.ASRouterPreferences.console.trace(); + + const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + + return this.setState(state => { + const messageBlockList = [...state.messageBlockList]; + const messageImpressions = { ...state.messageImpressions }; + + idsToBlock.forEach(id => { + const message = state.messages.find(m => m.id === id); + const idToBlock = message && message.campaign ? message.campaign : id; + if (!messageBlockList.includes(idToBlock)) { + messageBlockList.push(idToBlock); + } + + // When a message is blocked, its impressions should be cleared as well + delete messageImpressions[id]; + }); + + this._storage.set("messageBlockList", messageBlockList); + this._storage.set("messageImpressions", messageImpressions); + return { messageBlockList, messageImpressions }; + }); + } + + unblockMessageById(idOrIds) { + const idsToUnblock = Array.isArray(idOrIds) ? idOrIds : [idOrIds]; + + return this.setState(state => { + const messageBlockList = [...state.messageBlockList]; + idsToUnblock + .map(id => state.messages.find(m => m.id === id)) + // Remove all `id`s from the message block list + .forEach(message => { + const idToUnblock = + message && message.campaign ? message.campaign : message.id; + messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1); + }); + + this._storage.set("messageBlockList", messageBlockList); + return { messageBlockList }; + }); + } + + resetGroupsState() { + const newGroupImpressions = {}; + for (let { id } of this.state.groups) { + newGroupImpressions[id] = []; + } + // Update storage + this._storage.set("groupImpressions", newGroupImpressions); + return this.setState(({ groups }) => ({ + groupImpressions: newGroupImpressions, + })); + } + + resetMessageState() { + const newMessageImpressions = {}; + for (let { id } of this.state.messages) { + newMessageImpressions[id] = []; + } + // Update storage + this._storage.set("messageImpressions", newMessageImpressions); + return this.setState(() => ({ + messageImpressions: newMessageImpressions, + })); + } + + resetScreenImpressions() { + const newScreenImpressions = {}; + this._storage.set("screenImpressions", newScreenImpressions); + return this.setState(() => ({ screenImpressions: newScreenImpressions })); + } + + /** + * Edit the ASRouter state directly. For use by the ASRouter devtools. + * Requires browser.newtabpage.activity-stream.asrouter.devtoolsEnabled + * @param {string} key Key of the property to edit, one of: + * | "groupImpressions" + * | "messageImpressions" + * | "screenImpressions" + * | "messageBlockList" + * @param {object|string[]} value New value to set for state[key] + * @returns {Promise} The new value in state + */ + async editState(key, value) { + if (!lazy.ASRouterPreferences.devtoolsEnabled) { + throw new Error("Editing state is only allowed in devtools mode"); + } + switch (key) { + case "groupImpressions": + case "messageImpressions": + case "screenImpressions": + if (typeof value !== "object") { + throw new Error("Invalid impression data"); + } + break; + case "messageBlockList": + if (!Array.isArray(value)) { + throw new Error("Invalid message block list"); + } + break; + default: + throw new Error("Invalid state key"); + } + const newState = await this.setState(() => { + this._storage.set(key, value); + return { [key]: value }; + }); + return newState[key]; + } + + _validPreviewEndpoint(url) { + try { + const endpoint = new URL(url); + if (!this.ALLOWLIST_HOSTS[endpoint.host]) { + console.error( + `The preview URL host ${endpoint.host} is not in the list of allowed hosts.` + ); + } + if (endpoint.protocol !== "https:") { + console.error("The URL protocol is not https."); + } + return ( + endpoint.protocol === "https:" && this.ALLOWLIST_HOSTS[endpoint.host] + ); + } catch (e) { + return false; + } + } + + _loadAllowHosts() { + return DEFAULT_ALLOWLIST_HOSTS; + } + + // To be passed to ASRouterTriggerListeners + _triggerHandler(browser, trigger) { + // Disable ASRouterTriggerListeners in kiosk mode. + if (lazy.BrowserHandler.kiosk) { + return Promise.resolve(); + } + return this.sendTriggerMessage({ ...trigger, browser }); + } + + /** Simple wrapper to make test mocking easier + * + * @returns {Promise} resolves when the attribution string has been set + * succesfully. + */ + setAttributionString(attrStr) { + return lazy.MacAttribution.setAttributionString(attrStr); + } + + /** + * forceAttribution - this function should only be called from within about:newtab#asrouter. + * It forces the browser attribution to be set to something specified in asrouter admin + * tools, and reloads the providers in order to get messages that are dependant on this + * attribution data (see Return to AMO flow in bug 1475354 for example). Note - OSX and Windows only + * @param {data} Object an object containing the attribtion data that came from asrouter admin page + */ + async forceAttribution(data) { + // Extract the parameters from data that will make up the referrer url + const attributionData = lazy.AttributionCode.allowedCodeKeys + .map(key => `${key}=${encodeURIComponent(data[key] || "")}`) + .join("&"); + if (AppConstants.platform === "win") { + // The whole attribution data is encoded (again) for windows + await lazy.AttributionCode.writeAttributionFile( + encodeURIComponent(attributionData) + ); + } else if (AppConstants.platform === "macosx") { + await this.setAttributionString(encodeURIComponent(attributionData)); + } + + // Clear cache call is only possible in a testing environment + Services.env.set("XPCSHELL_TEST_PROFILE_DIR", "testing"); + + // Clear and refresh Attribution, and then fetch the messages again to update + lazy.AttributionCode._clearCache(); + await lazy.AttributionCode.getAttrDataAsync(); + await this._updateMessageProviders(); + return this.loadMessagesFromAllProviders(); + } + + async sendPBNewTabMessage({ tabId, hideDefault }) { + let message = null; + const PromoInfo = { + FOCUS: { enabledPref: "browser.promo.focus.enabled" }, + VPN: { enabledPref: "browser.vpn_promo.enabled" }, + PIN: { enabledPref: "browser.promo.pin.enabled" }, + COOKIE_BANNERS: { enabledPref: "browser.promo.cookiebanners.enabled" }, + }; + await this.loadMessagesFromAllProviders(); + + // If message has hideDefault property set to true + // remove from state all pb_newtab messages with type default + if (hideDefault) { + await this.setState(state => ({ + messages: state.messages.filter( + m => !(m.template === "pb_newtab" && m.type === "default") + ), + })); + } + + // Remove from state pb_newtab messages with PromoType disabled + await this.setState(state => ({ + messages: state.messages.filter( + m => + !( + m.template === "pb_newtab" && + !Services.prefs.getBoolPref( + PromoInfo[m.content?.promoType]?.enabledPref, + true + ) + ) + ), + })); + + const telemetryObject = { tabId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + message = await this.handleMessageRequest({ + template: "pb_newtab", + }); + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + + // Format urls if any are defined + ["infoLinkUrl"].forEach(key => { + if (message?.content?.[key]) { + message.content[key] = Services.urlFormatter.formatURL( + message.content[key] + ); + } + }); + + return { message }; + } + + _recordReachEvent(message) { + const messageGroup = message.forReachEvent.group; + // Events telemetry only accepts understores for the event `object` + const underscored = messageGroup.split("-").join("_"); + const extra = { branches: message.branchSlug }; + Services.telemetry.recordEvent( + REACH_EVENT_CATEGORY, + REACH_EVENT_METHOD, + underscored, + message.experimentSlug, + extra + ); + } + + /** + * Fire a trigger, look for a matching message, and route it to the + * appropriate message handler/messaging surface. + * @param {object} trigger + * @param {string} trigger.id the name of the trigger, e.g. "openURL" + * @param {object} [trigger.param] an object with host, url, type, etc. keys + * whose values are used to match against the message's trigger params + * @param {object} [trigger.context] an object with data about the source of + * the trigger, matched against the message's targeting expression + * @param {MozBrowser} trigger.browser the browser to route messages to + * @param {number} [trigger.tabId] identifier used only for exposure testing + * @param {boolean} [skipLoadingMessages=false] pass true to skip looking for + * new messages. use when calling from loadMessagesFromAllProviders to avoid + * recursion. we call this from loadMessagesFromAllProviders in order to + * fire the messagesLoaded trigger. + * @returns {Promise} + * @resolves {message} an object with the routed message + */ + async sendTriggerMessage( + { tabId, browser, ...trigger }, + skipLoadingMessages = false + ) { + if (!skipLoadingMessages) { + await this.loadMessagesFromAllProviders(); + } + const telemetryObject = { tabId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + // Return all the messages so that it can record the Reach event + const messages = + (await this.handleMessageRequest({ + triggerId: trigger.id, + triggerParam: trigger.param, + triggerContext: trigger.context, + returnAll: true, + })) || []; + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + + // Record the Reach event for all the messages with `forReachEvent`, + // only send the first message without forReachEvent to the target + const nonReachMessages = []; + for (const message of messages) { + if (message.forReachEvent) { + if (!message.forReachEvent.sent) { + this._recordReachEvent(message); + message.forReachEvent.sent = true; + } + } else { + nonReachMessages.push(message); + } + } + + if (nonReachMessages.length) { + let featureId = nonReachMessages[0]._nimbusFeature; + if (featureId) { + lazy.NimbusFeatures[featureId].recordExposureEvent({ once: true }); + } + } + + return this.routeCFRMessage( + nonReachMessages[0] || null, + browser, + trigger, + false + ); + } + + async forceWNPanel(browser) { + let win = browser.ownerGlobal; + await lazy.ToolbarPanelHub.enableToolbarButton(); + + win.PanelUI.showSubView( + "PanelUI-whatsNew", + win.document.getElementById("whats-new-menu-button") + ); + + let panel = win.document.getElementById("customizationui-widget-panel"); + // Set the attribute to keep the panel open + panel.setAttribute("noautohide", true); + } + + async closeWNPanel(browser) { + let win = browser.ownerGlobal; + let panel = win.document.getElementById("customizationui-widget-panel"); + // Set the attribute to allow the panel to close + panel.setAttribute("noautohide", false); + // Removing the button is enough to close the panel. + await lazy.ToolbarPanelHub._hideToolbarButton(win); + } + + async _onExperimentEnrollmentsUpdated() { + const experimentProvider = this.state.providers.find( + p => p.id === "messaging-experiments" + ); + if (!experimentProvider?.enabled) { + return; + } + await this.loadMessagesFromAllProviders([experimentProvider]); + } + + async forcePBWindow(browser, msg) { + const privateBrowserOpener = await new Promise( + ( + resolveOnContentBrowserCreated // wrap this in a promise to give back the right browser + ) => + browser.ownerGlobal.openTrustedLinkIn( + "about:privatebrowsing?debug", + "window", + { + private: true, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal({}), + csp: null, + resolveOnContentBrowserCreated, + opener: "devtools", + } + ) + ); + + lazy.setTimeout(() => { + // setTimeout is necessary to make sure the private browsing window has a chance to open before the message is sent + privateBrowserOpener.browsingContext.currentWindowGlobal + .getActor("AboutPrivateBrowsing") + .sendAsyncMessage("ShowDevToolsMessage", msg); + }, 100); + + return privateBrowserOpener; + } +} + +/** + * ASRouter - singleton instance of _ASRouter that controls all messages + * in the new tab page. + */ +export const ASRouter = new _ASRouter(); diff --git a/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs b/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs new file mode 100644 index 0000000000..e81380b1e2 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs @@ -0,0 +1,64 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +import { ASRouter } from "resource:///modules/asrouter/ASRouter.sys.mjs"; +import { TelemetryFeed } from "resource://activity-stream/lib/TelemetryFeed.sys.mjs"; +import { ASRouterParentProcessMessageHandler } from "resource:///modules/asrouter/ASRouterParentProcessMessageHandler.sys.mjs"; + +// 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 does not actually rely +// on SpecialMessageActions, 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 { SpecialMessageActions } = ChromeUtils.importESModule( + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs" +); + +import { ASRouterPreferences } from "resource:///modules/asrouter/ASRouterPreferences.sys.mjs"; +import { QueryCache } from "resource:///modules/asrouter/ASRouterTargeting.sys.mjs"; +import { ActivityStreamStorage } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs"; + +const createStorage = async telemetryFeed => { + // "snippets" is the name of one storage space, but these days it is used + // not for snippet-related data (snippets were removed in bug 1715158), + // but storage for impression or session data for all ASRouter messages. + // + // We keep the name "snippets" to avoid having to do an IndexedDB database + // migration. + const dbStore = new ActivityStreamStorage({ + storeNames: ["sectionPrefs", "snippets"], + telemetry: { + handleUndesiredEvent: e => telemetryFeed.SendASRouterUndesiredEvent(e), + }, + }); + // Accessing the db causes the object stores to be created / migrated. + // This needs to happen before other instances try to access the db, which + // would update only a subset of the stores to the latest version. + try { + await dbStore.db; // eslint-disable-line no-unused-expressions + } catch (e) { + return Promise.reject(e); + } + return dbStore.getDbTable("snippets"); +}; + +export const ASRouterDefaultConfig = () => { + const router = ASRouter; + const telemetry = new TelemetryFeed(); + const messageHandler = new ASRouterParentProcessMessageHandler({ + router, + preferences: ASRouterPreferences, + specialMessageActions: SpecialMessageActions, + queryCache: QueryCache, + sendTelemetry: telemetry.onAction.bind(telemetry), + }); + return { + router, + messageHandler, + createStorage: createStorage.bind(null, telemetry), + }; +}; diff --git a/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs b/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs new file mode 100644 index 0000000000..d0fdbfdae4 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs @@ -0,0 +1,117 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +class ASRouterNewTabHookInstance { + constructor() { + this._newTabMessageHandler = null; + this._parentProcessMessageHandler = null; + this._router = null; + this._clearChildMessages = (...params) => + this._newTabMessageHandler === null + ? Promise.resolve() + : this._newTabMessageHandler.clearChildMessages(...params); + this._clearChildProviders = (...params) => + this._newTabMessageHandler === null + ? Promise.resolve() + : this._newTabMessageHandler.clearChildProviders(...params); + this._updateAdminState = (...params) => + this._newTabMessageHandler === null + ? Promise.resolve() + : this._newTabMessageHandler.updateAdminState(...params); + } + + /** + * Params: + * object - { + * messageHandler: message handler for parent process messages + * { + * handleCFRAction: Responds to CFR action and returns a Promise + * handleTelemetry: Logs telemetry events and returns nothing + * }, + * router: ASRouter instance + * createStorage: function to create DB storage for ASRouter + * } + */ + async initialize({ messageHandler, router, createStorage }) { + this._parentProcessMessageHandler = messageHandler; + this._router = router; + if (!this._router.initialized) { + const storage = await createStorage(); + await this._router.init({ + storage, + sendTelemetry: this._parentProcessMessageHandler.handleTelemetry, + dispatchCFRAction: this._parentProcessMessageHandler.handleCFRAction, + clearChildMessages: this._clearChildMessages, + clearChildProviders: this._clearChildProviders, + updateAdminState: this._updateAdminState, + }); + } + } + + destroy() { + if (this._router?.initialized) { + this.disconnect(); + this._router.uninit(); + } + } + + /** + * Connects new tab message handler to hook. + * Note: Should only ever be called on an initialized instance + * Params: + * newTabMessageHandler - { + * clearChildMessages: clears child messages and returns Promise + * clearChildProviders: clears child providers and returns Promise. + * updateAdminState: updates admin state and returns Promise + * } + * Returns: parentProcessMessageHandler + */ + connect(newTabMessageHandler) { + this._newTabMessageHandler = newTabMessageHandler; + return this._parentProcessMessageHandler; + } + + /** + * Disconnects new tab message handler from hook. + */ + disconnect() { + this._newTabMessageHandler = null; + } +} + +class AwaitSingleton { + constructor() { + this.instance = null; + const initialized = new Promise(resolve => { + this.setInstance = instance => { + this.setInstance = () => {}; + this.instance = instance; + resolve(instance); + }; + }); + this.getInstance = () => initialized; + } +} + +export const ASRouterNewTabHook = (() => { + const singleton = new AwaitSingleton(); + const instance = new ASRouterNewTabHookInstance(); + return { + getInstance: singleton.getInstance, + + /** + * Param: + * params - see ASRouterNewTabHookInstance.init + */ + createInstance: async params => { + await instance.initialize(params); + singleton.setInstance(instance); + }, + + destroy: () => { + instance.destroy(); + }, + }; +})(); diff --git a/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs b/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs new file mode 100644 index 0000000000..c2f5fcd884 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs @@ -0,0 +1,171 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +import { ASRouterPreferences } from "resource:///modules/asrouter/ASRouterPreferences.sys.mjs"; +import { MESSAGE_TYPE_HASH as msg } from "resource:///modules/asrouter/ActorConstants.sys.mjs"; + +export class ASRouterParentProcessMessageHandler { + constructor({ + router, + preferences, + specialMessageActions, + queryCache, + sendTelemetry, + }) { + this._router = router; + this._preferences = preferences; + this._specialMessageActions = specialMessageActions; + this._queryCache = queryCache; + this.handleTelemetry = sendTelemetry; + this.handleMessage = this.handleMessage.bind(this); + this.handleCFRAction = this.handleCFRAction.bind(this); + } + + handleCFRAction({ type, data }, browser) { + switch (type) { + case msg.INFOBAR_TELEMETRY: + case msg.TOOLBAR_BADGE_TELEMETRY: + case msg.TOOLBAR_PANEL_TELEMETRY: + case msg.MOMENTS_PAGE_TELEMETRY: + case msg.DOORHANGER_TELEMETRY: + case msg.SPOTLIGHT_TELEMETRY: + case msg.TOAST_NOTIFICATION_TELEMETRY: { + return this.handleTelemetry({ type, data }); + } + default: { + return this.handleMessage(type, data, { browser }); + } + } + } + + handleMessage(name, data, { id: tabId, browser } = { browser: null }) { + switch (name) { + case msg.AS_ROUTER_TELEMETRY_USER_EVENT: + return this.handleTelemetry({ + type: msg.AS_ROUTER_TELEMETRY_USER_EVENT, + data, + }); + case msg.BLOCK_MESSAGE_BY_ID: { + ASRouterPreferences.console.debug( + "handleMesssage(): about to block, data = ", + data + ); + ASRouterPreferences.console.trace(); + + // Block the message but don't dismiss it in case the action taken has + // another state that needs to be visible + return this._router + .blockMessageById(data.id) + .then(() => !data.preventDismiss); + } + case msg.USER_ACTION: { + return this._specialMessageActions.handleAction(data, browser); + } + case msg.IMPRESSION: { + return this._router.addImpression(data); + } + case msg.TRIGGER: { + return this._router.sendTriggerMessage({ + ...(data && data.trigger), + tabId, + browser, + }); + } + case msg.PBNEWTAB_MESSAGE_REQUEST: { + return this._router.sendPBNewTabMessage({ + ...data, + tabId, + browser, + }); + } + + // ADMIN Messages + case msg.ADMIN_CONNECT_STATE: { + if (data && data.endpoint) { + return this._router.loadMessagesFromAllProviders(); + } + return this._router.updateTargetingParameters(); + } + case msg.UNBLOCK_MESSAGE_BY_ID: { + return this._router.unblockMessageById(data.id); + } + case msg.UNBLOCK_ALL: { + return this._router.unblockAll(); + } + case msg.BLOCK_BUNDLE: { + return this._router.blockMessageById(data.bundle.map(b => b.id)); + } + case msg.UNBLOCK_BUNDLE: { + return this._router.setState(state => { + const messageBlockList = [...state.messageBlockList]; + for (let message of data.bundle) { + messageBlockList.splice(messageBlockList.indexOf(message.id), 1); + } + this._router._storage.set("messageBlockList", messageBlockList); + return { messageBlockList }; + }); + } + case msg.DISABLE_PROVIDER: { + this._preferences.enableOrDisableProvider(data, false); + return Promise.resolve(); + } + case msg.ENABLE_PROVIDER: { + this._preferences.enableOrDisableProvider(data, true); + return Promise.resolve(); + } + case msg.EVALUATE_JEXL_EXPRESSION: { + return this._router.evaluateExpression(data); + } + case msg.EXPIRE_QUERY_CACHE: { + this._queryCache.expireAll(); + return Promise.resolve(); + } + case msg.FORCE_ATTRIBUTION: { + return this._router.forceAttribution(data); + } + case msg.FORCE_PRIVATE_BROWSING_WINDOW: { + return this._router.forcePBWindow(browser, data.message); + } + case msg.FORCE_WHATSNEW_PANEL: { + return this._router.forceWNPanel(browser); + } + case msg.CLOSE_WHATSNEW_PANEL: { + return this._router.closeWNPanel(browser); + } + case msg.MODIFY_MESSAGE_JSON: { + return this._router.routeCFRMessage(data.content, browser, data, true); + } + case msg.OVERRIDE_MESSAGE: { + return this._router.setMessageById(data, true, browser); + } + case msg.RESET_PROVIDER_PREF: { + this._preferences.resetProviderPref(); + return Promise.resolve(); + } + case msg.SET_PROVIDER_USER_PREF: { + this._preferences.setUserPreference(data.id, data.value); + return Promise.resolve(); + } + case msg.RESET_GROUPS_STATE: { + return this._router + .resetGroupsState(data) + .then(() => this._router.loadMessagesFromAllProviders()); + } + case msg.RESET_MESSAGE_STATE: { + return this._router.resetMessageState(); + } + case msg.RESET_SCREEN_IMPRESSIONS: { + return this._router.resetScreenImpressions(); + } + case msg.EDIT_STATE: { + const [[key, value]] = Object.entries(data); + return this._router.editState(key, value); + } + default: { + return Promise.reject(new Error(`Unknown message received: ${name}`)); + } + } + } +} diff --git a/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs b/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs new file mode 100644 index 0000000000..c7617d80c0 --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs @@ -0,0 +1,241 @@ +/* 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/. */ + +const PROVIDER_PREF_BRANCH = + "browser.newtabpage.activity-stream.asrouter.providers."; +const DEVTOOLS_PREF = + "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled"; + +/** + * Use `ASRouterPreferences.console.debug()` and friends from ASRouter files to + * log messages during development. See LOG_LEVELS in ConsoleAPI.jsm for the + * available methods as well as the available values for this pref. + */ +const DEBUG_PREF = "browser.newtabpage.activity-stream.asrouter.debugLogLevel"; + +const FXA_USERNAME_PREF = "services.sync.username"; + +const DEFAULT_STATE = { + _initialized: false, + _providers: null, + _providerPrefBranch: PROVIDER_PREF_BRANCH, + _devtoolsEnabled: null, + _devtoolsPref: DEVTOOLS_PREF, +}; + +const USER_PREFERENCES = { + cfrAddons: "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", + cfrFeatures: + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", +}; + +// Preferences that influence targeting attributes. When these change we need +// to re-evaluate if the message targeting still matches +export const TARGETING_PREFERENCES = [FXA_USERNAME_PREF]; + +export const TEST_PROVIDERS = [ + { + id: "panel_local_testing", + type: "local", + localProvider: "PanelTestProvider", + enabled: true, + }, +]; + +export class _ASRouterPreferences { + constructor() { + Object.assign(this, DEFAULT_STATE); + this._callbacks = new Set(); + + ChromeUtils.defineLazyGetter(this, "console", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + /* eslint-disable mozilla/use-console-createInstance */ + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevel: "error", + maxLogLevelPref: DEBUG_PREF, + prefix: "ASRouter", + }; + return new ConsoleAPI(consoleOptions); + }); + } + + _transformPersonalizedCfrScores(value) { + let result = {}; + try { + result = JSON.parse(value); + } catch (e) { + console.error(e); + } + return result; + } + + _getProviderConfig() { + const prefList = Services.prefs.getChildList(this._providerPrefBranch); + return prefList.reduce((filtered, pref) => { + let value; + try { + value = JSON.parse(Services.prefs.getStringPref(pref, "")); + } catch (e) { + console.error( + `Could not parse ASRouter preference. Try resetting ${pref} in about:config.` + ); + } + if (value) { + filtered.push(value); + } + return filtered; + }, []); + } + + get providers() { + if (!this._initialized || this._providers === null) { + const config = this._getProviderConfig(); + const providers = config.map(provider => Object.freeze(provider)); + if (this.devtoolsEnabled) { + providers.unshift(...TEST_PROVIDERS); + } + this._providers = Object.freeze(providers); + } + + return this._providers; + } + + enableOrDisableProvider(id, value) { + const providers = this._getProviderConfig(); + const config = providers.find(p => p.id === id); + if (!config) { + console.error( + `Cannot set enabled state for '${id}' because the pref ${this._providerPrefBranch}${id} does not exist or is not correctly formatted.` + ); + return; + } + + Services.prefs.setStringPref( + this._providerPrefBranch + id, + JSON.stringify({ ...config, enabled: value }) + ); + } + + resetProviderPref() { + for (const pref of Services.prefs.getChildList(this._providerPrefBranch)) { + Services.prefs.clearUserPref(pref); + } + for (const id of Object.keys(USER_PREFERENCES)) { + Services.prefs.clearUserPref(USER_PREFERENCES[id]); + } + } + + /** + * Bug 1800087 - Migrate the ASRouter message provider prefs' values to the + * current format (provider.bucket -> provider.collection). + * + * TODO (Bug 1800937): Remove migration code after the next watershed release. + */ + _migrateProviderPrefs() { + const prefList = Services.prefs.getChildList(this._providerPrefBranch); + for (const pref of prefList) { + if (!Services.prefs.prefHasUserValue(pref)) { + continue; + } + try { + let value = JSON.parse(Services.prefs.getStringPref(pref, "")); + if (value && "bucket" in value && !("collection" in value)) { + const { bucket, ...rest } = value; + Services.prefs.setStringPref( + pref, + JSON.stringify({ + ...rest, + collection: bucket, + }) + ); + } + } catch (e) { + Services.prefs.clearUserPref(pref); + } + } + } + + get devtoolsEnabled() { + if (!this._initialized || this._devtoolsEnabled === null) { + this._devtoolsEnabled = Services.prefs.getBoolPref( + this._devtoolsPref, + false + ); + } + return this._devtoolsEnabled; + } + + observe(aSubject, aTopic, aPrefName) { + if (aPrefName && aPrefName.startsWith(this._providerPrefBranch)) { + this._providers = null; + } else if (aPrefName === this._devtoolsPref) { + this._providers = null; + this._devtoolsEnabled = null; + } + this._callbacks.forEach(cb => cb(aPrefName)); + } + + getUserPreference(name) { + const prefName = USER_PREFERENCES[name] || name; + return Services.prefs.getBoolPref(prefName, true); + } + + getAllUserPreferences() { + const values = {}; + for (const id of Object.keys(USER_PREFERENCES)) { + values[id] = this.getUserPreference(id); + } + return values; + } + + setUserPreference(providerId, value) { + if (!USER_PREFERENCES[providerId]) { + return; + } + Services.prefs.setBoolPref(USER_PREFERENCES[providerId], value); + } + + addListener(callback) { + this._callbacks.add(callback); + } + + removeListener(callback) { + this._callbacks.delete(callback); + } + + init() { + if (this._initialized) { + return; + } + this._migrateProviderPrefs(); + Services.prefs.addObserver(this._providerPrefBranch, this); + Services.prefs.addObserver(this._devtoolsPref, this); + for (const id of Object.keys(USER_PREFERENCES)) { + Services.prefs.addObserver(USER_PREFERENCES[id], this); + } + for (const targetingPref of TARGETING_PREFERENCES) { + Services.prefs.addObserver(targetingPref, this); + } + this._initialized = true; + } + + uninit() { + if (this._initialized) { + Services.prefs.removeObserver(this._providerPrefBranch, this); + Services.prefs.removeObserver(this._devtoolsPref, this); + for (const id of Object.keys(USER_PREFERENCES)) { + Services.prefs.removeObserver(USER_PREFERENCES[id], this); + } + for (const targetingPref of TARGETING_PREFERENCES) { + Services.prefs.removeObserver(targetingPref, this); + } + } + Object.assign(this, DEFAULT_STATE); + this._callbacks.clear(); + } +} + +export const ASRouterPreferences = new _ASRouterPreferences(); diff --git a/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs new file mode 100644 index 0000000000..a262f8911e --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs @@ -0,0 +1,1308 @@ +/* This Source Code Form is subject to the terms of the Mozilla PublicddonMa + * 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/. */ + +const FXA_ENABLED_PREF = "identity.fxaccounts.enabled"; +const DISTRIBUTION_ID_PREF = "distribution.id"; +const DISTRIBUTION_ID_CHINA_REPACK = "MozillaOnline"; + +// 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, AppConstants, NewTabUtils and ShellService, 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" +); + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { NewTabUtils } = ChromeUtils.importESModule( + "resource://gre/modules/NewTabUtils.sys.mjs" +); + +// eslint-disable-next-line mozilla/use-static-import +const { ShellService } = ChromeUtils.importESModule( + "resource:///modules/ShellService.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + ASRouterPreferences: + "resource:///modules/asrouter/ASRouterPreferences.sys.mjs", + AttributionCode: "resource:///modules/AttributionCode.sys.mjs", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs", + WindowsLaunchOnLogin: "resource://gre/modules/WindowsLaunchOnLogin.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "cfrFeaturesUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "cfrAddonsUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isWhatsNewPanelEnabled", + "browser.messaging-system.whatsNewPanel.enabled", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasAccessedFxAPanel", + "identity.fxaccounts.toolbar.accessed", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "clientsDevicesDesktop", + "services.sync.clients.devices.desktop", + 0 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "clientsDevicesMobile", + "services.sync.clients.devices.mobile", + 0 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "syncNumClients", + "services.sync.numClients", + 0 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "devtoolsSelfXSSCount", + "devtools.selfxss.count", + 0 +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isFxAEnabled", + FXA_ENABLED_PREF, + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isXPIInstallEnabled", + "xpinstall.enabled", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasMigratedBookmarks", + "browser.migrate.interactions.bookmarks", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasMigratedCSVPasswords", + "browser.migrate.interactions.csvpasswords", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasMigratedHistory", + "browser.migrate.interactions.history", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hasMigratedPasswords", + "browser.migrate.interactions.passwords", + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "useEmbeddedMigrationWizard", + "browser.migrate.content-modal.about-welcome-behavior", + "default", + null, + behaviorString => { + return behaviorString === "embedded"; + } +); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + AUS: ["@mozilla.org/updates/update-service;1", "nsIApplicationUpdateService"], + BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"], + TrackingDBService: [ + "@mozilla.org/tracking-db-service;1", + "nsITrackingDBService", + ], + UpdateCheckSvc: ["@mozilla.org/updates/update-checker;1", "nsIUpdateChecker"], +}); + +const FXA_USERNAME_PREF = "services.sync.username"; + +const { activityStreamProvider: asProvider } = NewTabUtils; + +const FXA_ATTACHED_CLIENTS_UPDATE_INTERVAL = 4 * 60 * 60 * 1000; // Four hours +const FRECENT_SITES_UPDATE_INTERVAL = 6 * 60 * 60 * 1000; // Six hours +const FRECENT_SITES_IGNORE_BLOCKED = false; +const FRECENT_SITES_NUM_ITEMS = 25; +const FRECENT_SITES_MIN_FRECENCY = 100; + +const CACHE_EXPIRATION = 5 * 60 * 1000; +const jexlEvaluationCache = new Map(); + +/** + * CachedTargetingGetter + * @param property {string} Name of the method + * @param options {any=} Options passed to the method + * @param updateInterval {number?} Update interval for query. Defaults to FRECENT_SITES_UPDATE_INTERVAL + */ +export function CachedTargetingGetter( + property, + options = null, + updateInterval = FRECENT_SITES_UPDATE_INTERVAL, + getter = asProvider +) { + return { + _lastUpdated: 0, + _value: null, + // For testing + expire() { + this._lastUpdated = 0; + this._value = null; + }, + async get() { + const now = Date.now(); + if (now - this._lastUpdated >= updateInterval) { + this._value = await getter[property](options); + this._lastUpdated = now; + } + return this._value; + }, + }; +} + +function CacheListAttachedOAuthClients() { + return { + _lastUpdated: 0, + _value: null, + expire() { + this._lastUpdated = 0; + this._value = null; + }, + get() { + const now = Date.now(); + if (now - this._lastUpdated >= FXA_ATTACHED_CLIENTS_UPDATE_INTERVAL) { + this._value = new Promise(resolve => { + lazy.fxAccounts + .listAttachedOAuthClients() + .then(clients => { + resolve(clients); + }) + .catch(() => resolve([])); + }); + this._lastUpdated = now; + } + return this._value; + }, + }; +} + +function CheckBrowserNeedsUpdate( + updateInterval = FRECENT_SITES_UPDATE_INTERVAL +) { + const checker = { + _lastUpdated: 0, + _value: null, + // For testing. Avoid update check network call. + setUp(value) { + this._lastUpdated = Date.now(); + this._value = value; + }, + expire() { + this._lastUpdated = 0; + this._value = null; + }, + async get() { + const now = Date.now(); + if ( + !AppConstants.MOZ_UPDATER || + now - this._lastUpdated < updateInterval + ) { + return this._value; + } + if (!lazy.AUS.canCheckForUpdates) { + return false; + } + this._lastUpdated = now; + let check = lazy.UpdateCheckSvc.checkForUpdates( + lazy.UpdateCheckSvc.FOREGROUND_CHECK + ); + let result = await check.result; + if (!result.succeeded) { + lazy.ASRouterPreferences.console.error( + "CheckBrowserNeedsUpdate failed :>> ", + result.request + ); + return false; + } + checker._value = !!result.updates.length; + return checker._value; + }, + }; + + return checker; +} + +export const QueryCache = { + expireAll() { + Object.keys(this.queries).forEach(query => { + this.queries[query].expire(); + }); + Object.keys(this.getters).forEach(key => { + this.getters[key].expire(); + }); + }, + queries: { + TopFrecentSites: new CachedTargetingGetter("getTopFrecentSites", { + ignoreBlocked: FRECENT_SITES_IGNORE_BLOCKED, + numItems: FRECENT_SITES_NUM_ITEMS, + topsiteFrecency: FRECENT_SITES_MIN_FRECENCY, + onePerDomain: true, + includeFavicon: false, + }), + TotalBookmarksCount: new CachedTargetingGetter("getTotalBookmarksCount"), + CheckBrowserNeedsUpdate: new CheckBrowserNeedsUpdate(), + RecentBookmarks: new CachedTargetingGetter("getRecentBookmarks"), + ListAttachedOAuthClients: new CacheListAttachedOAuthClients(), + UserMonthlyActivity: new CachedTargetingGetter("getUserMonthlyActivity"), + }, + getters: { + doesAppNeedPin: new CachedTargetingGetter( + "doesAppNeedPin", + null, + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + doesAppNeedPrivatePin: new CachedTargetingGetter( + "doesAppNeedPin", + true, + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + isDefaultBrowser: new CachedTargetingGetter( + "isDefaultBrowser", + null, + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + currentThemes: new CachedTargetingGetter( + "getAddonsByTypes", + ["theme"], + FRECENT_SITES_UPDATE_INTERVAL, + lazy.AddonManager // eslint-disable-line mozilla/valid-lazy + ), + isDefaultHTMLHandler: new CachedTargetingGetter( + "isDefaultHandlerFor", + [".html"], + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + isDefaultPDFHandler: new CachedTargetingGetter( + "isDefaultHandlerFor", + [".pdf"], + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + defaultPDFHandler: new CachedTargetingGetter( + "getDefaultPDFHandler", + null, + FRECENT_SITES_UPDATE_INTERVAL, + ShellService + ), + }, +}; + +/** + * sortMessagesByWeightedRank + * + * Each message has an associated weight, which is guaranteed to be strictly + * positive. Sort the messages so that higher weighted messages are more likely + * to come first. + * + * Specifically, sort them so that the probability of message x_1 with weight + * w_1 appearing before message x_2 with weight w_2 is (w_1 / (w_1 + w_2)). + * + * This is equivalent to requiring that x_1 appearing before x_2 is (w_1 / w_2) + * "times" as likely as x_2 appearing before x_1. + * + * See Bug 1484996, Comment 2 for a justification of the method. + * + * @param {Array} messages - A non-empty array of messages to sort, all with + * strictly positive weights + * @returns the sorted array + */ +function sortMessagesByWeightedRank(messages) { + return messages + .map(message => ({ + message, + rank: Math.pow(Math.random(), 1 / message.weight), + })) + .sort((a, b) => b.rank - a.rank) + .map(({ message }) => message); +} + +/** + * getSortedMessages - Given an array of Messages, applies sorting and filtering rules + * in expected order. + * + * @param {Array} messages + * @param {{}} options + * @param {boolean} options.ordered - Should .order be used instead of random weighted sorting? + * @returns {Array} + */ +export function getSortedMessages(messages, options = {}) { + let { ordered } = { ordered: false, ...options }; + let result = messages; + + if (!ordered) { + result = sortMessagesByWeightedRank(result); + } + + result.sort((a, b) => { + // Next, sort by priority + if (a.priority > b.priority || (!isNaN(a.priority) && isNaN(b.priority))) { + return -1; + } + if (a.priority < b.priority || (isNaN(a.priority) && !isNaN(b.priority))) { + return 1; + } + + // Sort messages with targeting expressions higher than those with none + if (a.targeting && !b.targeting) { + return -1; + } + if (!a.targeting && b.targeting) { + return 1; + } + + // Next, sort by order *ascending* if ordered = true + if (ordered) { + if (a.order > b.order || (!isNaN(a.order) && isNaN(b.order))) { + return 1; + } + if (a.order < b.order || (isNaN(a.order) && !isNaN(b.order))) { + return -1; + } + } + + return 0; + }); + + return result; +} + +/** + * parseAboutPageURL - Parse a URL string retrieved from about:home and about:new, returns + * its type (web extenstion or custom url) and the parsed url(s) + * + * @param {string} url - A URL string for home page or newtab page + * @returns {Object} { + * isWebExt: boolean, + * isCustomUrl: boolean, + * urls: Array<{url: string, host: string}> + * } + */ +function parseAboutPageURL(url) { + let ret = { + isWebExt: false, + isCustomUrl: false, + urls: [], + }; + if (url.startsWith("moz-extension://")) { + ret.isWebExt = true; + ret.urls.push({ url, host: "" }); + } else { + // The home page URL could be either a single URL or a list of "|" separated URLs. + // Note that it should work with "about:home" and "about:blank", in which case the + // "host" is set as an empty string. + for (const _url of url.split("|")) { + if (!["about:home", "about:newtab", "about:blank"].includes(_url)) { + ret.isCustomUrl = true; + } + try { + const parsedURL = new URL(_url); + const host = parsedURL.hostname.replace(/^www\./i, ""); + ret.urls.push({ url: _url, host }); + } catch (e) {} + } + // If URL parsing failed, just return the given url with an empty host + if (!ret.urls.length) { + ret.urls.push({ url, host: "" }); + } + } + + return ret; +} + +/** + * Get the number of records in autofill storage, e.g. credit cards/addresses. + * + * @param {Object} [data] + * @param {string} [data.collectionName] + * The name used to specify which collection to retrieve records. + * @param {string} [data.searchString] + * The typed string for filtering out the matched records. + * @param {string} [data.info] + * The input autocomplete property's information. + * @returns {Promise} The number of matched records. + * @see FormAutofillParent._getRecords + */ +async function getAutofillRecords(data) { + let actor; + try { + const win = Services.wm.getMostRecentBrowserWindow(); + actor = + win.gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "FormAutofill" + ); + } catch (error) { + // If the actor is not available, we can't get the records. We could import + // the records directly from FormAutofillStorage to avoid the messiness of + // JSActors, but that would import a lot of code for a targeting attribute. + return 0; + } + let records = await actor?.receiveMessage({ + name: "FormAutofill:GetRecords", + data, + }); + return records?.records?.length ?? 0; +} + +// Attribution data can be encoded multiple times so we need this function to +// get a cleartext value. +function decodeAttributionValue(value) { + if (!value) { + return null; + } + + let decodedValue = value; + + while (decodedValue.includes("%")) { + try { + const result = decodeURIComponent(decodedValue); + if (result === decodedValue) { + break; + } + decodedValue = result; + } catch (e) { + break; + } + } + + return decodedValue; +} + +const TargetingGetters = { + get locale() { + return Services.locale.appLocaleAsBCP47; + }, + get localeLanguageCode() { + return ( + Services.locale.appLocaleAsBCP47 && + Services.locale.appLocaleAsBCP47.substr(0, 2) + ); + }, + get browserSettings() { + const { settings } = lazy.TelemetryEnvironment.currentEnvironment; + return { + update: settings.update, + }; + }, + get attributionData() { + // Attribution is determined at startup - so we can use the cached attribution at this point + return lazy.AttributionCode.getCachedAttributionData(); + }, + get currentDate() { + return new Date(); + }, + get profileAgeCreated() { + return lazy.ProfileAge().then(times => times.created); + }, + get profileAgeReset() { + return lazy.ProfileAge().then(times => times.reset); + }, + get usesFirefoxSync() { + return Services.prefs.prefHasUserValue(FXA_USERNAME_PREF); + }, + get isFxAEnabled() { + return lazy.isFxAEnabled; + }, + get isFxASignedIn() { + return new Promise(resolve => { + if (!lazy.isFxAEnabled) { + resolve(false); + } + if (Services.prefs.getStringPref(FXA_USERNAME_PREF, "")) { + resolve(true); + } + lazy.fxAccounts + .getSignedInUser() + .then(data => resolve(!!data)) + .catch(e => resolve(false)); + }); + }, + get sync() { + return { + desktopDevices: lazy.clientsDevicesDesktop, + mobileDevices: lazy.clientsDevicesMobile, + totalDevices: lazy.syncNumClients, + }; + }, + get xpinstallEnabled() { + // This is needed for all add-on recommendations, to know if we allow xpi installs in the first place + return lazy.isXPIInstallEnabled; + }, + get addonsInfo() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + return { addons: {}, isFullData: true }; + } + + return lazy.AddonManager.getActiveAddons(["extension", "service"]).then( + ({ addons, fullData }) => { + const info = {}; + for (const addon of addons) { + info[addon.id] = { + version: addon.version, + type: addon.type, + isSystem: addon.isSystem, + isWebExtension: addon.isWebExtension, + }; + if (fullData) { + Object.assign(info[addon.id], { + name: addon.name, + userDisabled: addon.userDisabled, + installDate: addon.installDate, + }); + } + } + return { addons: info, isFullData: fullData }; + } + ); + }, + get searchEngines() { + const NONE = { installed: [], current: "" }; + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + return Promise.resolve(NONE); + } + return new Promise(resolve => { + // Note: calling init ensures this code is only executed after Search has been initialized + Services.search + .getAppProvidedEngines() + .then(engines => { + resolve({ + current: Services.search.defaultEngine.identifier, + installed: engines.map(engine => engine.identifier), + }); + }) + .catch(() => resolve(NONE)); + }); + }, + get isDefaultBrowser() { + return QueryCache.getters.isDefaultBrowser.get().catch(() => null); + }, + get devToolsOpenedCount() { + return lazy.devtoolsSelfXSSCount; + }, + get topFrecentSites() { + return QueryCache.queries.TopFrecentSites.get().then(sites => + sites.map(site => ({ + url: site.url, + host: new URL(site.url).hostname, + frecency: site.frecency, + lastVisitDate: site.lastVisitDate, + })) + ); + }, + get recentBookmarks() { + return QueryCache.queries.RecentBookmarks.get(); + }, + get pinnedSites() { + return NewTabUtils.pinnedLinks.links.map(site => + site + ? { + url: site.url, + host: new URL(site.url).hostname, + searchTopSite: site.searchTopSite, + } + : {} + ); + }, + get providerCohorts() { + return lazy.ASRouterPreferences.providers.reduce((prev, current) => { + prev[current.id] = current.cohort || ""; + return prev; + }, {}); + }, + get totalBookmarksCount() { + return QueryCache.queries.TotalBookmarksCount.get(); + }, + get firefoxVersion() { + return parseInt(AppConstants.MOZ_APP_VERSION.match(/\d+/), 10); + }, + get region() { + return lazy.Region.home || ""; + }, + get needsUpdate() { + return QueryCache.queries.CheckBrowserNeedsUpdate.get(); + }, + get hasPinnedTabs() { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (win.closed || !win.ownerGlobal.gBrowser) { + continue; + } + if (win.ownerGlobal.gBrowser.visibleTabs.filter(t => t.pinned).length) { + return true; + } + } + + return false; + }, + get hasAccessedFxAPanel() { + return lazy.hasAccessedFxAPanel; + }, + get isWhatsNewPanelEnabled() { + return lazy.isWhatsNewPanelEnabled; + }, + get userPrefs() { + return { + cfrFeatures: lazy.cfrFeaturesUserPref, + cfrAddons: lazy.cfrAddonsUserPref, + }; + }, + get totalBlockedCount() { + return lazy.TrackingDBService.sumAllEvents(); + }, + get blockedCountByType() { + const idToTextMap = new Map([ + [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"], + [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"], + [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"], + [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"], + [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"], + ]); + + const dateTo = new Date(); + const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000); + return lazy.TrackingDBService.getEventsByDateRange(dateFrom, dateTo).then( + eventsByDate => { + let totalEvents = {}; + for (let blockedType of idToTextMap.values()) { + totalEvents[blockedType] = 0; + } + + return eventsByDate.reduce((acc, day) => { + const type = day.getResultByName("type"); + const count = day.getResultByName("count"); + acc[idToTextMap.get(type)] = acc[idToTextMap.get(type)] + count; + return acc; + }, totalEvents); + } + ); + }, + get attachedFxAOAuthClients() { + return this.usesFirefoxSync + ? QueryCache.queries.ListAttachedOAuthClients.get() + : []; + }, + get platformName() { + return AppConstants.platform; + }, + get isChinaRepack() { + return ( + Services.prefs + .getDefaultBranch(null) + .getCharPref(DISTRIBUTION_ID_PREF, "default") === + DISTRIBUTION_ID_CHINA_REPACK + ); + }, + get userId() { + return lazy.ClientEnvironment.userId; + }, + get profileRestartCount() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + return 0; + } + // Counter starts at 1 when a profile is created, substract 1 so the value + // returned matches expectations + return ( + lazy.TelemetrySession.getMetadata("targeting").profileSubsessionCounter - + 1 + ); + }, + get homePageSettings() { + const url = lazy.HomePage.get(); + const { isWebExt, isCustomUrl, urls } = parseAboutPageURL(url); + + return { + isWebExt, + isCustomUrl, + urls, + isDefault: lazy.HomePage.isDefault, + isLocked: lazy.HomePage.locked, + }; + }, + get newtabSettings() { + const url = lazy.AboutNewTab.newTabURL; + const { isWebExt, isCustomUrl, urls } = parseAboutPageURL(url); + + return { + isWebExt, + isCustomUrl, + isDefault: lazy.AboutNewTab.activityStreamEnabled, + url: urls[0].url, + host: urls[0].host, + }; + }, + get activeNotifications() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + if (bts?.isBackgroundTaskMode) { + // This might need to hook into the alert service to enumerate relevant + // persistent native notifications. + return false; + } + + let window = lazy.BrowserWindowTracker.getTopWindow(); + + // Technically this doesn't mean we have active notifications, + // but because we use !activeNotifications to check for conflicts, this should return true + if (!window) { + return true; + } + + if ( + window.gURLBar?.view.isOpen || + window.gNotificationBox?.currentNotification || + window.gBrowser.getNotificationBox()?.currentNotification + ) { + return true; + } + + return false; + }, + + get isMajorUpgrade() { + return lazy.BrowserHandler.majorUpgrade; + }, + + get hasActiveEnterprisePolicies() { + return Services.policies.status === Services.policies.ACTIVE; + }, + + get userMonthlyActivity() { + return QueryCache.queries.UserMonthlyActivity.get(); + }, + + get doesAppNeedPin() { + return QueryCache.getters.doesAppNeedPin.get(); + }, + + get doesAppNeedPrivatePin() { + return QueryCache.getters.doesAppNeedPrivatePin.get(); + }, + + get launchOnLoginEnabled() { + if (AppConstants.platform !== "win") { + return false; + } + return lazy.WindowsLaunchOnLogin.getLaunchOnLoginEnabled(); + }, + + /** + * Is this invocation running in background task mode? + * + * @return {boolean} `true` if running in background task mode. + */ + get isBackgroundTaskMode() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + return !!bts?.isBackgroundTaskMode; + }, + + /** + * A non-empty task name if this invocation is running in background + * task mode, or `null` if this invocation is not running in + * background task mode. + * + * @return {string|null} background task name or `null`. + */ + get backgroundTaskName() { + let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService( + Ci.nsIBackgroundTasks + ); + return bts?.backgroundTaskName(); + }, + + get userPrefersReducedMotion() { + let window = Services.appShell.hiddenDOMWindow; + return window?.matchMedia("(prefers-reduced-motion: reduce)")?.matches; + }, + + /** + * Whether or not the user is in the Major Release 2022 holdback study. + */ + get inMr2022Holdback() { + return ( + lazy.NimbusFeatures.majorRelease2022.getVariable("onboarding") === false + ); + }, + + /** + * The distribution id, if any. + * @return {string} + */ + get distributionId() { + return Services.prefs + .getDefaultBranch(null) + .getCharPref("distribution.id", ""); + }, + + /** Where the Firefox View button is shown, if at all. + * @return {string} container of the button if it is shown in the toolbar/overflow menu + * @return {string} `null` if the button has been removed + */ + get fxViewButtonAreaType() { + let button = lazy.CustomizableUI.getWidget("firefox-view-button"); + return button.areaType; + }, + + isDefaultHandler: { + get html() { + return QueryCache.getters.isDefaultHTMLHandler.get(); + }, + get pdf() { + return QueryCache.getters.isDefaultPDFHandler.get(); + }, + }, + + get defaultPDFHandler() { + return QueryCache.getters.defaultPDFHandler.get(); + }, + + get creditCardsSaved() { + return getAutofillRecords({ collectionName: "creditCards" }); + }, + + get addressesSaved() { + return getAutofillRecords({ collectionName: "addresses" }); + }, + + /** + * Has the user ever used the Migration Wizard to migrate bookmarks? + * @return {boolean} `true` if bookmark migration has occurred. + */ + get hasMigratedBookmarks() { + return lazy.hasMigratedBookmarks; + }, + + /** + * Has the user ever used the Migration Wizard to migrate passwords from + * a CSV file? + * @return {boolean} `true` if CSV passwords have been imported via the + * migration wizard. + */ + get hasMigratedCSVPasswords() { + return lazy.hasMigratedCSVPasswords; + }, + + /** + * Has the user ever used the Migration Wizard to migrate history? + * @return {boolean} `true` if history migration has occurred. + */ + get hasMigratedHistory() { + return lazy.hasMigratedHistory; + }, + + /** + * Has the user ever used the Migration Wizard to migrate passwords? + * @return {boolean} `true` if password migration has occurred. + */ + get hasMigratedPasswords() { + return lazy.hasMigratedPasswords; + }, + + /** + * Returns true if the user is configured to use the embedded migration + * wizard in about:welcome by having + * "browser.migrate.content-modal.about-welcome-behavior" be equal to + * "embedded". + * @return {boolean} `true` if the embedded migration wizard is enabled. + */ + get useEmbeddedMigrationWizard() { + return lazy.useEmbeddedMigrationWizard; + }, + + /** + * Whether the user installed Firefox via the RTAMO flow. + * @return {boolean} `true` when RTAMO has been used to download Firefox, + * `false` otherwise. + */ + get isRTAMO() { + const { attributionData } = this; + + return ( + attributionData?.source === "addons.mozilla.org" && + !!decodeAttributionValue(attributionData?.content)?.startsWith("rta:") + ); + }, + + /** + * Whether the user installed via the device migration flow. + * @return {boolean} `true` when the link to download the browser was part + * of guidance for device migration. `false` otherwise. + */ + get isDeviceMigration() { + const { attributionData } = this; + + return attributionData?.campaign === "migration"; + }, + + /** + * The values of the height and width available to the browser to display + * web content. The available height and width are each calculated taking + * into account the presence of menu bars, docks, and other similar OS elements + * @returns {Object} resolution The resolution object containing width and height + * @returns {string} resolution.width The available width of the primary monitor + * @returns {string} resolution.height The available height of the primary monitor + */ + get primaryResolution() { + // Using hidden dom window ensures that we have a window object + // to grab a screen from in certain edge cases such as targeting evaluation + // during first startup before the browser is available, and in MacOS + let window = Services.appShell.hiddenDOMWindow; + return { + width: window?.screen.availWidth, + height: window?.screen.availHeight, + }; + }, + + get archBits() { + let bits = null; + try { + bits = Services.sysinfo.getProperty("archbits", null); + } catch (_e) { + // getProperty can throw if the memsize does not exist + } + if (bits) { + bits = Number(bits); + } + return bits; + }, + + get memoryMB() { + let memory = null; + try { + memory = Services.sysinfo.getProperty("memsize", null); + } catch (_e) { + // getProperty can throw if the memsize does not exist + } + if (memory) { + memory = Number(memory) / 1024 / 1024; + } + return memory; + }, +}; + +export const ASRouterTargeting = { + Environment: TargetingGetters, + + /** + * Snapshot the current targeting environment. + * + * Asynchronous getters are handled. Getters that throw or reject + * are ignored. + * + * Leftward (earlier) targets supercede rightward (later) targets, just like + * `TargetingContext.combineContexts`. + * + * @param {object} options - object containing: + * @param {Array|null} options.targets - + * targeting environments to snapshot; (default: `[ASRouterTargeting.Environment]`) + * @return {object} snapshot of target with `environment` object and `version` integer. + */ + async getEnvironmentSnapshot({ + targets = [ASRouterTargeting.Environment], + } = {}) { + async function resolve(object) { + if (typeof object === "object" && object !== null) { + if (Array.isArray(object)) { + return Promise.all(object.map(async item => resolve(await item))); + } + + if (object instanceof Date) { + return object; + } + + // One promise for each named property. Label promises with property name. + const promises = Object.keys(object).map(async key => { + // Each promise needs to check if we're shutting down when it is evaluated. + if (Services.startup.shuttingDown) { + throw new Error( + "shutting down, so not querying targeting environment" + ); + } + + const value = await resolve(await object[key]); + + return [key, value]; + }); + + const resolved = {}; + for (const result of await Promise.allSettled(promises)) { + // Ignore properties that are rejected. + if (result.status === "fulfilled") { + const [key, value] = result.value; + resolved[key] = value; + } + } + + return resolved; + } + + return object; + } + + // We would like to use `TargetingContext.combineContexts`, but `Proxy` + // instances complicate iterating with `Object.keys`. Instead, merge by + // hand after resolving. + const environment = {}; + for (let target of targets.toReversed()) { + Object.assign(environment, await resolve(target)); + } + + // Should we need to migrate in the future. + const snapshot = { environment, version: 1 }; + + return snapshot; + }, + + isTriggerMatch(trigger = {}, candidateMessageTrigger = {}) { + if (trigger.id !== candidateMessageTrigger.id) { + return false; + } else if ( + !candidateMessageTrigger.params && + !candidateMessageTrigger.patterns + ) { + return true; + } + + if (!trigger.param) { + return false; + } + + return ( + (candidateMessageTrigger.params && + trigger.param.host && + candidateMessageTrigger.params.includes(trigger.param.host)) || + (candidateMessageTrigger.params && + trigger.param.type && + candidateMessageTrigger.params.filter(t => t === trigger.param.type) + .length) || + (candidateMessageTrigger.params && + trigger.param.type && + candidateMessageTrigger.params.filter( + t => (t & trigger.param.type) === t + ).length) || + (candidateMessageTrigger.patterns && + trigger.param.url && + new MatchPatternSet(candidateMessageTrigger.patterns).matches( + trigger.param.url + )) + ); + }, + + /** + * getCachedEvaluation - Return a cached jexl evaluation if available + * + * @param {string} targeting JEXL expression to lookup + * @returns {obj|null} Object with value result or null if not available + */ + getCachedEvaluation(targeting) { + if (jexlEvaluationCache.has(targeting)) { + const { timestamp, value } = jexlEvaluationCache.get(targeting); + if (Date.now() - timestamp <= CACHE_EXPIRATION) { + return { value }; + } + jexlEvaluationCache.delete(targeting); + } + + return null; + }, + + /** + * checkMessageTargeting - Checks is a message's targeting parameters are satisfied + * + * @param {*} message An AS router message + * @param {obj} targetingContext a TargetingContext instance complete with eval environment + * @param {func} onError A function to handle errors (takes two params; error, message) + * @param {boolean} shouldCache Should the JEXL evaluations be cached and reused. + * @returns + */ + async checkMessageTargeting(message, targetingContext, onError, shouldCache) { + lazy.ASRouterPreferences.console.debug( + "in checkMessageTargeting, arguments = ", + Array.from(arguments) // eslint-disable-line prefer-rest-params + ); + + // If no targeting is specified, + if (!message.targeting) { + return true; + } + let result; + try { + if (shouldCache) { + result = this.getCachedEvaluation(message.targeting); + if (result) { + return result.value; + } + } + // Used to report the source of the targeting error in the case of + // undesired events + targetingContext.setTelemetrySource(message.id); + result = await targetingContext.evalWithDefault(message.targeting); + if (shouldCache) { + jexlEvaluationCache.set(message.targeting, { + timestamp: Date.now(), + value: result, + }); + } + } catch (error) { + if (onError) { + onError(error, message); + } + console.error(error); + result = false; + } + return result; + }, + + _isMessageMatch( + message, + trigger, + targetingContext, + onError, + shouldCache = false + ) { + return ( + message && + (trigger + ? this.isTriggerMatch(trigger, message.trigger) + : !message.trigger) && + // If a trigger expression was passed to this function, the message should match it. + // Otherwise, we should choose a message with no trigger property (i.e. a message that can show up at any time) + this.checkMessageTargeting( + message, + targetingContext, + onError, + shouldCache + ) + ); + }, + + /** + * findMatchingMessage - Given an array of messages, returns one message + * whos targeting expression evaluates to true + * + * @param {Array} messages An array of AS router messages + * @param {trigger} string A trigger expression if a message for that trigger is desired + * @param {obj|null} context A FilterExpression context. Defaults to TargetingGetters above. + * @param {func} onError A function to handle errors (takes two params; error, message) + * @param {func} ordered An optional param when true sort message by order specified in message + * @param {boolean} shouldCache Should the JEXL evaluations be cached and reused. + * @param {boolean} returnAll Should we return all matching messages, not just the first one found. + * @returns {obj|Array} If returnAll is false, a single message. If returnAll is true, an array of messages. + */ + async findMatchingMessage({ + messages, + trigger = {}, + context = {}, + onError, + ordered = false, + shouldCache = false, + returnAll = false, + }) { + const sortedMessages = getSortedMessages(messages, { ordered }); + lazy.ASRouterPreferences.console.debug( + "in findMatchingMessage, sortedMessages = ", + sortedMessages + ); + const matching = returnAll ? [] : null; + const targetingContext = new lazy.TargetingContext( + lazy.TargetingContext.combineContexts( + context, + this.Environment, + trigger.context || {} + ) + ); + + const isMatch = candidate => + this._isMessageMatch( + candidate, + trigger, + targetingContext, + onError, + shouldCache + ); + + for (const candidate of sortedMessages) { + if (await isMatch(candidate)) { + // If not returnAll, we should return the first message we find that matches. + if (!returnAll) { + return candidate; + } + + matching.push(candidate); + } + } + return matching; + }, +}; diff --git a/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs b/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs new file mode 100644 index 0000000000..d8eaa3994d --- /dev/null +++ b/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs @@ -0,0 +1,1439 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs", + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + EveryWindow: "resource:///modules/EveryWindow.sys.mjs", + FeatureCalloutBroker: + "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("ASRouterTriggerListeners"); +}); + +const FEW_MINUTES = 15 * 60 * 1000; // 15 mins + +function isPrivateWindow(win) { + return ( + !(win instanceof Ci.nsIDOMWindow) || + win.closed || + lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ); +} + +/** + * Check current location against the list of allowed hosts + * Additionally verify for redirects and check original request URL against + * the list. + * + * @returns {object} - {host, url} pair that matched the list of allowed hosts + */ +function checkURLMatch(aLocationURI, { hosts, matchPatternSet }, aRequest) { + // If checks pass we return a match + let match; + try { + match = { host: aLocationURI.host, url: aLocationURI.spec }; + } catch (e) { + // nsIURI.host can throw for non-nsStandardURL nsIURIs + return false; + } + + // Check current location against allowed hosts + if (hosts.has(match.host)) { + return match; + } + + if (matchPatternSet) { + if (matchPatternSet.matches(match.url)) { + return match; + } + } + + // Nothing else to check, return early + if (!aRequest) { + return false; + } + + // The original URL at the start of the request + const originalLocation = aRequest.QueryInterface(Ci.nsIChannel).originalURI; + // We have been redirected + if (originalLocation.spec !== aLocationURI.spec) { + return ( + hosts.has(originalLocation.host) && { + host: originalLocation.host, + url: originalLocation.spec, + } + ); + } + + return false; +} + +function createMatchPatternSet(patterns, flags) { + try { + return new MatchPatternSet(new Set(patterns), flags); + } catch (e) { + console.error(e); + } + return new MatchPatternSet([]); +} + +/** + * A Map from trigger IDs to singleton trigger listeners. Each listener must + * have idempotent `init` and `uninit` methods. + */ +export const ASRouterTriggerListeners = new Map([ + [ + "openArticleURL", + { + id: "openArticleURL", + _initialized: false, + _triggerHandler: null, + _hosts: new Set(), + _matchPatternSet: null, + readerModeEvent: "Reader:UpdateReaderButton", + + init(triggerHandler, hosts, patterns) { + if (!this._initialized) { + this.receiveMessage = this.receiveMessage.bind(this); + lazy.AboutReaderParent.addMessageListener(this.readerModeEvent, this); + this._triggerHandler = triggerHandler; + this._initialized = true; + } + if (patterns) { + this._matchPatternSet = createMatchPatternSet([ + ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), + ...patterns, + ]); + } + if (hosts) { + hosts.forEach(h => this._hosts.add(h)); + } + }, + + receiveMessage({ data, target }) { + if (data && data.isArticle) { + const match = checkURLMatch(target.currentURI, { + hosts: this._hosts, + matchPatternSet: this._matchPatternSet, + }); + if (match) { + this._triggerHandler(target, { id: this.id, param: match }); + } + } + }, + + uninit() { + if (this._initialized) { + lazy.AboutReaderParent.removeMessageListener( + this.readerModeEvent, + this + ); + this._initialized = false; + this._triggerHandler = null; + this._hosts = new Set(); + this._matchPatternSet = null; + } + }, + }, + ], + [ + "openBookmarkedURL", + { + id: "openBookmarkedURL", + _initialized: false, + _triggerHandler: null, + _hosts: new Set(), + bookmarkEvent: "bookmark-icon-updated", + + init(triggerHandler) { + if (!this._initialized) { + Services.obs.addObserver(this, this.bookmarkEvent); + this._triggerHandler = triggerHandler; + this._initialized = true; + } + }, + + observe(subject, topic, data) { + if (topic === this.bookmarkEvent && data === "starred") { + const browser = Services.wm.getMostRecentBrowserWindow(); + if (browser) { + this._triggerHandler(browser.gBrowser.selectedBrowser, { + id: this.id, + }); + } + } + }, + + uninit() { + if (this._initialized) { + Services.obs.removeObserver(this, this.bookmarkEvent); + this._initialized = false; + this._triggerHandler = null; + this._hosts = new Set(); + } + }, + }, + ], + [ + "frequentVisits", + { + id: "frequentVisits", + _initialized: false, + _triggerHandler: null, + _hosts: null, + _matchPatternSet: null, + _visits: null, + + init(triggerHandler, hosts = [], patterns) { + if (!this._initialized) { + this.onTabSwitch = this.onTabSwitch.bind(this); + lazy.EveryWindow.registerCallback( + this.id, + win => { + if (!isPrivateWindow(win)) { + win.addEventListener("TabSelect", this.onTabSwitch); + win.gBrowser.addTabsProgressListener(this); + } + }, + win => { + if (!isPrivateWindow(win)) { + win.removeEventListener("TabSelect", this.onTabSwitch); + win.gBrowser.removeTabsProgressListener(this); + } + } + ); + this._visits = new Map(); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + if (patterns) { + this._matchPatternSet = createMatchPatternSet([ + ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), + ...patterns, + ]); + } + if (this._hosts) { + hosts.forEach(h => this._hosts.add(h)); + } else { + this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour + } + }, + + /* _updateVisits - Record visit timestamps for websites that match `this._hosts` and only + * if it's been more than FEW_MINUTES since the last visit. + * @param {string} host - Location host of current selected tab + * @returns {boolean} - If the new visit has been recorded + */ + _updateVisits(host) { + const visits = this._visits.get(host); + + if (visits && Date.now() - visits[0] > FEW_MINUTES) { + this._visits.set(host, [Date.now(), ...visits]); + return true; + } + if (!visits) { + this._visits.set(host, [Date.now()]); + return true; + } + + return false; + }, + + onTabSwitch(event) { + if (!event.target.ownerGlobal.gBrowser) { + return; + } + + const { gBrowser } = event.target.ownerGlobal; + const match = checkURLMatch(gBrowser.currentURI, { + hosts: this._hosts, + matchPatternSet: this._matchPatternSet, + }); + if (match) { + this.triggerHandler(gBrowser.selectedBrowser, match); + } + }, + + triggerHandler(aBrowser, match) { + const updated = this._updateVisits(match.host); + + // If the previous visit happend less than FEW_MINUTES ago + // no updates were made, no need to trigger the handler + if (!updated) { + return; + } + + this._triggerHandler(aBrowser, { + id: this.id, + param: match, + context: { + // Remapped to {host, timestamp} because JEXL operators can only + // filter over collections (arrays of objects) + recentVisits: this._visits + .get(match.host) + .map(timestamp => ({ host: match.host, timestamp })), + }, + }); + }, + + onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) { + // Some websites trigger redirect events after they finish loading even + // though the location remains the same. This results in onLocationChange + // events to be fired twice. + const isSameDocument = !!( + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + if (aWebProgress.isTopLevel && !isSameDocument) { + const match = checkURLMatch( + aLocationURI, + { hosts: this._hosts, matchPatternSet: this._matchPatternSet }, + aRequest + ); + if (match) { + this.triggerHandler(aBrowser, match); + } + } + }, + + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + + this._initialized = false; + this._triggerHandler = null; + this._hosts = null; + this._matchPatternSet = null; + this._visits = null; + } + }, + }, + ], + + /** + * Attach listeners to every browser window to detect location changes, and + * notify the trigger handler whenever we navigate to a URL with a hostname + * we're looking for. + */ + [ + "openURL", + { + id: "openURL", + _initialized: false, + _triggerHandler: null, + _hosts: null, + _matchPatternSet: null, + _visits: null, + + /* + * If the listener is already initialised, `init` will replace the trigger + * handler and add any new hosts to `this._hosts`. + */ + init(triggerHandler, hosts = [], patterns) { + if (!this._initialized) { + this.onLocationChange = this.onLocationChange.bind(this); + lazy.EveryWindow.registerCallback( + this.id, + win => { + if (!isPrivateWindow(win)) { + win.gBrowser.addTabsProgressListener(this); + } + }, + win => { + if (!isPrivateWindow(win)) { + win.gBrowser.removeTabsProgressListener(this); + } + } + ); + + this._visits = new Map(); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + if (patterns) { + this._matchPatternSet = createMatchPatternSet([ + ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), + ...patterns, + ]); + } + if (this._hosts) { + hosts.forEach(h => this._hosts.add(h)); + } else { + this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour + } + }, + + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + + this._initialized = false; + this._triggerHandler = null; + this._hosts = null; + this._matchPatternSet = null; + this._visits = null; + } + }, + + onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) { + // Some websites trigger redirect events after they finish loading even + // though the location remains the same. This results in onLocationChange + // events to be fired twice. + const isSameDocument = !!( + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + if (aWebProgress.isTopLevel && !isSameDocument) { + const match = checkURLMatch( + aLocationURI, + { hosts: this._hosts, matchPatternSet: this._matchPatternSet }, + aRequest + ); + if (match) { + let visitsCount = (this._visits.get(match.url) || 0) + 1; + this._visits.set(match.url, visitsCount); + this._triggerHandler(aBrowser, { + id: this.id, + param: match, + context: { visitsCount }, + }); + } + } + }, + }, + ], + + /** + * Add an observer notification to notify the trigger handler whenever the user + * saves or updates a login via the login capture doorhanger. + */ + [ + "newSavedLogin", + { + _initialized: false, + _triggerHandler: null, + + /** + * If the listener is already initialised, `init` will replace the trigger + * handler. + */ + init(triggerHandler) { + if (!this._initialized) { + Services.obs.addObserver(this, "LoginStats:NewSavedPassword"); + Services.obs.addObserver(this, "LoginStats:LoginUpdateSaved"); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + Services.obs.removeObserver(this, "LoginStats:NewSavedPassword"); + Services.obs.removeObserver(this, "LoginStats:LoginUpdateSaved"); + + this._initialized = false; + this._triggerHandler = null; + } + }, + + observe(aSubject, aTopic, aData) { + if (aSubject.currentURI.asciiHost === "accounts.firefox.com") { + // Don't notify about saved logins on the FxA login origin since this + // trigger is used to promote login Sync and getting a recommendation + // to enable Sync during the sign up process is a bad UX. + return; + } + + switch (aTopic) { + case "LoginStats:NewSavedPassword": { + this._triggerHandler(aSubject, { + id: "newSavedLogin", + context: { type: "save" }, + }); + break; + } + case "LoginStats:LoginUpdateSaved": { + this._triggerHandler(aSubject, { + id: "newSavedLogin", + context: { type: "update" }, + }); + break; + } + default: { + throw new Error(`Unexpected observer notification: ${aTopic}`); + } + } + }, + }, + ], + [ + "formAutofill", + { + id: "formAutofill", + _initialized: false, + _triggerHandler: null, + _triggerDelay: 10000, // 10 second delay before triggering + _topic: "formautofill-storage-changed", + _events: ["add", "update", "notifyUsed"] /** @see AutofillRecords */, + _collections: ["addresses", "creditCards"] /** @see AutofillRecords */, + + init(triggerHandler) { + if (!this._initialized) { + Services.obs.addObserver(this, this._topic); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + Services.obs.removeObserver(this, this._topic); + this._initialized = false; + this._triggerHandler = null; + } + }, + + observe(subject, topic, data) { + const browser = + Services.wm.getMostRecentBrowserWindow()?.gBrowser.selectedBrowser; + if ( + !browser || + topic !== this._topic || + !subject.wrappedJSObject || + // Ignore changes caused by manual edits in the credit card/address + // managers in about:preferences. + browser.contentWindow?.gSubDialog?.dialogs.length + ) { + return; + } + let { sourceSync, collectionName } = subject.wrappedJSObject; + // Ignore changes from sync and changes to untracked collections. + if (sourceSync || !this._collections.includes(collectionName)) { + return; + } + if (this._events.includes(data)) { + let event = data; + let type = collectionName; + if (event === "notifyUsed") { + event = "use"; + } + if (type === "creditCards") { + type = "card"; + } + if (type === "addresses") { + type = "address"; + } + lazy.setTimeout(() => { + if ( + this._initialized && + // Make sure the browser still exists and is still selected. + browser.isConnectedAndReady && + browser === + Services.wm.getMostRecentBrowserWindow()?.gBrowser + .selectedBrowser + ) { + this._triggerHandler(browser, { + id: this.id, + context: { event, type }, + }); + } + }, this._triggerDelay); + } + }, + }, + ], + + [ + "contentBlocking", + { + _initialized: false, + _triggerHandler: null, + _events: [], + _sessionPageLoad: 0, + onLocationChange: null, + + init(triggerHandler, params, patterns) { + params.forEach(p => this._events.push(p)); + + if (!this._initialized) { + Services.obs.addObserver(this, "SiteProtection:ContentBlockingEvent"); + Services.obs.addObserver( + this, + "SiteProtection:ContentBlockingMilestone" + ); + this.onLocationChange = this._onLocationChange.bind(this); + lazy.EveryWindow.registerCallback( + this.id, + win => { + if (!isPrivateWindow(win)) { + win.gBrowser.addTabsProgressListener(this); + } + }, + win => { + if (!isPrivateWindow(win)) { + win.gBrowser.removeTabsProgressListener(this); + } + } + ); + + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + Services.obs.removeObserver( + this, + "SiteProtection:ContentBlockingEvent" + ); + Services.obs.removeObserver( + this, + "SiteProtection:ContentBlockingMilestone" + ); + lazy.EveryWindow.unregisterCallback(this.id); + this.onLocationChange = null; + this._initialized = false; + } + this._triggerHandler = null; + this._events = []; + this._sessionPageLoad = 0; + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "SiteProtection:ContentBlockingEvent": + const { browser, host, event } = aSubject.wrappedJSObject; + if (this._events.filter(e => (e & event) === e).length) { + this._triggerHandler(browser, { + id: "contentBlocking", + param: { + host, + type: event, + }, + context: { + pageLoad: this._sessionPageLoad, + }, + }); + } + break; + case "SiteProtection:ContentBlockingMilestone": + if (this._events.includes(aSubject.wrappedJSObject.event)) { + this._triggerHandler( + Services.wm.getMostRecentBrowserWindow().gBrowser + .selectedBrowser, + { + id: "contentBlocking", + context: { + pageLoad: this._sessionPageLoad, + }, + param: { + type: aSubject.wrappedJSObject.event, + }, + } + ); + } + break; + } + }, + + _onLocationChange( + aBrowser, + aWebProgress, + aRequest, + aLocationURI, + aFlags + ) { + // Some websites trigger redirect events after they finish loading even + // though the location remains the same. This results in onLocationChange + // events to be fired twice. + const isSameDocument = !!( + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + if ( + ["http", "https"].includes(aLocationURI.scheme) && + aWebProgress.isTopLevel && + !isSameDocument + ) { + this._sessionPageLoad += 1; + } + }, + }, + ], + + [ + "captivePortalLogin", + { + id: "captivePortalLogin", + _initialized: false, + _triggerHandler: null, + + _shouldShowCaptivePortalVPNPromo() { + return lazy.BrowserUtils.shouldShowVPNPromo(); + }, + + init(triggerHandler) { + if (!this._initialized) { + Services.obs.addObserver(this, "captive-portal-login-success"); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "captive-portal-login-success": + const browser = Services.wm.getMostRecentBrowserWindow(); + // The check is here rather than in init because some + // folks leave their browsers running for a long time, + // eg from before leaving on a plane trip to after landing + // in the new destination, and the current region may have + // changed since init time. + if (browser && this._shouldShowCaptivePortalVPNPromo()) { + this._triggerHandler(browser.gBrowser.selectedBrowser, { + id: this.id, + }); + } + break; + } + }, + + uninit() { + if (this._initialized) { + this._triggerHandler = null; + this._initialized = false; + Services.obs.removeObserver(this, "captive-portal-login-success"); + } + }, + }, + ], + + [ + "preferenceObserver", + { + id: "preferenceObserver", + _initialized: false, + _triggerHandler: null, + _observedPrefs: [], + + init(triggerHandler, prefs) { + if (!this._initialized) { + this._triggerHandler = triggerHandler; + this._initialized = true; + } + prefs.forEach(pref => { + this._observedPrefs.push(pref); + Services.prefs.addObserver(pref, this); + }); + }, + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "nsPref:changed": + const browser = Services.wm.getMostRecentBrowserWindow(); + if (browser && this._observedPrefs.includes(aData)) { + this._triggerHandler(browser.gBrowser.selectedBrowser, { + id: this.id, + param: { + type: aData, + }, + }); + } + break; + } + }, + + uninit() { + if (this._initialized) { + this._observedPrefs.forEach(pref => + Services.prefs.removeObserver(pref, this) + ); + this._initialized = false; + this._triggerHandler = null; + this._observedPrefs = []; + } + }, + }, + ], + [ + "nthTabClosed", + { + id: "nthTabClosed", + _initialized: false, + _triggerHandler: null, + // Number of tabs the user closed this session + _closedTabs: 0, + + init(triggerHandler) { + this._triggerHandler = triggerHandler; + if (!this._initialized) { + lazy.EveryWindow.registerCallback( + this.id, + win => { + win.addEventListener("TabClose", this); + }, + win => { + win.removeEventListener("TabClose", this); + } + ); + this._initialized = true; + } + }, + handleEvent(event) { + if (this._initialized) { + if (!event.target.ownerGlobal.gBrowser) { + return; + } + const { gBrowser } = event.target.ownerGlobal; + this._closedTabs++; + this._triggerHandler(gBrowser.selectedBrowser, { + id: this.id, + context: { tabsClosedCount: this._closedTabs }, + }); + } + }, + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + this._closedTabs = 0; + } + }, + }, + ], + [ + "activityAfterIdle", + { + id: "activityAfterIdle", + _initialized: false, + _triggerHandler: null, + _idleService: null, + // Optimization - only report idle state after one minute of idle time. + // This represents a minimum idleForMilliseconds of 60000. + _idleThreshold: 60, + _idleSince: null, + _quietSince: null, + _awaitingVisibilityChange: false, + // Fire the trigger 2 seconds after activity resumes to ensure user is + // actively using the browser when it fires. + _triggerDelay: 2000, + _triggerTimeout: null, + // We may get an idle notification immediately after waking from sleep. + // The idle time in such a case will be the amount of time since the last + // user interaction, which was before the computer went to sleep. We want + // to ignore them in that case, so we ignore idle notifications that + // happen within 1 second of the last wake notification. + _wakeDelay: 1000, + _lastWakeTime: null, + _listenedEvents: ["visibilitychange", "TabClose", "TabAttrModified"], + // When the OS goes to sleep or the process is suspended, we want to drop + // the idle time, since the time between sleep and wake is expected to be + // very long (e.g. overnight). Otherwise, this would trigger on the first + // activity after waking/resuming, counting sleep as idle time. This + // basically means each session starts with a fresh idle time. + _observedTopics: [ + "sleep_notification", + "suspend_process_notification", + "wake_notification", + "resume_process_notification", + "mac_app_activate", + ], + + get _isVisible() { + return [...Services.wm.getEnumerator("navigator:browser")].some( + win => !win.closed && !win.document?.hidden + ); + }, + get _soundPlaying() { + return [...Services.wm.getEnumerator("navigator:browser")].some(win => + win.gBrowser?.tabs.some(tab => !tab.closing && tab.soundPlaying) + ); + }, + init(triggerHandler) { + this._triggerHandler = triggerHandler; + // Instantiate this here instead of with a lazy service getter so we can + // stub it in tests (otherwise we'd have to wait up to 6 minutes for an + // idle notification in certain test environments). + if (!this._idleService) { + this._idleService = Cc[ + "@mozilla.org/widget/useridleservice;1" + ].getService(Ci.nsIUserIdleService); + } + if ( + !this._initialized && + !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + this._idleService.addIdleObserver(this, this._idleThreshold); + for (let topic of this._observedTopics) { + Services.obs.addObserver(this, topic); + } + lazy.EveryWindow.registerCallback( + this.id, + win => { + for (let ev of this._listenedEvents) { + win.addEventListener(ev, this); + } + }, + win => { + for (let ev of this._listenedEvents) { + win.removeEventListener(ev, this); + } + } + ); + if (!this._soundPlaying) { + this._quietSince = Date.now(); + } + this._initialized = true; + this.log("Initialized: ", { + idleTime: this._idleService.idleTime, + quietSince: this._quietSince, + }); + } + }, + observe(subject, topic, data) { + if (this._initialized) { + this.log("Heard observer notification: ", { + subject, + topic, + data, + idleTime: this._idleService.idleTime, + idleSince: this._idleSince, + quietSince: this._quietSince, + lastWakeTime: this._lastWakeTime, + }); + switch (topic) { + case "idle": + const now = Date.now(); + // If the idle notification is within 1 second of the last wake + // notification, ignore it. We do this to avoid counting time the + // computer spent asleep as "idle time" + const isImmediatelyAfterWake = + this._lastWakeTime && + now - this._lastWakeTime < this._wakeDelay; + if (!isImmediatelyAfterWake) { + this._idleSince = now - subject.idleTime; + } + break; + case "active": + // Trigger when user returns from being idle. + if (this._isVisible) { + this._onActive(); + this._idleSince = null; + this._lastWakeTime = null; + } else if (this._idleSince) { + // If the window is not visible, we want to wait until it is + // visible before triggering. + this._awaitingVisibilityChange = true; + } + break; + // OS/process notifications + case "wake_notification": + case "resume_process_notification": + case "mac_app_activate": + this._lastWakeTime = Date.now(); + // Fall through to reset idle time. + default: + this._idleSince = null; + } + } + }, + handleEvent(event) { + if (this._initialized) { + switch (event.type) { + case "visibilitychange": + if (this._awaitingVisibilityChange && this._isVisible) { + this._onActive(); + this._idleSince = null; + this._lastWakeTime = null; + this._awaitingVisibilityChange = false; + } + break; + case "TabAttrModified": + // Listen for DOMAudioPlayback* events. + if (!event.detail?.changed?.includes("soundplaying")) { + break; + } + // fall through + case "TabClose": + this.log("Tab sound changed: ", { + event, + idleTime: this._idleService.idleTime, + idleSince: this._idleSince, + quietSince: this._quietSince, + }); + // Maybe update time if a tab closes with sound playing. + if (this._soundPlaying) { + this._quietSince = null; + } else if (!this._quietSince) { + this._quietSince = Date.now(); + } + } + } + }, + _onActive() { + this.log("User is active: ", { + idleTime: this._idleService.idleTime, + idleSince: this._idleSince, + quietSince: this._quietSince, + lastWakeTime: this._lastWakeTime, + }); + if (this._idleSince && this._quietSince) { + const win = Services.wm.getMostRecentBrowserWindow(); + if (win && !isPrivateWindow(win) && !this._triggerTimeout) { + // Time since the most recent user interaction/audio playback, + // reported as the number of milliseconds the user has been idle. + const idleForMilliseconds = + Date.now() - Math.max(this._idleSince, this._quietSince); + this._triggerTimeout = lazy.setTimeout(() => { + this._triggerHandler(win.gBrowser.selectedBrowser, { + id: this.id, + context: { idleForMilliseconds }, + }); + this._triggerTimeout = null; + }, this._triggerDelay); + } + } + }, + uninit() { + if (this._initialized) { + this._idleService.removeIdleObserver(this, this._idleThreshold); + for (let topic of this._observedTopics) { + Services.obs.removeObserver(this, topic); + } + lazy.EveryWindow.unregisterCallback(this.id); + lazy.clearTimeout(this._triggerTimeout); + this._triggerTimeout = null; + this._initialized = false; + this._triggerHandler = null; + this._idleSince = null; + this._quietSince = null; + this._lastWakeTime = null; + this._awaitingVisibilityChange = false; + this.log("Uninitialized"); + } + }, + log(...args) { + lazy.log.debug("Idle trigger :>>", ...args); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + }, + ], + [ + "cookieBannerDetected", + { + id: "cookieBannerDetected", + _initialized: false, + _triggerHandler: null, + + init(triggerHandler) { + this._triggerHandler = triggerHandler; + if (!this._initialized) { + lazy.EveryWindow.registerCallback( + this.id, + win => { + win.addEventListener("cookiebannerdetected", this); + }, + win => { + win.removeEventListener("cookiebannerdetected", this); + } + ); + this._initialized = true; + } + }, + handleEvent(event) { + if (this._initialized) { + const win = event.target || Services.wm.getMostRecentBrowserWindow(); + if (!win) { + return; + } + this._triggerHandler(win.gBrowser.selectedBrowser, { + id: this.id, + }); + } + }, + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + } + }, + }, + ], + [ + "cookieBannerHandled", + { + id: "cookieBannerHandled", + _initialized: false, + _triggerHandler: null, + + init(triggerHandler) { + this._triggerHandler = triggerHandler; + if (!this._initialized) { + lazy.EveryWindow.registerCallback( + this.id, + win => { + win.addEventListener("cookiebannerhandled", this); + }, + win => { + win.removeEventListener("cookiebannerhandled", this); + } + ); + this._initialized = true; + } + }, + handleEvent(event) { + if (this._initialized) { + const browser = + event.detail.windowContext.rootFrameLoader?.ownerElement; + const win = browser?.ownerGlobal; + // We only want to show messages in the active browser window. + if ( + win === Services.wm.getMostRecentBrowserWindow() && + browser === win.gBrowser.selectedBrowser + ) { + this._triggerHandler(browser, { id: this.id }); + } + } + }, + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + } + }, + }, + ], + [ + "pdfJsFeatureCalloutCheck", + { + id: "pdfJsFeatureCalloutCheck", + _initialized: false, + _triggerHandler: null, + _callouts: new WeakMap(), + + init(triggerHandler) { + if (!this._initialized) { + this.onLocationChange = this.onLocationChange.bind(this); + this.onStateChange = this.onLocationChange; + lazy.EveryWindow.registerCallback( + this.id, + win => { + this.onBrowserWindow(win); + win.addEventListener("TabSelect", this); + win.addEventListener("TabClose", this); + win.addEventListener("SSTabRestored", this); + win.gBrowser.addTabsProgressListener(this); + }, + win => { + win.removeEventListener("TabSelect", this); + win.removeEventListener("TabClose", this); + win.removeEventListener("SSTabRestored", this); + win.gBrowser.removeTabsProgressListener(this); + } + ); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + for (let key of ChromeUtils.nondeterministicGetWeakMapKeys( + this._callouts + )) { + const item = this._callouts.get(key); + if (item) { + item.callout.endTour(true); + item.cleanup(); + this._callouts.delete(key); + } + } + } + }, + + async showFeatureCalloutTour(win, browser, panelId, context) { + const result = await this._triggerHandler(browser, { + id: "pdfJsFeatureCalloutCheck", + context, + }); + if (result.message.trigger) { + const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout( + { + win, + browser, + pref: { + name: + result.message.content?.tour_pref_name ?? + "browser.pdfjs.feature-tour", + defaultValue: result.message.content?.tour_pref_default_value, + }, + location: "pdfjs", + theme: { preset: "pdfjs", simulateContent: true }, + cleanup: () => { + this._callouts.delete(win); + }, + }, + result.message + ); + if (callout) { + callout.panelId = panelId; + this._callouts.set(win, callout); + } + } + }, + + onLocationChange(browser) { + const tabbrowser = browser.getTabBrowser(); + if (browser !== tabbrowser.selectedBrowser) { + return; + } + const win = tabbrowser.ownerGlobal; + const tab = tabbrowser.selectedTab; + const existingCallout = this._callouts.get(win); + const isPDFJS = + browser.contentPrincipal.originNoSuffix === "resource://pdf.js"; + if ( + existingCallout && + (existingCallout.panelId !== tab.linkedPanel || !isPDFJS) + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + if (!this._callouts.has(win) && isPDFJS) { + this.showFeatureCalloutTour(win, browser, tab.linkedPanel, { + source: "open", + }); + } + }, + + handleEvent(event) { + const tab = event.target; + const win = tab.ownerGlobal; + const { gBrowser } = win; + if (!gBrowser) { + return; + } + switch (event.type) { + case "SSTabRestored": + if (tab !== gBrowser.selectedTab) { + return; + } + // fall through + case "TabSelect": { + const browser = gBrowser.getBrowserForTab(tab); + const existingCallout = this._callouts.get(win); + const isPDFJS = + browser.contentPrincipal.originNoSuffix === "resource://pdf.js"; + if ( + existingCallout && + (existingCallout.panelId !== tab.linkedPanel || !isPDFJS) + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + if (!this._callouts.has(win) && isPDFJS) { + this.showFeatureCalloutTour(win, browser, tab.linkedPanel, { + source: "open", + }); + } + break; + } + case "TabClose": { + const existingCallout = this._callouts.get(win); + if ( + existingCallout && + existingCallout.panelId === tab.linkedPanel + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + break; + } + } + }, + + onBrowserWindow(win) { + this.onLocationChange(win.gBrowser.selectedBrowser); + }, + }, + ], + [ + "newtabFeatureCalloutCheck", + { + id: "newtabFeatureCalloutCheck", + _initialized: false, + _triggerHandler: null, + _callouts: new WeakMap(), + + init(triggerHandler) { + if (!this._initialized) { + this.onLocationChange = this.onLocationChange.bind(this); + this.onStateChange = this.onLocationChange; + lazy.EveryWindow.registerCallback( + this.id, + win => { + this.onBrowserWindow(win); + win.addEventListener("TabSelect", this); + win.addEventListener("TabClose", this); + win.addEventListener("SSTabRestored", this); + win.gBrowser.addTabsProgressListener(this); + }, + win => { + win.removeEventListener("TabSelect", this); + win.removeEventListener("TabClose", this); + win.removeEventListener("SSTabRestored", this); + win.gBrowser.removeTabsProgressListener(this); + } + ); + this._initialized = true; + } + this._triggerHandler = triggerHandler; + }, + + uninit() { + if (this._initialized) { + lazy.EveryWindow.unregisterCallback(this.id); + this._initialized = false; + this._triggerHandler = null; + for (let key of ChromeUtils.nondeterministicGetWeakMapKeys( + this._callouts + )) { + const item = this._callouts.get(key); + if (item) { + item.callout.endTour(true); + item.cleanup(); + this._callouts.delete(key); + } + } + } + }, + + async showFeatureCalloutTour(win, browser, panelId, context) { + const result = await this._triggerHandler(browser, { + id: "newtabFeatureCalloutCheck", + context, + }); + if (result.message.trigger) { + const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout( + { + win, + browser, + pref: { + name: + result.message.content?.tour_pref_name ?? + "browser.newtab.feature-tour", + defaultValue: result.message.content?.tour_pref_default_value, + }, + location: "newtab", + theme: { preset: "newtab", simulateContent: true }, + cleanup: () => { + this._callouts.delete(win); + }, + }, + result.message + ); + if (callout) { + callout.panelId = panelId; + this._callouts.set(win, callout); + } + } + }, + + onLocationChange(browser) { + const tabbrowser = browser.getTabBrowser(); + if (browser !== tabbrowser.selectedBrowser) { + return; + } + const win = tabbrowser.ownerGlobal; + const tab = tabbrowser.selectedTab; + const existingCallout = this._callouts.get(win); + const isNewtabOrHome = + browser.currentURI.spec.startsWith("about:home") || + browser.currentURI.spec.startsWith("about:newtab"); + if ( + existingCallout && + (existingCallout.panelId !== tab.linkedPanel || !isNewtabOrHome) + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + if (!this._callouts.has(win) && isNewtabOrHome && tab.linkedPanel) { + this.showFeatureCalloutTour(win, browser, tab.linkedPanel, { + source: "open", + }); + } + }, + + handleEvent(event) { + const tab = event.target; + const win = tab.ownerGlobal; + const { gBrowser } = win; + if (!gBrowser) { + return; + } + switch (event.type) { + case "SSTabRestored": + if (tab !== gBrowser.selectedTab) { + return; + } + // fall through + case "TabSelect": { + const browser = gBrowser.getBrowserForTab(tab); + const existingCallout = this._callouts.get(win); + const isNewtabOrHome = + browser.currentURI.spec.startsWith("about:home") || + browser.currentURI.spec.startsWith("about:newtab"); + if ( + existingCallout && + (existingCallout.panelId !== tab.linkedPanel || !isNewtabOrHome) + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + if (!this._callouts.has(win) && isNewtabOrHome) { + this.showFeatureCalloutTour(win, browser, tab.linkedPanel, { + source: "open", + }); + } + break; + } + case "TabClose": { + const existingCallout = this._callouts.get(win); + if ( + existingCallout && + existingCallout.panelId === tab.linkedPanel + ) { + existingCallout.callout.endTour(true); + existingCallout.cleanup(); + } + break; + } + } + }, + + onBrowserWindow(win) { + this.onLocationChange(win.gBrowser.selectedBrowser); + }, + }, + ], +]); diff --git a/browser/components/asrouter/modules/ActorConstants.sys.mjs b/browser/components/asrouter/modules/ActorConstants.sys.mjs new file mode 100644 index 0000000000..4c996552ab --- /dev/null +++ b/browser/components/asrouter/modules/ActorConstants.sys.mjs @@ -0,0 +1,49 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* 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/. */ + +export const MESSAGE_TYPE_LIST = [ + "BLOCK_MESSAGE_BY_ID", + "USER_ACTION", + "IMPRESSION", + "TRIGGER", + // PB is Private Browsing + "PBNEWTAB_MESSAGE_REQUEST", + "DOORHANGER_TELEMETRY", + "TOOLBAR_BADGE_TELEMETRY", + "TOOLBAR_PANEL_TELEMETRY", + "MOMENTS_PAGE_TELEMETRY", + "INFOBAR_TELEMETRY", + "SPOTLIGHT_TELEMETRY", + "TOAST_NOTIFICATION_TELEMETRY", + "AS_ROUTER_TELEMETRY_USER_EVENT", + + // Admin types + "ADMIN_CONNECT_STATE", + "UNBLOCK_MESSAGE_BY_ID", + "UNBLOCK_ALL", + "BLOCK_BUNDLE", + "UNBLOCK_BUNDLE", + "DISABLE_PROVIDER", + "ENABLE_PROVIDER", + "EVALUATE_JEXL_EXPRESSION", + "EXPIRE_QUERY_CACHE", + "FORCE_ATTRIBUTION", + "FORCE_WHATSNEW_PANEL", + "FORCE_PRIVATE_BROWSING_WINDOW", + "CLOSE_WHATSNEW_PANEL", + "OVERRIDE_MESSAGE", + "MODIFY_MESSAGE_JSON", + "RESET_PROVIDER_PREF", + "SET_PROVIDER_USER_PREF", + "RESET_GROUPS_STATE", + "RESET_MESSAGE_STATE", + "RESET_SCREEN_IMPRESSIONS", + "EDIT_STATE", +]; + +export const MESSAGE_TYPE_HASH = MESSAGE_TYPE_LIST.reduce((hash, value) => { + hash[value] = value; + return hash; +}, {}); diff --git a/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs b/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs new file mode 100644 index 0000000000..e0aa49ad49 --- /dev/null +++ b/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs @@ -0,0 +1,820 @@ +/* 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/. */ + +const FACEBOOK_CONTAINER_PARAMS = { + existing_addons: [ + "@contain-facebook", + "{bb1b80be-e6b3-40a1-9b6e-9d4073343f0b}", + "{a50d61ca-d27b-437a-8b52-5fd801a0a88b}", + ], + open_urls: ["www.facebook.com", "facebook.com"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; +const GOOGLE_TRANSLATE_PARAMS = { + existing_addons: [ + "jid1-93WyvpgvxzGATw@jetpack", + "{087ef4e1-4286-4be6-9aa3-8d6c420ee1db}", + "{4170faaa-ee87-4a0e-b57a-1aec49282887}", + "jid1-TMndP6cdKgxLcQ@jetpack", + "s3google@translator", + "{9c63d15c-b4d9-43bd-b223-37f0a1f22e2a}", + "translator@zoli.bod", + "{8cda9ce6-7893-4f47-ac70-a65215cec288}", + "simple-translate@sienori", + "@translatenow", + "{a79fafce-8da6-4685-923f-7ba1015b8748})", + "{8a802b5a-eeab-11e2-a41d-b0096288709b}", + "jid0-fbHwsGfb6kJyq2hj65KnbGte3yT@jetpack", + "storetranslate.plugin@gmail.com", + "jid1-r2tWDbSkq8AZK1@jetpack", + "{b384b75c-c978-4c4d-b3cf-62a82d8f8f12}", + "jid1-f7dnBeTj8ElpWQ@jetpack", + "{dac8a935-4775-4918-9205-5c0600087dc4}", + "gtranslation2@slam.com", + "{e20e0de5-1667-4df4-bd69-705720e37391}", + "{09e26ae9-e9c1-477c-80a6-99934212f2fe}", + "mgxtranslator@magemagix.com", + "gtranslatewins@mozilla.org", + ], + open_urls: ["translate.google.com"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; +const YOUTUBE_ENHANCE_PARAMS = { + existing_addons: [ + "enhancerforyoutube@maximerf.addons.mozilla.org", + "{dc8f61ab-5e98-4027-98ef-bb2ff6060d71}", + "{7b1bf0b6-a1b9-42b0-b75d-252036438bdc}", + "jid0-UVAeBCfd34Kk5usS8A1CBiobvM8@jetpack", + "iridium@particlecore.github.io", + "jid1-ss6kLNCbNz6u0g@jetpack", + "{1cf918d2-f4ea-4b4f-b34e-455283fef19f}", + ], + open_urls: ["www.youtube.com", "youtube.com"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; +const WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS = { + existing_addons: [ + "@wikipediacontextmenusearch", + "{ebf47fc8-01d8-4dba-aa04-2118402f4b20}", + "{5737a280-b359-4e26-95b0-adec5915a854}", + "olivier.debroqueville@gmail.com", + "{3923146e-98cb-472b-9c13-f6849d34d6b8}", + ], + open_urls: ["www.wikipedia.org", "wikipedia.org"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; +const REDDIT_ENHANCEMENT_PARAMS = { + existing_addons: ["jid1-xUfzOsOFlzSOXg@jetpack"], + open_urls: ["www.reddit.com", "reddit.com"], + sumo_path: "extensionrecommendations", + min_frecency: 10000, +}; + +const CFR_MESSAGES = [ + { + id: "FACEBOOK_CONTAINER_3", + template: "cfr_doorhanger", + groups: ["cfr-message-provider"], + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: FACEBOOK_CONTAINER_PARAMS.sumo_path, + }, + addon: { + id: "954390", + title: "Facebook Container", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.6", + users: "299019", + author: "Mozilla", + amo_url: "https://addons.mozilla.org/firefox/addon/facebook-container/", + }, + text: "Stop Facebook from tracking your activity across the web. Use Facebook the way you normally do without annoying ads following you around.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + FACEBOOK_CONTAINER_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + FACEBOOK_CONTAINER_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + FACEBOOK_CONTAINER_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { id: "openURL", params: FACEBOOK_CONTAINER_PARAMS.open_urls }, + }, + { + id: "GOOGLE_TRANSLATE_3", + groups: ["cfr-message-provider"], + template: "cfr_doorhanger", + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: GOOGLE_TRANSLATE_PARAMS.sumo_path, + }, + addon: { + id: "445852", + title: "To Google Translate", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.1", + users: "313474", + author: "Juan Escobar", + amo_url: + "https://addons.mozilla.org/firefox/addon/to-google-translate/", + }, + text: "Instantly translate any webpage text. Simply highlight the text, right-click to open the context menu, and choose a text or aural translation.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + GOOGLE_TRANSLATE_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + GOOGLE_TRANSLATE_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + GOOGLE_TRANSLATE_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { id: "openURL", params: GOOGLE_TRANSLATE_PARAMS.open_urls }, + }, + { + id: "YOUTUBE_ENHANCE_3", + groups: ["cfr-message-provider"], + template: "cfr_doorhanger", + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: YOUTUBE_ENHANCE_PARAMS.sumo_path, + }, + addon: { + id: "700308", + title: "Enhancer for YouTube\u2122", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.8", + users: "357328", + author: "Maxime RF", + amo_url: + "https://addons.mozilla.org/firefox/addon/enhancer-for-youtube/", + }, + text: "Take control of your YouTube experience. Automatically block annoying ads, set playback speed and volume, remove annotations, and more.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + YOUTUBE_ENHANCE_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + YOUTUBE_ENHANCE_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + YOUTUBE_ENHANCE_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { id: "openURL", params: YOUTUBE_ENHANCE_PARAMS.open_urls }, + }, + { + id: "WIKIPEDIA_CONTEXT_MENU_SEARCH_3", + groups: ["cfr-message-provider"], + template: "cfr_doorhanger", + exclude: true, + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.sumo_path, + }, + addon: { + id: "659026", + title: "Wikipedia Context Menu Search", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.9", + users: "3095", + author: "Nick Diedrich", + amo_url: + "https://addons.mozilla.org/firefox/addon/wikipedia-context-menu-search/", + }, + text: "Get to a Wikipedia page fast, from anywhere on the web. Just highlight any webpage text and right-click to open the context menu to start a Wikipedia search.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { + id: "openURL", + params: WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.open_urls, + }, + }, + { + id: "REDDIT_ENHANCEMENT_3", + groups: ["cfr-message-provider"], + template: "cfr_doorhanger", + exclude: true, + content: { + layout: "addon_recommendation", + category: "cfrAddons", + bucket_id: "CFR_M1", + notification_text: { + string_id: "cfr-doorhanger-extension-notification2", + }, + heading_text: { string_id: "cfr-doorhanger-extension-heading" }, + info_icon: { + label: { string_id: "cfr-doorhanger-extension-sumo-link" }, + sumo_path: REDDIT_ENHANCEMENT_PARAMS.sumo_path, + }, + addon: { + id: "387429", + title: "Reddit Enhancement Suite", + icon: "chrome://browser/skin/addons/addon-install-downloading.svg", + rating: "4.6", + users: "258129", + author: "honestbleeps", + amo_url: + "https://addons.mozilla.org/firefox/addon/reddit-enhancement-suite/", + }, + text: "New features include Inline Image Viewer, Never Ending Reddit (never click 'next page' again), Keyboard Navigation, Account Switcher, and User Tagger.", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-extension-ok-button" }, + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: "https://example.com", telemetrySource: "amo" }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfraddons" }, + }, + }, + ], + }, + }, + frequency: { lifetime: 3 }, + targeting: ` + localeLanguageCode == "en" && + (xpinstallEnabled == true) && + (${JSON.stringify( + REDDIT_ENHANCEMENT_PARAMS.existing_addons + )} intersect addonsInfo.addons|keys)|length == 0 && + (${JSON.stringify( + REDDIT_ENHANCEMENT_PARAMS.open_urls + )} intersect topFrecentSites[.frecency >= ${ + REDDIT_ENHANCEMENT_PARAMS.min_frecency + }]|mapToProperty('host'))|length > 0`, + trigger: { id: "openURL", params: REDDIT_ENHANCEMENT_PARAMS.open_urls }, + }, + { + id: "DOH_ROLLOUT_CONFIRMATION", + groups: ["cfr-message-provider"], + targeting: ` + "doh-rollout.enabled"|preferenceValue && + !"doh-rollout.disable-heuristics"|preferenceValue && + !"doh-rollout.skipHeuristicsCheck"|preferenceValue && + !"doh-rollout.doorhanger-decision"|preferenceValue + `, + template: "cfr_doorhanger", + content: { + skip_address_bar_notifier: true, + persistent_doorhanger: true, + anchor_id: "PanelUI-menu-button", + layout: "icon_and_message", + text: { string_id: "cfr-doorhanger-doh-body" }, + icon: "chrome://global/skin/icons/security.svg", + buttons: { + secondary: [ + { + label: { string_id: "cfr-doorhanger-doh-secondary-button" }, + action: { + type: "DISABLE_DOH", + }, + }, + ], + primary: { + label: { string_id: "cfr-doorhanger-doh-primary-button-2" }, + action: { + type: "ACCEPT_DOH", + }, + }, + }, + bucket_id: "DOH_ROLLOUT_CONFIRMATION", + heading_text: { string_id: "cfr-doorhanger-doh-header" }, + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: "Message from Firefox", + category: "cfrFeatures", + }, + trigger: { + id: "openURL", + patterns: ["*://*/*"], + }, + }, + { + id: "SAVE_LOGIN", + groups: ["cfr-message-provider"], + frequency: { + lifetime: 3, + }, + targeting: + "(!type || type == 'save') && isFxAEnabled == true && usesFirefoxSync == false", + template: "cfr_doorhanger", + content: { + layout: "icon_and_message", + text: "Securely store and sync your passwords to all your devices.", + icon: "chrome://browser/content/aboutlogins/icons/intro-illustration.svg", + icon_class: "cfr-doorhanger-large-icon", + buttons: { + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "general-cfrfeatures", + }, + }, + }, + ], + primary: { + label: { + value: "Turn on Sync", + attributes: { accesskey: "T" }, + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "sync", + entrypoint: "cfr-save-login", + }, + }, + }, + }, + bucket_id: "CFR_SAVE_LOGIN", + heading_text: "Never Lose a Password Again", + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: { + string_id: "cfr-doorhanger-feature-notification", + }, + category: "cfrFeatures", + }, + trigger: { + id: "newSavedLogin", + }, + }, + { + id: "UPDATE_LOGIN", + groups: ["cfr-message-provider"], + frequency: { + lifetime: 3, + }, + targeting: + "type == 'update' && isFxAEnabled == true && usesFirefoxSync == false", + template: "cfr_doorhanger", + content: { + layout: "icon_and_message", + text: "Securely store and sync your passwords to all your devices.", + icon: "chrome://browser/content/aboutlogins/icons/intro-illustration.svg", + icon_class: "cfr-doorhanger-large-icon", + buttons: { + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "general-cfrfeatures", + }, + }, + }, + ], + primary: { + label: { + value: "Turn on Sync", + attributes: { accesskey: "T" }, + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "sync", + entrypoint: "cfr-update-login", + }, + }, + }, + }, + bucket_id: "CFR_UPDATE_LOGIN", + heading_text: "Never Lose a Password Again", + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: { + string_id: "cfr-doorhanger-feature-notification", + }, + category: "cfrFeatures", + }, + trigger: { + id: "newSavedLogin", + }, + }, + { + id: "MILESTONE_MESSAGE", + groups: ["cfr-message-provider"], + template: "milestone_message", + content: { + layout: "short_message", + category: "cfrFeatures", + anchor_id: "tracking-protection-icon-container", + skip_address_bar_notifier: true, + bucket_id: "CFR_MILESTONE_MESSAGE", + heading_text: { string_id: "cfr-doorhanger-milestone-heading2" }, + notification_text: "", + text: "", + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-milestone-ok-button" }, + action: { type: "OPEN_PROTECTION_REPORT" }, + event: "PROTECTION", + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-milestone-close-button" }, + action: { type: "CANCEL" }, + event: "DISMISS", + }, + ], + }, + }, + targeting: "pageLoad >= 1", + frequency: { + lifetime: 7, // Length of privacy.contentBlocking.cfr-milestone.milestones pref + }, + trigger: { + id: "contentBlocking", + params: ["ContentBlockingMilestone"], + }, + }, + { + id: "HEARTBEAT_TACTIC_2", + groups: ["cfr-message-provider"], + template: "cfr_urlbar_chiclet", + content: { + layout: "chiclet_open_url", + category: "cfrHeartbeat", + bucket_id: "HEARTBEAT_TACTIC_2", + notification_text: "Improve Firefox", + active_color: "#595e91", + action: { + url: "http://example.com/%VERSION%/", + where: "tabshifted", + }, + }, + targeting: "false", + frequency: { + lifetime: 3, + }, + trigger: { + id: "openURL", + patterns: ["*://*/*"], + }, + }, + { + id: "HOMEPAGE_REMEDIATION_82", + groups: ["cfr-message-provider"], + frequency: { + lifetime: 3, + }, + targeting: + "!homePageSettings.isDefault && homePageSettings.isCustomUrl && homePageSettings.urls[.host == 'google.com']|length > 0 && visitsCount >= 3 && userPrefs.cfrFeatures", + template: "cfr_doorhanger", + content: { + layout: "icon_and_message", + text: "Update your homepage to search Google while also being able to search your Firefox history and bookmarks.", + icon: "chrome://global/skin/icons/search-glass.svg", + buttons: { + secondary: [ + { + label: { + string_id: "cfr-doorhanger-extension-cancel-button", + }, + action: { + type: "CANCEL", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { + category: "general-cfrfeatures", + }, + }, + }, + ], + primary: { + label: { + value: "Activate now", + attributes: { + accesskey: "A", + }, + }, + action: { + type: "CONFIGURE_HOMEPAGE", + data: { + homePage: "default", + newtab: "default", + layout: { + search: true, + topsites: false, + highlights: false, + topstories: false, + }, + }, + }, + }, + }, + bucket_id: "HOMEPAGE_REMEDIATION_82", + heading_text: "A better search experience", + info_icon: { + label: { + string_id: "cfr-doorhanger-extension-sumo-link", + }, + sumo_path: "extensionrecommendations", + }, + notification_text: { + string_id: "cfr-doorhanger-feature-notification", + }, + category: "cfrFeatures", + }, + trigger: { + id: "openURL", + params: ["google.com", "www.google.com"], + }, + }, + { + id: "INFOBAR_ACTION_86", + groups: ["cfr-message-provider"], + targeting: "false", + template: "infobar", + content: { + type: "global", + text: { string_id: "default-browser-notification-message" }, + buttons: [ + { + label: { string_id: "default-browser-notification-button" }, + primary: true, + accessKey: "O", + action: { + type: "SET_DEFAULT_BROWSER", + }, + }, + ], + }, + trigger: { id: "defaultBrowserCheck" }, + }, + { + id: "PREF_OBSERVER_MESSAGE_94", + groups: ["cfr-message-provider"], + targeting: "true", + template: "infobar", + content: { + type: "global", + text: "This is a message triggered when a pref value changes", + buttons: [ + { + label: "OK", + primary: true, + accessKey: "O", + action: { + type: "CANCEL", + }, + }, + ], + }, + trigger: { id: "preferenceObserver", params: ["foo.bar"] }, + }, +]; + +export const CFRMessageProvider = { + getMessages() { + return Promise.resolve(CFR_MESSAGES.filter(msg => !msg.exclude)); + }, +}; diff --git a/browser/components/asrouter/modules/CFRPageActions.sys.mjs b/browser/components/asrouter/modules/CFRPageActions.sys.mjs new file mode 100644 index 0000000000..cf7719d9eb --- /dev/null +++ b/browser/components/asrouter/modules/CFRPageActions.sys.mjs @@ -0,0 +1,1086 @@ +/* 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(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); + } + } + + _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(); + } + }, +}; diff --git a/browser/components/asrouter/modules/FeatureCallout.sys.mjs b/browser/components/asrouter/modules/FeatureCallout.sys.mjs new file mode 100644 index 0000000000..01998662f6 --- /dev/null +++ b/browser/components/asrouter/modules/FeatureCallout.sys.mjs @@ -0,0 +1,2100 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs", + ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs", + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", + PageEventManager: "resource:///modules/asrouter/PageEventManager.sys.mjs", +}); + +const TRANSITION_MS = 500; +const CONTAINER_ID = "feature-callout"; +const CONTENT_BOX_ID = "multi-stage-message-root"; +const BUNDLE_SRC = + "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js"; + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("FeatureCallout"); +}); + +/** + * Feature Callout fetches messages relevant to a given source and displays them + * in the parent page pointing to the element they describe. + */ +export class FeatureCallout { + /** + * @typedef {Object} FeatureCalloutOptions + * @property {Window} win window in which messages will be rendered. + * @property {{name: String, defaultValue?: String}} [pref] optional pref used + * to track progress through a given feature tour. for example: + * { + * name: "browser.pdfjs.feature-tour", + * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }', + * } + * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional) + * @property {String} [location] string to pass as the page when requesting + * messages from ASRouter and sending telemetry. + * @property {String} context either "chrome" or "content". "chrome" is used + * when the callout is shown in the browser chrome, and "content" is used + * when the callout is shown in a content page like Firefox View. + * @property {MozBrowser} [browser] element responsible for the + * feature callout. for content pages, this is the browser element that the + * callout is being shown in. for chrome, this is the active browser. + * @property {Function} [listener] callback to be invoked on various callout + * events to keep the broker informed of the callout's state. + * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets + */ + + /** @param {FeatureCalloutOptions} options */ + constructor({ + win, + pref, + location, + context, + browser, + listener, + theme = {}, + } = {}) { + this.win = win; + this.doc = win.document; + this.browser = browser || this.win.docShell.chromeEventHandler; + this.config = null; + this.loadingConfig = false; + this.message = null; + if (pref?.name) { + this.pref = pref; + } + this._featureTourProgress = null; + this.currentScreen = null; + this.renderObserver = null; + this.savedFocus = null; + this.ready = false; + this._positionListenersRegistered = false; + this._panelConflictListenersRegistered = false; + this.AWSetup = false; + this.location = location; + this.context = context; + this.listener = listener; + this._initTheme(theme); + + this._handlePrefChange = this._handlePrefChange.bind(this); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "cfrFeaturesUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + true + ); + this.setupFeatureTourProgress(); + + // When the window is focused, ensure tour is synced with tours in any other + // instances of the parent page. This does not apply when the Callout is + // shown in the browser chrome. + if (this.context !== "chrome") { + this.win.addEventListener("visibilitychange", this); + } + + this.win.addEventListener("unload", this); + } + + setupFeatureTourProgress() { + if (this.featureTourProgress) { + return; + } + if (this.pref?.name) { + this._handlePrefChange(null, null, this.pref.name); + Services.prefs.addObserver(this.pref.name, this._handlePrefChange); + } + } + + teardownFeatureTourProgress() { + if (this.pref?.name) { + Services.prefs.removeObserver(this.pref.name, this._handlePrefChange); + } + this._featureTourProgress = null; + } + + get featureTourProgress() { + return this._featureTourProgress; + } + + /** + * Get the page event manager and instantiate it if necessary. Only used by + * _attachPageEventListeners, since we don't want to do this unnecessary work + * if a message with page event listeners hasn't loaded. Other consumers + * should use `this._pageEventManager?.property` instead. + */ + get _loadPageEventManager() { + if (!this._pageEventManager) { + this._pageEventManager = new lazy.PageEventManager(this.win); + } + return this._pageEventManager; + } + + _addPositionListeners() { + if (!this._positionListenersRegistered) { + this.win.addEventListener("resize", this); + this._positionListenersRegistered = true; + } + } + + _removePositionListeners() { + if (this._positionListenersRegistered) { + this.win.removeEventListener("resize", this); + this._positionListenersRegistered = false; + } + } + + _addPanelConflictListeners() { + if (!this._panelConflictListenersRegistered) { + this.win.addEventListener("popupshowing", this); + this.win.gURLBar.controller.addQueryListener(this); + this._panelConflictListenersRegistered = true; + } + } + + _removePanelConflictListeners() { + if (this._panelConflictListenersRegistered) { + this.win.removeEventListener("popupshowing", this); + this.win.gURLBar.controller.removeQueryListener(this); + this._panelConflictListenersRegistered = false; + } + } + + /** + * Close the tour when the urlbar is opened in the chrome. Set up by + * gURLBar.controller.addQueryListener in _addPanelConflictListeners. + */ + onViewOpen() { + this.endTour(); + } + + _handlePrefChange(subject, topic, prefName) { + switch (prefName) { + case this.pref?.name: + try { + this._featureTourProgress = JSON.parse( + Services.prefs.getStringPref( + this.pref.name, + this.pref.defaultValue ?? null + ) + ); + } catch (error) { + this._featureTourProgress = null; + } + if (topic === "nsPref:changed") { + this._maybeAdvanceScreens(); + } + break; + } + } + + _maybeAdvanceScreens() { + if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) { + return; + } + + // If we have more than one screen, it means that we're displaying a feature + // tour, and transitions are handled based on the value of a tour progress + // pref. Otherwise, just show the feature callout. If a pref change results + // from an event in a Spotlight message, initialize the feature callout with + // the next message in the tour. + if ( + this.config?.screens.length === 1 || + this.currentScreen === "spotlight" + ) { + this.showFeatureCallout(); + return; + } + + let prefVal = this.featureTourProgress; + // End the tour according to the tour progress pref or if the user disabled + // contextual feature recommendations. + if (prefVal.complete || !this.cfrFeaturesUserPref) { + this.endTour(); + } else if (prefVal.screen !== this.currentScreen?.id) { + // Pref changes only matter to us insofar as they let us advance an + // ongoing tour. If the tour was closed and the pref changed later, e.g. + // by editing the pref directly, we don't want to start up the tour again. + // This is more important in the chrome, which is always open. + if (this.context === "chrome" && !this.currentScreen) { + return; + } + this.ready = false; + this._container?.classList.toggle( + "hidden", + this._container?.localName !== "panel" + ); + this._pageEventManager?.emit({ + type: "touradvance", + target: this._container, + }); + const onFadeOut = async () => { + // If the initial message was deployed from outside by ASRouter as a + // result of a trigger, we can't continue it through _loadConfig, since + // that effectively requests a message with a `featureCalloutCheck` + // trigger. So we need to load up the same message again, merely + // changing the startScreen index. Just check that the next screen and + // the current screen are both within the message's screens array. + let nextMessage = null; + if ( + this.context === "chrome" && + this.message?.trigger.id !== "featureCalloutCheck" + ) { + if ( + this.config?.screens.some(s => s.id === this.currentScreen?.id) && + this.config.screens.some(s => s.id === prefVal.screen) + ) { + nextMessage = this.message; + } + } + this._container?.remove(); + this.renderObserver?.disconnect(); + this._removePositionListeners(); + this._removePanelConflictListeners(); + this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); + if (nextMessage) { + const isMessageUnblocked = await lazy.ASRouter.isUnblockedMessage( + nextMessage + ); + if (!isMessageUnblocked) { + this.endTour(); + return; + } + } + let updated = await this._updateConfig(nextMessage); + if (!updated && !this.currentScreen) { + this.endTour(); + return; + } + let rendering = await this._renderCallout(); + if (!rendering) { + this.endTour(); + } + }; + if (this._container?.localName === "panel") { + this._container.removeEventListener("popuphiding", this); + this._container.addEventListener("popuphidden", onFadeOut, { + once: true, + }); + this._container.hidePopup(true); + } else { + this.win.setTimeout(onFadeOut, TRANSITION_MS); + } + } + } + + handleEvent(event) { + switch (event.type) { + case "focus": { + if (!this._container) { + return; + } + // If focus has fired on the feature callout window itself, or on something + // contained in that window, ignore it, as we can't possibly place the focus + // on it after the callout is closd. + if ( + event.target === this._container || + (Node.isInstance(event.target) && + this._container.contains(event.target)) + ) { + return; + } + // Save this so that if the next focus event is re-entering the popup, + // then we'll put the focus back here where the user left it once we exit + // the feature callout series. + if (this.doc.activeElement) { + let element = this.doc.activeElement; + this.savedFocus = { + element, + focusVisible: element.matches(":focus-visible"), + }; + } else { + this.savedFocus = null; + } + break; + } + + case "keypress": { + if (event.key !== "Escape") { + return; + } + if (!this._container) { + return; + } + let focusedElement = + this.context === "chrome" + ? Services.focus.focusedElement + : this.doc.activeElement; + // If the window has a focused element, let it handle the ESC key instead. + if ( + !focusedElement || + focusedElement === this.doc.body || + (focusedElement === this.browser && this.theme.simulateContent) || + this._container.contains(focusedElement) + ) { + this.win.AWSendEventTelemetry?.({ + event: "DISMISS", + event_context: { + source: `KEY_${event.key}`, + page: this.location, + }, + message_id: this.config?.id.toUpperCase(), + }); + this._dismiss(); + event.preventDefault(); + } + break; + } + + case "visibilitychange": + this._maybeAdvanceScreens(); + break; + + case "resize": + case "toggle": + this.win.requestAnimationFrame(() => this._positionCallout()); + break; + + case "popupshowing": + // If another panel is showing, close the tour. + if ( + event.target !== this._container && + event.target.localName === "panel" && + event.target.id !== "ctrlTab-panel" && + event.target.ownerGlobal === this.win + ) { + this.endTour(); + } + break; + + case "popuphiding": + if (event.target === this._container) { + this.endTour(); + } + break; + + case "unload": + try { + this.teardownFeatureTourProgress(); + } catch (error) {} + break; + + default: + } + } + + async _addCalloutLinkElements() { + for (const path of [ + "browser/newtab/onboarding.ftl", + "browser/spotlight.ftl", + "branding/brand.ftl", + "toolkit/branding/brandings.ftl", + "browser/newtab/asrouter.ftl", + "browser/featureCallout.ftl", + ]) { + this.win.MozXULElement.insertFTLIfNeeded(path); + } + + const addChromeSheet = href => { + try { + this.win.windowUtils.loadSheetUsingURIString( + href, + Ci.nsIDOMWindowUtils.AUTHOR_SHEET + ); + } catch (error) { + // the sheet was probably already loaded. I don't think there's a way to + // check for this via JS, but the method checks and throws if it's + // already loaded, so we can just treat the error as expected. + } + }; + const addStylesheet = href => { + if (this.win.isChromeWindow) { + // for chrome, load the stylesheet using a special method to make sure + // it's loaded synchronously before the first paint & position. + return addChromeSheet(href); + } + if (this.doc.querySelector(`link[href="${href}"]`)) { + return null; + } + const link = this.doc.head.appendChild(this.doc.createElement("link")); + link.rel = "stylesheet"; + link.href = href; + return null; + }; + // Update styling to be compatible with about:welcome bundle + await addStylesheet( + "chrome://browser/content/aboutwelcome/aboutwelcome.css" + ); + } + + /** + * @typedef { + * | "topleft" + * | "topright" + * | "bottomleft" + * | "bottomright" + * | "leftcenter" + * | "rightcenter" + * | "topcenter" + * | "bottomcenter" + * } PopupAttachmentPoint + * + * @see nsMenuPopupFrame + * + * Each attachment point corresponds to an attachment point on the edge of a + * frame. For example, "topleft" corresponds to the frame's top left corner, + * and "rightcenter" corresponds to the center of the right edge of the frame. + */ + + /** + * @typedef {Object} PanelPosition Specifies how the callout panel should be + * positioned relative to the anchor element, by providing which point on + * the callout should be aligned with which point on the anchor element. + * @property {PopupAttachmentPoint} anchor_attachment + * @property {PopupAttachmentPoint} callout_attachment + * @property {Number} [offset_x] Offset in pixels to apply to the callout + * position in the horizontal direction. + * @property {Number} [offset_y] The same in the vertical direction. + * + * This is used when you want the callout to be displayed as a + * element. A panel is critical when the callout is displayed in the browser + * chrome, anchored to an element whose position on the screen is dynamic, + * such as a button. When the anchor moves, the panel will automatically move + * with it. Also, when the elements are aligned so that the callout would + * extend beyond the edge of the screen, the panel will automatically flip + * itself to the other side of the anchor element. This requires specifying + * both an anchor attachment point and a callout attachment point. For + * example, to get the callout to appear under a button, with its arrow on the + * right side of the callout: + * { anchor_attachment: "bottomcenter", callout_attachment: "topright" } + */ + + /** + * @typedef { + * | "top" + * | "bottom" + * | "end" + * | "start" + * | "top-end" + * | "top-start" + * | "top-center-arrow-end" + * | "top-center-arrow-start" + * } HTMLArrowPosition + * + * @see FeatureCallout._positionCallout() + * The position of the callout arrow relative to the callout container. Only + * used for HTML callouts, typically in content pages. If the position + * contains a dash, the value before the dash refers to which edge of the + * feature callout the arrow points from. The value after the dash describes + * where along that edge the arrow sits, with middle as the default. + */ + + /** + * @typedef {Object} PositionOverride CSS properties to override + * the callout's position relative to the anchor element. Although the + * callout is not actually a child of the anchor element, this allows + * absolute positioning of the callout relative to the anchor element. In + * other words, { top: "0px", left: "0px" } will position the callout in the + * top left corner of the anchor element, in the same way these properties + * would position a child element. + * @property {String} [top] + * @property {String} [left] + * @property {String} [right] + * @property {String} [bottom] + */ + + /** + * @typedef {Object} AnchorConfig + * @property {String} selector CSS selector for the anchor node. + * @property {PanelPosition} [panel_position] Used to show the callout in a + * XUL panel. Only works in chrome documents, like the main browser window. + * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in + * an HTML div container. Mutually exclusive with panel_position. + * @property {PositionOverride} [absolute_position] Only used for HTML + * callouts, i.e. when panel_position is not specified. Allows absolute + * positioning of the callout relative to the anchor element. + * @property {Boolean} [hide_arrow] Whether to hide the arrow. + * @property {Boolean} [no_open_on_anchor] Whether to set the [open] style on + * the anchor element when the callout is shown. False to set it, true to + * not set it. This only works for panel callouts. Not all elements have an + * [open] style. Buttons do, for example. It's usually similar to :active. + * @property {Number} [arrow_width] The desired width of the arrow in a number + * of pixels. 33.94113 by default (this corresponds to 24px edges). + */ + + /** + * @typedef {Object} Anchor + * @property {String} selector + * @property {PanelPosition} [panel_position] + * @property {HTMLArrowPosition} [arrow_position] + * @property {PositionOverride} [absolute_position] + * @property {Boolean} [hide_arrow] + * @property {Boolean} [no_open_on_anchor] + * @property {Number} [arrow_width] + * @property {Element} element The anchor node resolved from the selector. + * @property {String} [panel_position_string] The panel_position joined into a + * string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup. + */ + + /** + * Return the first visible anchor element for the current screen. Screens can + * specify multiple anchors in an array, and the first one that is visible + * will be used. If none are visible, return null. + * @returns {Anchor|null} + */ + _getAnchor() { + /** @type {AnchorConfig[]} */ + const anchors = Array.isArray(this.currentScreen?.anchors) + ? this.currentScreen.anchors + : []; + for (let anchor of anchors) { + if (!anchor || typeof anchor !== "object") { + lazy.log.debug( + `In ${this.location}: Invalid anchor config. Expected an object, got: ${anchor}` + ); + continue; + } + const { selector, arrow_position, panel_position } = anchor; + let panel_position_string; + if (panel_position) { + panel_position_string = this._getPanelPositionString(panel_position); + // if the positionString doesn't match the format we expect, don't + // render the callout. + if (!panel_position_string && !arrow_position) { + lazy.log.debug( + `In ${ + this.location + }: Invalid panel_position config. Expected an object with anchor_attachment and callout_attachment properties, got: ${JSON.stringify( + panel_position + )}` + ); + continue; + } + } + if ( + arrow_position && + !this._HTMLArrowPositions.includes(arrow_position) + ) { + lazy.log.debug( + `In ${ + this.location + }: Invalid arrow_position config. Expected one of ${JSON.stringify( + this._HTMLArrowPositions + )}, got: ${arrow_position}` + ); + continue; + } + const element = selector && this.doc.querySelector(selector); + if (!element) { + continue; // Element doesn't exist at all. + } + const isVisible = () => { + if ( + this.context === "chrome" && + typeof this.win.isElementVisible === "function" + ) { + // In chrome windows, we can use the isElementVisible function to + // check that the element has non-zero width and height. If it was + // hidden, it would most likely have zero width and/or height. + if (!this.win.isElementVisible(element)) { + return false; + } + } + // CSS rules like visibility: hidden or display: none. These result in + // element being invisible and unclickable. + const style = this.win.getComputedStyle(element); + return style?.visibility === "visible" && style?.display !== "none"; + }; + if (!isVisible()) { + continue; + } + if ( + this.context === "chrome" && + element.id && + anchor.selector.includes(`#${element.id}`) + ) { + let widget = lazy.CustomizableUI.getWidget(element.id); + if ( + widget && + (this.win.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. + continue; + } + } + return { ...anchor, panel_position_string, element }; + } + return null; + } + + /** @see PopupAttachmentPoint */ + _popupAttachmentPoints = [ + "topleft", + "topright", + "bottomleft", + "bottomright", + "leftcenter", + "rightcenter", + "topcenter", + "bottomcenter", + ]; + + /** + * Return a string representing the position of the panel relative to the + * anchor element. Passed to XULPopupElement::openPopup. The string is of the + * form "anchor_attachment callout_attachment". + * + * @param {PanelPosition} panelPosition + * @returns {String|null} A string like "bottomcenter topright", or null if + * the panelPosition object is invalid. + */ + _getPanelPositionString(panelPosition) { + const { anchor_attachment, callout_attachment } = panelPosition; + if ( + !this._popupAttachmentPoints.includes(anchor_attachment) || + !this._popupAttachmentPoints.includes(callout_attachment) + ) { + return null; + } + let positionString = `${anchor_attachment} ${callout_attachment}`; + return positionString; + } + + /** + * Set/override methods on a panel element. Can be used to override methods on + * the custom element class, or to add additional methods. + * + * @param {MozPanel} panel The panel to set methods for + */ + _setPanelMethods(panel) { + // This method is optionally called by MozPanel::_setSideAttribute, though + // it does not exist on the class. + panel.setArrowPosition = function setArrowPosition(event) { + if (!this.hasAttribute("show-arrow")) { + return; + } + let { alignmentPosition, alignmentOffset, popupAlignment } = event; + let positionParts = alignmentPosition?.match( + /^(before|after|start|end)_(before|after|start|end)$/ + ); + if (!positionParts) { + return; + } + // Hide the arrow if the `flip` behavior has caused the panel to + // offset relative to its anchor, since the arrow would no longer + // point at the true anchor. This differs from an arrow that is + // intentionally hidden by the user in message. + if (this.getAttribute("hide-arrow") !== "permanent") { + if (alignmentOffset) { + this.setAttribute("hide-arrow", "temporary"); + } else { + this.removeAttribute("hide-arrow"); + } + } + let arrowPosition = "top"; + switch (positionParts[1]) { + case "start": + case "end": { + // Inline arrow, i.e. arrow is on one of the left/right edges. + let isRTL = + this.ownerGlobal.getComputedStyle(this).direction === "rtl"; + let isRight = isRTL ^ (positionParts[1] === "start"); + let side = isRight ? "end" : "start"; + arrowPosition = `inline-${side}`; + if (popupAlignment?.includes("center")) { + arrowPosition = `inline-${side}`; + } else if (positionParts[2] === "before") { + arrowPosition = `inline-${side}-top`; + } else if (positionParts[2] === "after") { + arrowPosition = `inline-${side}-bottom`; + } + break; + } + case "before": + case "after": { + // Block arrow, i.e. arrow is on one of the top/bottom edges. + let side = positionParts[1] === "before" ? "bottom" : "top"; + arrowPosition = side; + if (popupAlignment?.includes("center")) { + arrowPosition = side; + } else if (positionParts[2] === "end") { + arrowPosition = `${side}-end`; + } else if (positionParts[2] === "start") { + arrowPosition = `${side}-start`; + } + break; + } + } + this.setAttribute("arrow-position", arrowPosition); + }; + } + + _createContainer() { + const anchor = this._getAnchor(); + // Don't render the callout if none of the anchors is visible. + if (!anchor) { + return false; + } + + const { autohide, padding } = this.currentScreen.content; + const { + panel_position_string, + hide_arrow, + no_open_on_anchor, + arrow_width, + } = anchor; + const needsPanel = "MozXULElement" in this.win && !!panel_position_string; + + if (this._container) { + if (needsPanel ^ (this._container?.localName === "panel")) { + this._container.remove(); + } + } + + if (!this._container?.parentElement) { + if (needsPanel) { + let fragment = this.win.MozXULElement.parseXULToFragment(``); + this._container = fragment.firstElementChild; + this._setPanelMethods(this._container); + } else { + this._container = this.doc.createElement("div"); + this._container?.classList.add("hidden"); + } + this._container.classList.add("featureCallout", "callout-arrow"); + if (hide_arrow) { + this._container.setAttribute("hide-arrow", "permanent"); + } else { + this._container.removeAttribute("hide-arrow"); + } + this._container.id = CONTAINER_ID; + this._container.setAttribute( + "aria-describedby", + `#${CONTAINER_ID} .welcome-text` + ); + this._container.tabIndex = 0; + if (arrow_width) { + this._container.style.setProperty("--arrow-width", `${arrow_width}px`); + } else { + this._container.style.removeProperty("--arrow-width"); + } + if (padding) { + this._container.style.setProperty("--callout-padding", `${padding}px`); + } else { + this._container.style.removeProperty("--callout-padding"); + } + let contentBox = this.doc.createElement("div"); + contentBox.id = CONTENT_BOX_ID; + contentBox.classList.add("onboardingContainer"); + // This value is reported as the "page" in about:welcome telemetry + contentBox.dataset.page = this.location; + this._applyTheme(); + if (needsPanel && this.win.isChromeWindow) { + this.doc.getElementById("mainPopupSet").appendChild(this._container); + } else { + this.doc.body.prepend(this._container); + } + const makeArrow = classPrefix => { + const arrowRotationBox = this.doc.createElement("div"); + arrowRotationBox.classList.add("arrow-box", `${classPrefix}-arrow-box`); + const arrow = this.doc.createElement("div"); + arrow.classList.add("arrow", `${classPrefix}-arrow`); + arrowRotationBox.appendChild(arrow); + return arrowRotationBox; + }; + this._container.appendChild(makeArrow("shadow")); + this._container.appendChild(contentBox); + this._container.appendChild(makeArrow("background")); + } + return this._container; + } + + /** @see HTMLArrowPosition */ + _HTMLArrowPositions = [ + "top", + "bottom", + "end", + "start", + "top-end", + "top-start", + "top-center-arrow-end", + "top-center-arrow-start", + ]; + + /** + * Set callout's position relative to parent element + */ + _positionCallout() { + const container = this._container; + const anchor = this._getAnchor(); + if (!container || !anchor) { + this.endTour(); + return; + } + const parentEl = anchor.element; + const { doc } = this; + const arrowPosition = anchor.arrow_position || "top"; + const arrowWidth = anchor.arrow_width || 33.94113; + const arrowHeight = arrowWidth / 2; + const overlapAmount = 5; + let overlap = overlapAmount - arrowHeight; + // Is the document layout right to left? + const RTL = this.doc.dir === "rtl"; + const customPosition = anchor.absolute_position; + + const getOffset = el => { + const rect = el.getBoundingClientRect(); + return { + left: rect.left + this.win.scrollX, + right: rect.right + this.win.scrollX, + top: rect.top + this.win.scrollY, + bottom: rect.bottom + this.win.scrollY, + }; + }; + + const centerVertically = () => { + let topOffset = + (container.getBoundingClientRect().height - + parentEl.getBoundingClientRect().height) / + 2; + container.style.top = `${getOffset(parentEl).top - topOffset}px`; + }; + + /** + * Horizontally align a top/bottom-positioned callout according to the + * passed position. + * @param {String} position one of... + * - "center": for use with top/bottom. arrow is in the center, and the + * center of the callout aligns with the parent center. + * - "center-arrow-start": for use with center-arrow-top-start. arrow is + * on the start (left) side of the callout, and the callout is aligned + * so that the arrow points to the center of the parent element. + * - "center-arrow-end": for use with center-arrow-top-end. arrow is on + * the end, and the arrow points to the center of the parent. + * - "start": currently unused. align the callout's starting edge with the + * parent's starting edge. + * - "end": currently unused. same as start but for the ending edge. + */ + const alignHorizontally = position => { + switch (position) { + case "center": { + const sideOffset = + (parentEl.getBoundingClientRect().width - + container.getBoundingClientRect().width) / + 2; + const containerSide = RTL + ? doc.documentElement.clientWidth - + getOffset(parentEl).right + + sideOffset + : getOffset(parentEl).left + sideOffset; + container.style[RTL ? "right" : "left"] = `${Math.max( + containerSide, + 0 + )}px`; + break; + } + case "end": + case "start": { + const containerSide = + RTL ^ (position === "end") + ? parentEl.getBoundingClientRect().left + + parentEl.getBoundingClientRect().width - + container.getBoundingClientRect().width + : parentEl.getBoundingClientRect().left; + container.style.left = `${Math.max(containerSide, 0)}px`; + break; + } + case "center-arrow-end": + case "center-arrow-start": { + const parentRect = parentEl.getBoundingClientRect(); + const containerWidth = container.getBoundingClientRect().width; + const containerSide = + RTL ^ position.endsWith("end") + ? parentRect.left + + parentRect.width / 2 + + 12 + + arrowWidth / 2 - + containerWidth + : parentRect.left + parentRect.width / 2 - 12 - arrowWidth / 2; + const maxContainerSide = + doc.documentElement.clientWidth - containerWidth; + container.style.left = `${Math.min( + maxContainerSide, + Math.max(containerSide, 0) + )}px`; + } + } + }; + + // Remember not to use HTML-only properties/methods like offsetHeight. Try + // to use getBoundingClientRect() instead, which is available on XUL + // elements. This is necessary to support feature callout in chrome, which + // is still largely XUL-based. + const positioners = { + // availableSpace should be the space between the edge of the page in the + // assumed direction and the edge of the parent (with the callout being + // intended to fit between those two edges) while needed space should be + // the space necessary to fit the callout container. + top: { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center"); + }, + }, + bottom: { + availableSpace() { + return getOffset(parentEl).top; + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element below the callout + let containerTop = + getOffset(parentEl).top - + container.getBoundingClientRect().height + + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center"); + }, + }, + right: { + availableSpace() { + return getOffset(parentEl).left; + }, + neededSpace: container.getBoundingClientRect().width - overlap, + position() { + // Point to an element to the right of the callout + let containerLeft = + getOffset(parentEl).left - + container.getBoundingClientRect().width + + overlap; + container.style.left = `${Math.max(0, containerLeft)}px`; + if ( + container.getBoundingClientRect().height <= + parentEl.getBoundingClientRect().height + ) { + container.style.top = `${getOffset(parentEl).top}px`; + } else { + centerVertically(); + } + }, + }, + left: { + availableSpace() { + return doc.documentElement.clientWidth - getOffset(parentEl).right; + }, + neededSpace: container.getBoundingClientRect().width - overlap, + position() { + // Point to an element to the left of the callout + let containerLeft = + getOffset(parentEl).left + + parentEl.getBoundingClientRect().width - + overlap; + container.style.left = `${Math.max(0, containerLeft)}px`; + if ( + container.getBoundingClientRect().height <= + parentEl.getBoundingClientRect().height + ) { + container.style.top = `${getOffset(parentEl).top}px`; + } else { + centerVertically(); + } + }, + }, + "top-start": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the start of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("start"); + }, + }, + "top-end": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the end of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("end"); + }, + }, + "top-center-arrow-start": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the start of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center-arrow-start"); + }, + }, + "top-center-arrow-end": { + availableSpace() { + return ( + doc.documentElement.clientHeight - + getOffset(parentEl).top - + parentEl.getBoundingClientRect().height + ); + }, + neededSpace: container.getBoundingClientRect().height - overlap, + position() { + // Point to an element above and at the end of the callout + let containerTop = + getOffset(parentEl).top + + parentEl.getBoundingClientRect().height - + overlap; + container.style.top = `${Math.max(0, containerTop)}px`; + alignHorizontally("center-arrow-end"); + }, + }, + }; + + const clearPosition = () => { + Object.keys(positioners).forEach(position => { + container.style[position] = "unset"; + }); + container.removeAttribute("arrow-position"); + }; + + const setArrowPosition = position => { + let val; + switch (position) { + case "bottom": + val = "bottom"; + break; + case "left": + val = "inline-start"; + break; + case "right": + val = "inline-end"; + break; + case "top-start": + case "top-center-arrow-start": + val = RTL ? "top-end" : "top-start"; + break; + case "top-end": + case "top-center-arrow-end": + val = RTL ? "top-start" : "top-end"; + break; + case "top": + default: + val = "top"; + break; + } + + container.setAttribute("arrow-position", val); + }; + + const addValueToPixelValue = (value, pixelValue) => { + return `${parseFloat(pixelValue) + value}px`; + }; + + const subtractPixelValueFromValue = (pixelValue, value) => { + return `${value - parseFloat(pixelValue)}px`; + }; + + const overridePosition = () => { + // We override _every_ positioner here, because we want to manually set + // all container.style.positions in every positioner's "position" function + // regardless of the actual arrow position + + // Note: We override the position functions with new functions here, but + // they don't actually get executed until the respective position + // functions are called and this function is not executed unless the + // message has a custom position property. + + // We're positioning relative to a parent element's bounds, if that parent + // element exists. + + for (const position in positioners) { + if (!Object.prototype.hasOwnProperty.call(positioners, position)) { + continue; + } + + positioners[position].position = () => { + if (customPosition.top) { + container.style.top = addValueToPixelValue( + parentEl.getBoundingClientRect().top, + customPosition.top + ); + } + + if (customPosition.left) { + const leftPosition = addValueToPixelValue( + parentEl.getBoundingClientRect().left, + customPosition.left + ); + + if (RTL) { + container.style.right = leftPosition; + } else { + container.style.left = leftPosition; + } + } + + if (customPosition.right) { + const rightPosition = subtractPixelValueFromValue( + customPosition.right, + parentEl.getBoundingClientRect().right - + container.getBoundingClientRect().width + ); + + if (RTL) { + container.style.right = rightPosition; + } else { + container.style.left = rightPosition; + } + } + + if (customPosition.bottom) { + container.style.top = subtractPixelValueFromValue( + customPosition.bottom, + parentEl.getBoundingClientRect().bottom - + container.getBoundingClientRect().height + ); + } + }; + } + }; + + const calloutFits = position => { + // Does callout element fit in this position relative + // to the parent element without going off screen? + + // Only consider which edge of the callout the arrow points from, + // not the alignment of the arrow along the edge of the callout + let [edgePosition] = position.split("-"); + return ( + positioners[edgePosition].availableSpace() > + positioners[edgePosition].neededSpace + ); + }; + + const choosePosition = () => { + let position = arrowPosition; + if (!this._HTMLArrowPositions.includes(position)) { + // Configured arrow position is not valid + position = null; + } + if (["start", "end"].includes(position)) { + // position here is referencing the direction that the callout container + // is pointing to, and therefore should be the _opposite_ side of the + // arrow eg. if arrow is at the "end" in LTR layouts, the container is + // pointing at an element to the right of itself, while in RTL layouts + // it is pointing to the left of itself + position = RTL ^ (position === "start") ? "left" : "right"; + } + // If we're overriding the position, we don't need to sort for available space + if (customPosition || (position && calloutFits(position))) { + return position; + } + let sortedPositions = ["top", "bottom", "left", "right"] + .filter(p => p !== position) + .filter(calloutFits) + .sort((a, b) => { + return ( + positioners[b].availableSpace() - positioners[b].neededSpace > + positioners[a].availableSpace() - positioners[a].neededSpace + ); + }); + // If the callout doesn't fit in any position, use the configured one. + // The callout will be adjusted to overlap the parent element so that + // the former doesn't go off screen. + return sortedPositions[0] || position; + }; + + clearPosition(container); + + if (customPosition) { + overridePosition(); + } + + let finalPosition = choosePosition(); + if (finalPosition) { + positioners[finalPosition].position(); + setArrowPosition(finalPosition); + } + + container.classList.remove("hidden"); + } + + /** Expose top level functions expected by the aboutwelcome bundle. */ + _setupWindowFunctions() { + if (this.AWSetup) { + return; + } + + const handleActorMessage = + lazy.AboutWelcomeParent.prototype.onContentMessage.bind({}); + const getActionHandler = name => data => + handleActorMessage(`AWPage:${name}`, data, this.doc); + + const telemetryMessageHandler = getActionHandler("TELEMETRY_EVENT"); + const AWSendEventTelemetry = data => { + if (this.config?.metrics !== "block") { + return telemetryMessageHandler(data); + } + return null; + }; + this._windowFuncs = { + AWGetFeatureConfig: () => this.config, + AWGetSelectedTheme: getActionHandler("GET_SELECTED_THEME"), + // Do not send telemetry if message config sets metrics as 'block'. + AWSendEventTelemetry, + AWSendToDeviceEmailsSupported: getActionHandler( + "SEND_TO_DEVICE_EMAILS_SUPPORTED" + ), + AWSendToParent: (name, data) => getActionHandler(name)(data), + AWFinish: () => this.endTour(), + AWEvaluateScreenTargeting: getActionHandler("EVALUATE_SCREEN_TARGETING"), + }; + for (const [name, func] of Object.entries(this._windowFuncs)) { + this.win[name] = func; + } + + this.AWSetup = true; + } + + /** Clean up the functions defined above. */ + _clearWindowFunctions() { + if (this.AWSetup) { + this.AWSetup = false; + + for (const name of Object.keys(this._windowFuncs)) { + delete this.win[name]; + } + } + } + + /** + * Emit an event to the broker, if one is present. + * @param {String} name + * @param {any} data + */ + _emitEvent(name, data) { + this.listener?.(this.win, name, data); + } + + endTour(skipFadeOut = false) { + // We don't want focus events that happen during teardown to affect + // this.savedFocus + this.win.removeEventListener("focus", this, { + capture: true, + passive: true, + }); + this.win.removeEventListener("keypress", this, { capture: true }); + this._pageEventManager?.emit({ + type: "tourend", + target: this._container, + }); + this._container?.removeEventListener("popuphiding", this); + this._pageEventManager?.clear(); + + // Delete almost everything to get this ready to show a different message. + this.teardownFeatureTourProgress(); + this.pref = null; + this.ready = false; + this.message = null; + this.content = null; + this.currentScreen = null; + // wait for fade out transition + this._container?.classList.toggle( + "hidden", + this._container?.localName !== "panel" + ); + this._clearWindowFunctions(); + const onFadeOut = () => { + this._container?.remove(); + this.renderObserver?.disconnect(); + this._removePositionListeners(); + this._removePanelConflictListeners(); + this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); + // Put the focus back to the last place the user focused outside of the + // featureCallout windows. + if (this.savedFocus) { + this.savedFocus.element.focus({ + focusVisible: this.savedFocus.focusVisible, + }); + } + this.savedFocus = null; + this._emitEvent("end"); + }; + if (this._container?.localName === "panel") { + this._container.addEventListener("popuphidden", onFadeOut, { + once: true, + }); + this._container.hidePopup(!skipFadeOut); + } else if (this._container) { + this.win.setTimeout(onFadeOut, skipFadeOut ? 0 : TRANSITION_MS); + } else { + onFadeOut(); + } + } + + _dismiss() { + let action = this.currentScreen?.content.dismiss_button?.action; + if (action?.type) { + this.win.AWSendToParent("SPECIAL_ACTION", action); + if (!action.dismiss) { + return; + } + } + this.endTour(); + } + + async _addScriptsAndRender() { + const reactSrc = "resource://activity-stream/vendor/react.js"; + const domSrc = "resource://activity-stream/vendor/react-dom.js"; + // Add React script + const getReactReady = () => { + return new Promise(resolve => { + let reactScript = this.doc.createElement("script"); + reactScript.src = reactSrc; + this.doc.head.appendChild(reactScript); + reactScript.addEventListener("load", resolve); + }); + }; + // Add ReactDom script + const getDomReady = () => { + return new Promise(resolve => { + let domScript = this.doc.createElement("script"); + domScript.src = domSrc; + this.doc.head.appendChild(domScript); + domScript.addEventListener("load", resolve); + }); + }; + // Load React, then React Dom + if (!this.doc.querySelector(`[src="${reactSrc}"]`)) { + await getReactReady(); + } + if (!this.doc.querySelector(`[src="${domSrc}"]`)) { + await getDomReady(); + } + // Load the bundle to render the content as configured. + this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove(); + let bundleScript = this.doc.createElement("script"); + bundleScript.src = BUNDLE_SRC; + this.doc.head.appendChild(bundleScript); + } + + _observeRender(container) { + this.renderObserver?.observe(container, { childList: true }); + } + + /** + * Update the internal config with a new message. If a message is not + * provided, try requesting one from ASRouter. The message content is stored + * in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome + * bundle will use that function to get the content when it executes. + * @param {Object} [message] ASRouter message. Omit to request a new one. + * @returns {Promise} true if a message is loaded, false if not. + */ + async _updateConfig(message) { + if (this.loadingConfig) { + return false; + } + + this.message = message || (await this._loadConfig()); + + switch (this.message.template) { + case "feature_callout": + break; + case "spotlight": + // Special handling for spotlight messages, which can be configured as a + // kind of introduction to a feature tour. + this.currentScreen = "spotlight"; + // fall through + default: + return false; + } + + this.config = this.message.content; + + // Set the default start screen. + let newScreen = this.config?.screens?.[this.config?.startScreen || 0]; + // If we have a feature tour in progress, try to set the start screen to + // whichever screen is configured in the feature tour pref. + if ( + this.config.screens && + this.config?.tour_pref_name && + this.config.tour_pref_name === this.pref?.name && + this.featureTourProgress + ) { + const newIndex = this.config.screens.findIndex( + screen => screen.id === this.featureTourProgress.screen + ); + if (newIndex !== -1) { + newScreen = this.config.screens[newIndex]; + if (newScreen?.id !== this.currentScreen?.id) { + // This is how we tell the bundle to render the correct screen. + this.config.startScreen = newIndex; + } + } + } + if (newScreen?.id === this.currentScreen?.id) { + return false; + } + + this.currentScreen = newScreen; + return true; + } + + /** + * Request a message from ASRouter, targeting the `browser` and `page` values + * passed to the constructor. + * @returns {Promise} the requested message. + */ + async _loadConfig() { + this.loadingConfig = true; + await lazy.ASRouter.waitForInitialized; + let result = await lazy.ASRouter.sendTriggerMessage({ + browser: this.browser, + // triggerId and triggerContext + id: "featureCalloutCheck", + context: { source: this.location }, + }); + this.loadingConfig = false; + return result.message; + } + + /** + * Try to render the callout in the current document. + * @returns {Promise} whether the callout was rendered. + */ + async _renderCallout() { + this._setupWindowFunctions(); + await this._addCalloutLinkElements(); + let container = this._createContainer(); + if (container) { + // This results in rendering the Feature Callout + await this._addScriptsAndRender(); + this._observeRender(container.querySelector(`#${CONTENT_BOX_ID}`)); + if (container.localName === "div") { + this._addPositionListeners(); + } + return true; + } + return false; + } + + /** + * For each member of the screen's page_event_listeners array, add a listener. + * @param {Array} listeners + * + * @typedef {Object} PageEventListenerConfig + * @property {PageEventListenerParams} params Event listener parameters + * @property {PageEventListenerAction} action Sent when the event fires + * + * @typedef {Object} PageEventListenerParams See PageEventManager.sys.mjs + * @property {String} type Event type string e.g. `click` + * @property {String} [selectors] Target selector, e.g. `tag.class, #id[attr]` + * @property {PageEventListenerOptions} [options] addEventListener options + * + * @typedef {Object} PageEventListenerOptions + * @property {Boolean} [capture] Use event capturing phase? + * @property {Boolean} [once] Remove listener after first event? + * @property {Boolean} [preventDefault] Prevent default action? + * @property {Number} [interval] Used only for `timeout` and `interval` event + * types. These don't set up real event listeners, but instead invoke the + * action on a timer. + * + * @typedef {Object} PageEventListenerAction Action sent to AboutWelcomeParent + * @property {String} [type] Action type, e.g. `OPEN_URL` + * @property {Object} [data] Extra data, properties depend on action type + * @property {Boolean} [dismiss] Dismiss screen after performing action? + * @property {Boolean} [reposition] Reposition screen after performing action? + */ + _attachPageEventListeners(listeners) { + listeners?.forEach(({ params, action }) => + this._loadPageEventManager[params.options?.once ? "once" : "on"]( + params, + event => { + this._handlePageEventAction(action, event); + if (params.options?.preventDefault) { + event.preventDefault?.(); + } + } + ) + ); + } + + /** + * Perform an action in response to a page event. + * @param {PageEventListenerAction} action + * @param {Event} event Triggering event + */ + _handlePageEventAction(action, event) { + const page = this.location; + const message_id = this.config?.id.toUpperCase(); + const source = + typeof event.target === "string" + ? event.target + : this._getUniqueElementIdentifier(event.target); + if (action.type) { + this.win.AWSendEventTelemetry?.({ + event: "PAGE_EVENT", + event_context: { + action: action.type, + reason: event.type?.toUpperCase(), + source, + page, + }, + message_id, + }); + this.win.AWSendToParent("SPECIAL_ACTION", action); + } + if (action.dismiss) { + this.win.AWSendEventTelemetry?.({ + event: "DISMISS", + event_context: { source: `PAGE_EVENT:${source}`, page }, + message_id, + }); + this._dismiss(); + } + if (action.reposition) { + this.win.requestAnimationFrame(() => this._positionCallout()); + } + } + + /** + * For a given element, calculate a unique string that identifies it. + * @param {Element} target Element to calculate the selector for + * @returns {String} Computed event target selector, e.g. `button#next` + */ + _getUniqueElementIdentifier(target) { + let source; + if (Element.isInstance(target)) { + source = target.localName; + if (target.className) { + source += `.${[...target.classList].join(".")}`; + } + if (target.id) { + source += `#${target.id}`; + } + if (target.attributes.length) { + source += `${[...target.attributes] + .filter(attr => ["is", "role", "open"].includes(attr.name)) + .map(attr => `[${attr.name}="${attr.value}"]`) + .join("")}`; + } + if (this.doc.querySelectorAll(source).length > 1) { + let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`); + if (uniqueAncestor) { + source = `${this._getUniqueElementIdentifier( + uniqueAncestor + )} > ${source}`; + } + } + } + return source; + } + + /** + * Get the element that should be initially focused. Prioritize the primary + * button, then the secondary button, then any additional button, excluding + * pseudo-links and the dismiss button. If no button is found, focus the first + * input element. If no affirmative action is found, focus the first button, + * which is probably the dismiss button. If no button is found, focus the + * container itself. + * @returns {Element|null} The element to focus when the callout is shown. + */ + getInitialFocus() { + if (!this._container) { + return null; + } + return ( + this._container.querySelector( + ".primary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)" + ) || + this._container.querySelector( + ".secondary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)" + ) || + this._container.querySelector( + "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button, .split-button)" + ) || + this._container.querySelector("input:not(:disabled, [hidden])") || + this._container.querySelector( + "button:not(:disabled, [hidden], .text-link, .cta-link)" + ) || + this._container + ); + } + + /** + * Show a feature callout message, either by requesting one from ASRouter or + * by showing a message passed as an argument. + * @param {Object} [message] optional message to show instead of requesting one + * @returns {Promise} true if a message was shown + */ + async showFeatureCallout(message) { + let updated = await this._updateConfig(message); + + if (!updated || !this.config?.screens?.length) { + return !!this.currentScreen; + } + + if (!this.renderObserver) { + this.renderObserver = new this.win.MutationObserver(() => { + // Check if the Feature Callout screen has loaded for the first time + if (!this.ready && this._container.querySelector(".screen")) { + const onRender = () => { + this.ready = true; + this._pageEventManager?.clear(); + this._attachPageEventListeners( + this.currentScreen?.content?.page_event_listeners + ); + this.getInitialFocus()?.focus(); + this.win.addEventListener("keypress", this, { capture: true }); + if (this._container.localName === "div") { + this.win.addEventListener("focus", this, { + capture: true, // get the event before retargeting + passive: true, + }); + this._positionCallout(); + } else { + this._container.classList.remove("hidden"); + } + }; + if ( + this._container.localName === "div" && + this.doc.activeElement && + !this.savedFocus + ) { + let element = this.doc.activeElement; + this.savedFocus = { + element, + focusVisible: element.matches(":focus-visible"), + }; + } + // Once the screen element is added to the DOM, wait for the + // animation frame after next to ensure that _positionCallout + // has access to the rendered screen with the correct height + if (this._container.localName === "div") { + this.win.requestAnimationFrame(() => { + this.win.requestAnimationFrame(onRender); + }); + } else if (this._container.localName === "panel") { + const anchor = this._getAnchor(); + if (!anchor) { + this.endTour(); + return; + } + const position = anchor.panel_position_string; + this._container.addEventListener("popupshown", onRender, { + once: true, + }); + this._container.addEventListener("popuphiding", this); + this._addPanelConflictListeners(); + this._container.openPopup(anchor.element, { position }); + } + } + }); + } + + this._pageEventManager?.clear(); + this.ready = false; + this._container?.remove(); + this.renderObserver?.disconnect(); + + if (!this.cfrFeaturesUserPref) { + this.endTour(); + return false; + } + + let rendering = (await this._renderCallout()) && !!this.currentScreen; + if (!rendering) { + this.endTour(); + } + + if (this.message.template) { + lazy.ASRouter.addImpression(this.message); + } + return rendering; + } + + /** + * @typedef {Object} FeatureCalloutTheme An object with a set of custom color + * schemes and/or a preset key. If both are provided, the preset will be + * applied first, then the custom themes will override the preset values. + * @property {String} [preset] Key of {@link FeatureCallout.themePresets} + * @property {ColorScheme} [light] Custom light scheme + * @property {ColorScheme} [dark] Custom dark scheme + * @property {ColorScheme} [hcm] Custom high contrast scheme + * @property {ColorScheme} [all] Custom scheme that will be applied in all + * cases, but overridden by the other schemes if they are present. This is + * useful if the values are already controlled by the browser theme. + * @property {Boolean} [simulateContent] Set to true if the feature callout + * exists in the browser chrome but is meant to be displayed over the + * content area to appear as if it is part of the page. This will cause the + * styles to use a media query targeting the content instead of the chrome, + * so that if the browser theme doesn't match the content color scheme, the + * callout will correctly follow the content scheme. This is currently used + * for the feature callouts displayed over the PDF.js viewer. + */ + + /** + * @typedef {Object} ColorScheme An object with key-value pairs, with keys + * from {@link FeatureCallout.themePropNames}, mapped to CSS color values + */ + + /** + * Combine the preset and custom themes into a single object and store it. + * @param {FeatureCalloutTheme} theme + */ + _initTheme(theme) { + /** @type {FeatureCalloutTheme} */ + this.theme = Object.assign( + {}, + FeatureCallout.themePresets[theme.preset], + theme + ); + } + + /** + * Apply all the theme colors to the feature callout's root element as CSS + * custom properties in inline styles. These custom properties are consumed by + * _feature-callout-theme.scss, which is bundled with the other styles that + * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}. + */ + _applyTheme() { + if (this._container) { + // This tells the stylesheets to use -moz-content-prefers-color-scheme + // instead of prefers-color-scheme, in order to follow the content color + // scheme instead of the chrome color scheme, in case of a mismatch when + // the feature callout exists in the chrome but is meant to look like it's + // part of the content of a page in a browser tab (like PDF.js). + this._container.classList.toggle( + "simulateContent", + !!this.theme.simulateContent + ); + for (const type of ["light", "dark", "hcm"]) { + const scheme = this.theme[type]; + for (const name of FeatureCallout.themePropNames) { + this._setThemeVariable( + `--fc-${name}-${type}`, + scheme?.[name] || this.theme.all?.[name] + ); + } + } + } + } + + /** + * Set or remove a CSS custom property on the feature callout container + * @param {String} name Name of the CSS custom property + * @param {String|void} [value] Value of the property, or omit to remove it + */ + _setThemeVariable(name, value) { + if (value) { + this._container.style.setProperty(name, value); + } else { + this._container.style.removeProperty(name); + } + } + + /** A list of all the theme properties that can be set */ + static themePropNames = [ + "background", + "color", + "border", + "accent-color", + "button-background", + "button-color", + "button-border", + "button-background-hover", + "button-color-hover", + "button-border-hover", + "button-background-active", + "button-color-active", + "button-border-active", + "primary-button-background", + "primary-button-color", + "primary-button-border", + "primary-button-background-hover", + "primary-button-color-hover", + "primary-button-border-hover", + "primary-button-background-active", + "primary-button-color-active", + "primary-button-border-active", + "link-color", + "link-color-hover", + "link-color-active", + ]; + + /** @type {Object} */ + static themePresets = { + // For themed system pages like New Tab and Firefox View. Themed content + // colors inherit from the user's theme through contentTheme.js. + "themed-content": { + all: { + background: "var(--newtab-background-color-secondary)", + color: "var(--newtab-text-primary-color, var(--in-content-page-color))", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)", + "accent-color": "var(--in-content-primary-button-background)", + "button-background": "color-mix(in srgb, transparent 93%, #000)", + "button-color": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border": "transparent", + "button-background-hover": "color-mix(in srgb, transparent 88%, #000)", + "button-color-hover": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border-hover": "transparent", + "button-background-active": "color-mix(in srgb, transparent 80%, #000)", + "button-color-active": + "var(--newtab-text-primary-color, var(--in-content-page-color))", + "button-border-active": "transparent", + "primary-button-background": + "var(--in-content-primary-button-background)", + "primary-button-color": "var(--in-content-primary-button-text-color)", + "primary-button-border": + "var(--in-content-primary-button-border-color)", + "primary-button-background-hover": + "var(--in-content-primary-button-background-hover)", + "primary-button-color-hover": + "var(--in-content-primary-button-text-color-hover)", + "primary-button-border-hover": + "var(--in-content-primary-button-border-hover)", + "primary-button-background-active": + "var(--in-content-primary-button-background-active)", + "primary-button-color-active": + "var(--in-content-primary-button-text-color-active)", + "primary-button-border-active": + "var(--in-content-primary-button-border-active)", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + dark: { + border: + "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)", + "button-background": "color-mix(in srgb, transparent 80%, #000)", + "button-background-hover": "color-mix(in srgb, transparent 65%, #000)", + "button-background-active": "color-mix(in srgb, transparent 55%, #000)", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "-moz-dialogtext", + "accent-color": "LinkText", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "ButtonText", + "button-color-hover": "ButtonFace", + "button-border-hover": "ButtonText", + "button-background-active": "ButtonText", + "button-color-active": "ButtonFace", + "button-border-active": "ButtonText", + }, + }, + // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css + pdfjs: { + all: { + background: "#FFF", + color: "rgb(12, 12, 13)", + border: "#CFCFD8", + "accent-color": "#0A84FF", + "button-background": "rgb(215, 215, 219)", + "button-color": "rgb(12, 12, 13)", + "button-border": "transparent", + "button-background-hover": "rgb(221, 222, 223)", + "button-color-hover": "rgb(12, 12, 13)", + "button-border-hover": "transparent", + "button-background-active": "rgb(221, 222, 223)", + "button-color-active": "rgb(12, 12, 13)", + "button-border-active": "transparent", + // use default primary button colors in _feature-callout-theme.scss + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + dark: { + background: "#1C1B22", + color: "#F9F9FA", + border: "#3A3944", + "button-background": "rgb(74, 74, 79)", + "button-color": "#F9F9FA", + "button-background-hover": "rgb(102, 102, 103)", + "button-color-hover": "#F9F9FA", + "button-background-active": "rgb(102, 102, 103)", + "button-color-active": "#F9F9FA", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "CanvasText", + "accent-color": "Highlight", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "Highlight", + "button-color-hover": "CanvasText", + "button-border-hover": "Highlight", + "button-background-active": "Highlight", + "button-color-active": "CanvasText", + "button-border-active": "Highlight", + }, + }, + newtab: { + all: { + background: "var(--newtab-background-color-secondary, #FFF)", + color: "var(--newtab-text-primary-color, WindowText)", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary, #FFF) 80%, #000)", + "accent-color": "#0061e0", + "button-background": "color-mix(in srgb, transparent 93%, #000)", + "button-color": "var(--newtab-text-primary-color, WindowText)", + "button-border": "transparent", + "button-background-hover": "color-mix(in srgb, transparent 88%, #000)", + "button-color-hover": "var(--newtab-text-primary-color, WindowText)", + "button-border-hover": "transparent", + "button-background-active": "color-mix(in srgb, transparent 80%, #000)", + "button-color-active": "var(--newtab-text-primary-color, WindowText)", + "button-border-active": "transparent", + // use default primary button colors in _feature-callout-theme.scss + "link-color": "rgb(0, 97, 224)", + "link-color-hover": "rgb(0, 97, 224)", + "link-color-active": "color-mix(in srgb, rgb(0, 97, 224) 80%, #000)", + "link-color-visited": "rgb(0, 97, 224)", + }, + dark: { + "accent-color": "rgb(0, 221, 255)", + background: "var(--newtab-background-color-secondary, #42414D)", + border: + "color-mix(in srgb, var(--newtab-background-color-secondary, #42414D) 80%, #FFF)", + "button-background": "color-mix(in srgb, transparent 80%, #000)", + "button-background-hover": "color-mix(in srgb, transparent 65%, #000)", + "button-background-active": "color-mix(in srgb, transparent 55%, #000)", + "link-color": "rgb(0, 221, 255)", + "link-color-hover": "rgb(0,221,255)", + "link-color-active": "color-mix(in srgb, rgb(0, 221, 255) 60%, #FFF)", + "link-color-visited": "rgb(0, 221, 255)", + }, + hcm: { + background: "-moz-dialog", + color: "-moz-dialogtext", + border: "-moz-dialogtext", + "accent-color": "SelectedItem", + "button-background": "ButtonFace", + "button-color": "ButtonText", + "button-border": "ButtonText", + "button-background-hover": "ButtonText", + "button-color-hover": "ButtonFace", + "button-border-hover": "ButtonText", + "button-background-active": "ButtonText", + "button-color-active": "ButtonFace", + "button-border-active": "ButtonText", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + }, + // These colors are intended to inherit the user's theme properties from the + // main chrome window, for callouts to be anchored to chrome elements. + // Specific schemes aren't necessary since the theme and frontend + // stylesheets handle these variables' values. + chrome: { + all: { + background: "var(--arrowpanel-background)", + color: "var(--arrowpanel-color)", + border: "var(--arrowpanel-border-color)", + "accent-color": "var(--focus-outline-color)", + "button-background": "var(--button-bgcolor)", + "button-color": "var(--button-color)", + "button-border": "transparent", + "button-background-hover": "var(--button-hover-bgcolor)", + "button-color-hover": "var(--button-color)", + "button-border-hover": "transparent", + "button-background-active": "var(--button-active-bgcolor)", + "button-color-active": "var(--button-color)", + "button-border-active": "transparent", + "primary-button-background": "var(--button-primary-bgcolor)", + "primary-button-color": "var(--button-primary-color)", + "primary-button-border": "transparent", + "primary-button-background-hover": + "var(--button-primary-hover-bgcolor)", + "primary-button-color-hover": "var(--button-primary-color)", + "primary-button-border-hover": "transparent", + "primary-button-background-active": + "var(--button-primary-active-bgcolor)", + "primary-button-color-active": "var(--button-primary-color)", + "primary-button-border-active": "transparent", + "link-color": "LinkText", + "link-color-hover": "LinkText", + "link-color-active": "ActiveText", + "link-color-visited": "VisitedText", + }, + }, + }; +} diff --git a/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs b/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs new file mode 100644 index 0000000000..7ede6c9bf8 --- /dev/null +++ b/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs @@ -0,0 +1,215 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs", +}); + +/** + * @typedef {Object} FeatureCalloutOptions + * @property {Window} win window in which messages will be rendered. + * @property {{name: String, defaultValue?: String}} [pref] optional pref used + * to track progress through a given feature tour. for example: + * { + * name: "browser.pdfjs.feature-tour", + * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }', + * } + * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional) + * @property {String} [location] string to pass as the page when requesting + * messages from ASRouter and sending telemetry. + * @property {MozBrowser} [browser] element responsible for the + * feature callout. for content pages, this is the browser element that the + * callout is being shown in. for chrome, this is the active browser. + * @property {Function} [cleanup] callback to be invoked when the callout is + * removed or the window is unloaded. + * @property {FeatureCalloutTheme} [theme] optional dynamic color theme. + */ + +/** @typedef {import("resource:///modules/asrouter/FeatureCallout.sys.mjs").FeatureCalloutTheme} FeatureCalloutTheme */ + +/** + * @typedef {Object} FeatureCalloutItem + * @property {lazy.FeatureCallout} callout instance of FeatureCallout. + * @property {Function} [cleanup] cleanup callback. + * @property {Boolean} showing whether the callout is currently showing. + */ + +export class _FeatureCalloutBroker { + /** + * Make a new FeatureCallout instance and store it in the callout map. Also + * add an unload listener to the window to clean up the callout when the + * window is unloaded. + * @param {FeatureCalloutOptions} config + */ + makeFeatureCallout(config) { + const { win, pref, location, browser, theme } = config; + // Use an AbortController to clean up the unload listener in case the + // callout is cleaned up before the window is unloaded. + const controller = new AbortController(); + const cleanup = () => { + this.#calloutMap.delete(win); + controller.abort(); + config.cleanup?.(); + }; + this.#calloutMap.set(win, { + callout: new lazy.FeatureCallout({ + win, + pref, + location, + context: "chrome", + browser, + listener: this.handleFeatureCalloutCallback.bind(this), + theme, + }), + cleanup, + showing: false, + }); + win.addEventListener("unload", cleanup, { signal: controller.signal }); + } + + /** + * Show a feature callout message. For use by ASRouter, to be invoked when a + * trigger has matched to a feature_callout message. + * @param {MozBrowser} browser element associated with the trigger. + * @param {Object} message feature_callout message from ASRouter. + * @see {@link FeatureCalloutMessages.sys.mjs} + * @returns {Promise} whether the callout was shown. + */ + async showFeatureCallout(browser, message) { + // Only show one callout at a time, across all windows. + if (this.isCalloutShowing) { + return false; + } + const win = browser.ownerGlobal; + // Avoid showing feature callouts if a dialog or panel is showing. + if ( + win.gDialogBox?.dialog || + [...win.document.querySelectorAll("panel")].some(p => p.state === "open") + ) { + return false; + } + const currentCallout = this.#calloutMap.get(win); + // If a custom callout was previously showing, but is no longer showing, + // tear down the FeatureCallout instance. We avoid tearing them down when + // they stop showing because they may be shown again, and we want to avoid + // the overhead of creating a new FeatureCallout instance. But the custom + // callout instance may be incompatible with the new ASRouter message, so + // we tear it down and create a new one. + if (currentCallout && currentCallout.callout.location !== "chrome") { + currentCallout.cleanup(); + } + let item = this.#calloutMap.get(win); + let callout = item?.callout; + if (item) { + // If a callout previously showed in this instance, but the new message's + // tour_pref_name is different, update the old instance's tour properties. + callout.teardownFeatureTourProgress(); + if (message.content.tour_pref_name) { + callout.pref = { + name: message.content.tour_pref_name, + defaultValue: message.content.tour_pref_default_value, + }; + callout.setupFeatureTourProgress(); + } else { + callout.pref = null; + } + } else { + const options = { + win, + location: "chrome", + browser, + theme: { preset: "chrome" }, + }; + if (message.content.tour_pref_name) { + options.pref = { + name: message.content.tour_pref_name, + defaultValue: message.content.tour_pref_default_value, + }; + } + this.makeFeatureCallout(options); + item = this.#calloutMap.get(win); + callout = item.callout; + } + // Set this to true for now so that we can't be interrupted by another + // invocation. We'll set it to false below if it ended up not showing. + item.showing = true; + item.showing = await callout.showFeatureCallout(message).catch(() => { + item.cleanup(); + return false; + }); + return item.showing; + } + + /** + * Make a new FeatureCallout instance specific to a special location, tearing + * down the existing generic FeatureCallout if it exists, and (if no message + * is passed) requesting a feature callout message to show. Does nothing if a + * callout is already in progress. This allows the PDF.js feature tour, which + * simulates content, to be shown in the chrome window without interfering + * with chrome feature callouts. + * @param {FeatureCalloutOptions} config + * @param {Object} message feature_callout message from ASRouter. + * @see {@link FeatureCalloutMessages.sys.mjs} + * @returns {FeatureCalloutItem|null} the callout item, if one was created. + */ + showCustomFeatureCallout(config, message) { + if (this.isCalloutShowing) { + return null; + } + const { win, pref, location } = config; + const currentCallout = this.#calloutMap.get(win); + if (currentCallout && currentCallout.location !== location) { + currentCallout.cleanup(); + } + let item = this.#calloutMap.get(win); + let callout = item?.callout; + if (item) { + callout.teardownFeatureTourProgress(); + callout.pref = pref; + if (pref) { + callout.setupFeatureTourProgress(); + } + } else { + this.makeFeatureCallout(config); + item = this.#calloutMap.get(win); + callout = item.callout; + } + item.showing = true; + // In this case, callers are not necessarily async, so we don't await. + callout + .showFeatureCallout(message) + .then(showing => { + item.showing = showing; + }) + .catch(() => { + item.cleanup(); + item.showing = false; + }); + /** @type {FeatureCalloutItem} */ + return item; + } + + handleFeatureCalloutCallback(win, event, data) { + switch (event) { + case "end": + const item = this.#calloutMap.get(win); + if (item) { + item.showing = false; + } + break; + } + } + + /** @returns {Boolean} whether a callout is currently showing. */ + get isCalloutShowing() { + return [...this.#calloutMap.values()].some(({ showing }) => showing); + } + + /** @type {Map} */ + #calloutMap = new Map(); +} + +export const FeatureCalloutBroker = new _FeatureCalloutBroker(); diff --git a/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs b/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs new file mode 100644 index 0000000000..38c9a8d848 --- /dev/null +++ b/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs @@ -0,0 +1,1299 @@ +/* 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/. */ + +// Eventually, make this a messaging system +// provider instead of adding these message +// into OnboardingMessageProvider.sys.mjs +const FIREFOX_VIEW_PREF = "browser.firefox-view.feature-tour"; +const PDFJS_PREF = "browser.pdfjs.feature-tour"; +// Empty screens are included as placeholders to ensure step +// indicator shows the correct number of total steps in the tour +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; + +// Generate a JEXL targeting string based on the `complete` property being true +// in a given Feature Callout tour progress preference value (which is JSON). +const matchIncompleteTargeting = (prefName, defaultValue = false) => { + // regExpMatch() is a JEXL filter expression. Here we check if 'complete' + // exists in the pref's value, and returns true if the tour is incomplete. + const prefVal = `'${prefName}' | preferenceValue`; + // prefVal might be null if the preference doesn't exist. in this case, don't + // try to pipe into regExpMatch. + const completeMatch = `${prefVal} | regExpMatch('(?<=complete":)(.*)(?=})')`; + return `((${prefVal}) ? ((${completeMatch}) ? (${completeMatch}[1] != "true") : ${String( + defaultValue + )}) : ${String(defaultValue)})`; +}; + +// Generate a JEXL targeting string based on the current screen id found in a +// given Feature Callout tour progress preference. +const matchCurrentScreenTargeting = (prefName, screenIdRegEx = ".*") => { + // regExpMatch() is a JEXL filter expression. Here we check if 'screen' exists + // in the pref's value, and if it matches the screenIdRegEx. Returns + // null otherwise. + const prefVal = `'${prefName}' | preferenceValue`; + const screenMatch = `${prefVal} | regExpMatch('(?<=screen"\s*:)\s*"(${screenIdRegEx})(?="\s*,)')`; + const screenValMatches = `(${screenMatch}) ? !!(${screenMatch}[1]) : false`; + return `(${screenValMatches})`; +}; + +/** + * add24HourImpressionJEXLTargeting - + * Creates a "hasn't been viewed in > 24 hours" + * JEXL string and adds it to each message specified + * + * @param {array} messageIds - IDs of messages that the targeting string will be added to + * @param {string} prefix - The prefix of messageIDs that will used to create the JEXL string + * @param {array} messages - The array of messages that will be edited + * @returns {array} - The array of messages with the appropriate targeting strings edited + */ +function add24HourImpressionJEXLTargeting( + messageIds, + prefix, + uneditedMessages +) { + let noImpressionsIn24HoursString = uneditedMessages + .filter(message => message.id.startsWith(prefix)) + .map( + message => + // If the last impression is null or if epoch time + // of the impression is < current time - 24hours worth of MS + `(messageImpressions.${message.id}[messageImpressions.${ + message.id + } | length - 1] == null || messageImpressions.${ + message.id + }[messageImpressions.${message.id} | length - 1] < ${ + Date.now() - ONE_DAY_IN_MS + })` + ) + .join(" && "); + + // We're appending the string here instead of using + // template strings to avoid a recursion error from + // using the 'messages' variable within itself + return uneditedMessages.map(message => { + if (messageIds.includes(message.id)) { + message.targeting += `&& ${noImpressionsIn24HoursString}`; + } + + return message; + }); +} + +// Exporting the about:firefoxview messages as a method here +// acts as a safety guard against mutations of the original objects +const MESSAGES = () => { + let messages = [ + { + id: "FIREFOX_VIEW_SPOTLIGHT", + template: "spotlight", + content: { + id: "FIREFOX_VIEW_PROMO", + template: "multistage", + modal: "tab", + tour_pref_name: FIREFOX_VIEW_PREF, + screens: [ + { + id: "DEFAULT_MODAL_UI", + content: { + title: { + fontSize: "32px", + fontWeight: 400, + string_id: "firefoxview-spotlight-promo-title", + }, + subtitle: { + fontSize: "15px", + fontWeight: 400, + marginBlock: "10px", + marginInline: "40px", + string_id: "firefoxview-spotlight-promo-subtitle", + }, + logo: { height: "48px" }, + primary_button: { + label: { + string_id: "firefoxview-spotlight-promo-primarybutton", + }, + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_1", + complete: false, + }), + }, + }, + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "firefoxview-spotlight-promo-secondarybutton", + }, + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + navigate: true, + }, + }, + }, + }, + ], + }, + priority: 3, + trigger: { + id: "featureCalloutCheck", + }, + frequency: { + // Add the highest possible cap to ensure impressions are recorded while allowing the Spotlight to sync across windows/tabs with Firefox View open + lifetime: 100, + }, + targeting: `!inMr2022Holdback && source == "about:firefoxview" && + !'browser.newtabpage.activity-stream.asrouter.providers.cfr'|preferenceIsUserSet && + 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue && + ${matchCurrentScreenTargeting( + FIREFOX_VIEW_PREF, + "FIREFOX_VIEW_SPOTLIGHT" + )} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`, + }, + { + id: "FIREFOX_VIEW_FEATURE_TOUR", + template: "feature_callout", + content: { + id: "FIREFOX_VIEW_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: FIREFOX_VIEW_PREF, + screens: [ + { + id: "FEATURE_CALLOUT_1", + anchors: [ + { + selector: "#tab-pickup-container", + arrow_position: "top", + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-firefox-view-tab-pickup-title", + }, + subtitle: { + string_id: "callout-firefox-view-tab-pickup-subtitle", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + height: "128px", + }, + primary_button: { + label: { + string_id: "callout-primary-advance-button-label", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_2", + complete: false, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + page_event_listeners: [ + { + params: { + type: "toggle", + selectors: "#tab-pickup-container", + }, + action: { reposition: true }, + }, + ], + }, + }, + { + id: "FEATURE_CALLOUT_2", + anchors: [ + { + selector: "#recently-closed-tabs-container", + arrow_position: "bottom", + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-firefox-view-recently-closed-title", + }, + subtitle: { + string_id: "callout-firefox-view-recently-closed-subtitle", + }, + primary_button: { + label: { + string_id: "callout-primary-complete-button-label", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: FIREFOX_VIEW_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + page_event_listeners: [ + { + params: { + type: "toggle", + selectors: "#recently-closed-tabs-container", + }, + action: { reposition: true }, + }, + ], + }, + }, + ], + }, + priority: 3, + targeting: `!inMr2022Holdback && source == "about:firefoxview" && ${matchCurrentScreenTargeting( + FIREFOX_VIEW_PREF, + "FEATURE_CALLOUT_[0-9]" + )} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`, + trigger: { id: "featureCalloutCheck" }, + }, + { + id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER", + template: "feature_callout", + content: { + id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER", + anchors: [ + { + selector: "#tab-pickup-container", + arrow_position: "top", + }, + ], + content: { + position: "callout", + title: { + string_id: + "continuous-onboarding-firefox-view-tab-pickup-title", + }, + subtitle: { + string_id: + "continuous-onboarding-firefox-view-tab-pickup-subtitle", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + height: "128px", + }, + primary_button: { + label: { + string_id: "mr1-onboarding-get-started-primary-button-label", + }, + style: "secondary", + action: { + type: "CLICK_ELEMENT", + navigate: true, + data: { + selector: + "#tab-pickup-container button.primary:not(#error-state-button)", + }, + }, + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "toggle", + selectors: "#tab-pickup-container", + }, + action: { reposition: true }, + }, + ], + }, + }, + ], + }, + priority: 2, + targeting: `!inMr2022Holdback && source == "about:firefoxview" && "browser.firefox-view.view-count" | preferenceValue > 2 + && (("identity.fxaccounts.enabled" | preferenceValue == false) || !(("services.sync.engine.tabs" | preferenceValue == true) && ("services.sync.username" | preferenceValue))) && (!messageImpressions.FIREFOX_VIEW_SPOTLIGHT[messageImpressions.FIREFOX_VIEW_SPOTLIGHT | length - 1] || messageImpressions.FIREFOX_VIEW_SPOTLIGHT[messageImpressions.FIREFOX_VIEW_SPOTLIGHT | length - 1] < currentDate|date - ${ONE_DAY_IN_MS})`, + frequency: { + lifetime: 1, + }, + trigger: { id: "featureCalloutCheck" }, + }, + { + id: "PDFJS_FEATURE_TOUR_A", + template: "feature_callout", + content: { + id: "PDFJS_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: PDFJS_PREF, + screens: [ + { + id: "FEATURE_CALLOUT_1_A", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "43px", right: "51px" }, + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-pdfjs-edit-title", + }, + subtitle: { + string_id: "callout-pdfjs-edit-body-a", + }, + primary_button: { + label: { + string_id: "callout-pdfjs-edit-button", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_2_A", + complete: false, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + }, + }, + { + id: "FEATURE_CALLOUT_2_A", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "43px", right: "23px" }, + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-pdfjs-draw-title", + }, + subtitle: { + string_id: "callout-pdfjs-draw-body-a", + }, + primary_button: { + label: { + string_id: "callout-pdfjs-draw-button", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + }, + }, + ], + }, + priority: 1, + targeting: `source == "open" && ${matchCurrentScreenTargeting( + PDFJS_PREF, + "FEATURE_CALLOUT_[0-9]_A" + )} && ${matchIncompleteTargeting(PDFJS_PREF)}`, + trigger: { id: "pdfJsFeatureCalloutCheck" }, + }, + { + id: "PDFJS_FEATURE_TOUR_B", + template: "feature_callout", + content: { + id: "PDFJS_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + tour_pref_name: PDFJS_PREF, + screens: [ + { + id: "FEATURE_CALLOUT_1_B", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "43px", right: "51px" }, + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-pdfjs-edit-title", + }, + subtitle: { + string_id: "callout-pdfjs-edit-body-b", + }, + primary_button: { + label: { + string_id: "callout-pdfjs-edit-button", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "FEATURE_CALLOUT_2_B", + complete: false, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + }, + }, + { + id: "FEATURE_CALLOUT_2_B", + anchors: [ + { + selector: "hbox#browser", + arrow_position: "top-end", + absolute_position: { top: "43px", right: "23px" }, + }, + ], + content: { + position: "callout", + title: { + string_id: "callout-pdfjs-draw-title", + }, + subtitle: { + string_id: "callout-pdfjs-draw-body-b", + }, + primary_button: { + label: { + string_id: "callout-pdfjs-draw-button", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + dismiss_button: { + action: { + type: "SET_PREF", + data: { + pref: { + name: PDFJS_PREF, + value: JSON.stringify({ + screen: "", + complete: true, + }), + }, + }, + }, + }, + }, + }, + ], + }, + priority: 1, + targeting: `source == "open" && ${matchCurrentScreenTargeting( + PDFJS_PREF, + "FEATURE_CALLOUT_[0-9]_B" + )} && ${matchIncompleteTargeting(PDFJS_PREF)}`, + trigger: { id: "pdfJsFeatureCalloutCheck" }, + }, + { + // "Callout 1" in the Fakespot Figma spec + id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + title_logo: { + imageURL: + "chrome://browser/content/shopping/assets/shopping.svg", + alignment: "top", + }, + title: { + string_id: "shopping-callout-closed-opted-in-subtitle", + marginInline: "3px 40px", + fontWeight: "inherit", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "24px 0", + marginInline: "0 24px", + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is not enabled; User is opted in; First time closing sidebar; Has not seen either on-closed callout before; Has not opted out of CFRs. + targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT|length && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT|length`, + trigger: { id: "shoppingProductPageWithSidebarClosed" }, + frequency: { lifetime: 1 }, + }, + { + // "Callout 3" in the Fakespot Figma spec + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + title_logo: { + imageURL: + "chrome://browser/content/shopping/assets/shopping.svg", + }, + title: { + string_id: "shopping-callout-closed-not-opted-in-title", + marginInline: "3px 40px", + }, + subtitle: { + string_id: "shopping-callout-closed-not-opted-in-subtitle", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "24px 0", + marginInline: "0 24px", + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is not enabled; User is not opted in; First time closing sidebar; Has not seen either on-closed callout before; Has not opted out of CFRs. + targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue != 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT|length && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT|length`, + trigger: { id: "shoppingProductPageWithSidebarClosed" }, + frequency: { lifetime: 1 }, + }, + { + // "callout 2" in the Fakespot Figma spec + id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + title: { string_id: "shopping-callout-pdp-opted-in-title" }, + subtitle: { string_id: "shopping-callout-pdp-opted-in-subtitle" }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/ratingLight.avif", + darkModeImageURL: + "chrome://browser/content/shopping/assets/ratingDark.avif", + height: "216px", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "24px 0", + marginInline: "0 24px", + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is not enabled; User is opted in; Has not opted out of CFRs; Has seen either on-closed callout before, but not within the last 24hrs or in this session. + targeting: `!isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && ((currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT[messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT | length - 1] | date) / 3600000 > 24 || (currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT[messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT | length - 1] | date) / 3600000 > 24)`, + trigger: { id: "shoppingProductPageWithSidebarClosed" }, + frequency: { lifetime: 1 }, + }, + { + // "Callout 1" in the Fakespot Figma spec, but + // targeting not opted-in users only for rediscoverability experiment 2. + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + width: "401px", + title: { + string_id: "shopping-callout-closed-not-opted-in-revised-title", + }, + subtitle: { + string_id: + "shopping-callout-closed-not-opted-in-revised-subtitle", + letterSpacing: "0", + }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg", + height: "214px", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "28px 0", + marginInline: "0 28px", + }, + primary_button: { + label: { + string_id: + "shopping-callout-closed-not-opted-in-revised-button", + marginBlock: "0 -8px", + }, + style: "secondary", + action: { + dismiss: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is enabled; User is not opted in; First time closing sidebar; Has not opted out of CFRs. + targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue != 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`, + trigger: { id: "shoppingProductPageWithSidebarClosed" }, + frequency: { lifetime: 1 }, + skip_in_tests: + "not tested in automation and might pop up unexpectedly during review checker tests", + }, + { + // "Callout 3" in the Fakespot Figma spec, but + // displayed if auto-open version of "callout 1" was seen already and 24 hours have passed. + id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + width: "401px", + title: { + string_id: "shopping-callout-not-opted-in-reminder-title", + fontSize: "20px", + letterSpacing: "0", + }, + subtitle: { + string_id: "shopping-callout-not-opted-in-reminder-subtitle", + letterSpacing: "0", + }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/reviewsVisualCallout.svg", + alt: { + string_id: "shopping-callout-not-opted-in-reminder-img-alt", + }, + height: "214px", + }, + dismiss_button: { + action: { + type: "MULTI_ACTION", + collectSelect: true, + data: { + actions: [], + }, + dismiss: true, + }, + size: "small", + marginBlock: "28px 0", + marginInline: "0 28px", + }, + primary_button: { + label: { + string_id: + "shopping-callout-not-opted-in-reminder-close-button", + marginBlock: "0 -8px", + }, + style: "secondary", + action: { + type: "MULTI_ACTION", + collectSelect: true, + data: { + actions: [], + }, + dismiss: true, + }, + }, + secondary_button: { + label: { + string_id: + "shopping-callout-not-opted-in-reminder-open-button", + marginBlock: "0 -8px", + }, + style: "primary", + action: { + type: "MULTI_ACTION", + collectSelect: true, + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.shopping.experience2023.active", + value: true, + }, + }, + }, + ], + }, + dismiss: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + tiles: { + type: "multiselect", + style: { + flexDirection: "column", + alignItems: "flex-start", + }, + data: [ + { + id: "checkbox-dont-show-again", + type: "checkbox", + defaultValue: false, + style: { + alignItems: "center", + }, + label: { + string_id: + "shopping-callout-not-opted-in-reminder-ignore-checkbox", + }, + icon: { + style: { + width: "16px", + height: "16px", + marginInline: "0 8px", + }, + }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "messaging-system-action.shopping-callouts-1-block", + value: true, + }, + }, + }, + }, + ], + }, + }, + }, + ], + }, + priority: 2, + // Auto-open feature flag is enabled; User is not opted in; Has not opted out of CFRs; Has seen callout 1 before, but not within the last 5 days. + targeting: + "!isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 0 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !'messaging-system-action.shopping-callouts-1-block' | preferenceValue && (currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN[messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN | length - 1] | date) / 3600000 > 24", + trigger: { + id: "shoppingProductPageWithSidebarClosed", + }, + frequency: { + custom: [ + { + cap: 1, + period: 432000000, + }, + ], + lifetime: 3, + }, + skip_in_tests: + "not tested in automation and might pop up unexpectedly during review checker tests", + }, + { + // "Callout 4" in the Fakespot Figma spec, for rediscoverability experiment 2. + id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + width: "401px", + title: { + string_id: "shopping-callout-disabled-auto-open-title", + }, + subtitle: { + string_id: "shopping-callout-disabled-auto-open-subtitle", + letterSpacing: "0", + }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg", + height: "214px", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "28px 0", + marginInline: "0 28px", + }, + primary_button: { + label: { + string_id: "shopping-callout-disabled-auto-open-button", + marginBlock: "0 -8px", + }, + style: "secondary", + action: { + dismiss: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is enabled; User disabled auto-open behavior; User is opted in; Has not opted out of CFRs. + targeting: `'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.autoOpen.userEnabled' | preferenceValue == false && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`, + trigger: { + id: "preferenceObserver", + params: ["browser.shopping.experience2023.autoOpen.userEnabled"], + }, + frequency: { lifetime: 1 }, + skip_in_tests: + "not tested in automation and might pop up unexpectedly during review checker tests", + }, + { + // "Callout 5" in the Fakespot Figma spec, for rediscoverability experiment 2. + id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN", + template: "feature_callout", + content: { + id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN", + anchors: [ + { + selector: "#shopping-sidebar-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + no_open_on_anchor: true, + }, + ], + content: { + position: "callout", + width: "401px", + title: { + string_id: "shopping-callout-opted-out-title", + }, + subtitle: { + string_id: "shopping-callout-opted-out-subtitle", + letterSpacing: "0", + }, + logo: { + imageURL: + "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg", + height: "214px", + }, + dismiss_button: { + action: { dismiss: true }, + size: "small", + marginBlock: "28px 0", + marginInline: "0 28px", + }, + primary_button: { + label: { + string_id: "shopping-callout-opted-out-button", + marginBlock: "0 -8px", + }, + style: "secondary", + action: { + dismiss: true, + }, + }, + page_event_listeners: [ + { + params: { + type: "click", + selectors: "#shopping-sidebar-button", + }, + action: { dismiss: true }, + }, + ], + }, + }, + ], + }, + priority: 1, + // Auto-open feature flag is enabled; User has opted out; Has not opted out of CFRs. + targeting: `'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 2 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`, + trigger: { + id: "preferenceObserver", + params: ["browser.shopping.experience2023.optedIn"], + }, + frequency: { lifetime: 1 }, + skip_in_tests: + "not tested in automation and might pop up unexpectedly during review checker tests", + }, + + // cookie banner reduction onboarding + { + id: "CFR_COOKIEBANNER", + groups: ["cfr"], + template: "feature_callout", + content: { + id: "CFR_COOKIEBANNER", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "COOKIEBANNER_CALLOUT", + anchors: [ + { + selector: "#tracking-protection-icon-container", + panel_position: { + callout_attachment: "topleft", + anchor_attachment: "bottomcenter", + }, + }, + ], + content: { + position: "callout", + autohide: true, + title: { + string_id: "cookie-banner-blocker-onboarding-header", + paddingInline: "12px 0", + }, + subtitle: { + string_id: "cookie-banner-blocker-onboarding-body", + paddingInline: "34px 0", + }, + title_logo: { + alignment: "top", + height: "20px", + width: "20px", + imageURL: + "chrome://browser/skin/controlcenter/3rdpartycookies-blocked.svg", + }, + dismiss_button: { + size: "small", + action: { dismiss: true }, + }, + additional_button: { + label: { + string_id: "cookie-banner-blocker-onboarding-learn-more", + marginInline: "34px 0", + }, + style: "link", + alignment: "start", + action: { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/cookie-banner-reduction", + where: "tabshifted", + }, + }, + }, + }, + }, + ], + }, + frequency: { + lifetime: 1, + }, + skip_in_tests: "it's not tested in automation", + trigger: { + id: "cookieBannerHandled", + }, + targeting: `'cookiebanners.ui.desktop.enabled'|preferenceValue == true && 'cookiebanners.ui.desktop.showCallout'|preferenceValue == true && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`, + }, + ]; + messages = add24HourImpressionJEXLTargeting( + ["FIREFOX_VIEW_TAB_PICKUP_REMINDER"], + "FIREFOX_VIEW", + messages + ); + return messages; +}; + +export const FeatureCalloutMessages = { + getMessages() { + return MESSAGES(); + }, +}; diff --git a/browser/components/asrouter/modules/InfoBar.sys.mjs b/browser/components/asrouter/modules/InfoBar.sys.mjs new file mode 100644 index 0000000000..a287b650a5 --- /dev/null +++ b/browser/components/asrouter/modules/InfoBar.sys.mjs @@ -0,0 +1,169 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", +}); + +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 (content.type === "global") { + 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, content.text), + image: content.icon || "chrome://branding/content/icon64.png", + priority, + eventCallback: this.infobarCallback, + }, + content.buttons.map(b => this.formatButtonConfig(b)) + ); + + this.addImpression(); + } + + formatMessageConfig(doc, content) { + let docFragment = doc.createDocumentFragment(); + // notificationbox will only `appendChild` for documentFragments + docFragment.appendChild( + lazy.RemoteL10n.createElement(doc, "span", { content }) + ); + + return docFragment; + } + + 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"); + } + + /** + * Called when one of the infobar buttons is clicked + */ + 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); + } + + dispatchUserAction(action, selectedBrowser) { + this._dispatch({ type: "USER_ACTION", data: action }, selectedBrowser); + } + + /** + * Called when interacting with the toolbar (but not through the buttons) + */ + infobarCallback(eventType) { + if (eventType === "removed") { + this.notification = null; + // eslint-disable-next-line no-use-before-define + InfoBar._activeInfobar = null; + } else if (this.notification) { + this.sendUserEventTelemetry("DISMISSED"); + this.notification = null; + // eslint-disable-next-line no-use-before-define + InfoBar._activeInfobar = null; + } + } + + 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, + + maybeLoadCustomElement(win) { + if (!win.customElements.get("remote-text")) { + Services.scriptloader.loadSubScript( + "resource://activity-stream/data/custom-elements/paragraph.js", + win + ); + } + }, + + maybeInsertFTL(win) { + win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); + win.MozXULElement.insertFTLIfNeeded( + "browser/defaultBrowserNotification.ftl" + ); + }, + + async showInfoBarMessage(browser, message, dispatch) { + // Prevent stacking multiple infobars + if (this._activeInfobar) { + return null; + } + + const win = browser?.ownerGlobal; + + if (!win || lazy.PrivateBrowsingUtils.isWindowPrivate(win)) { + return null; + } + + this.maybeLoadCustomElement(win); + this.maybeInsertFTL(win); + + let notification = new InfoBarNotification(message, dispatch); + await notification.showNotification(browser); + this._activeInfobar = true; + + return notification; + }, +}; diff --git a/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs b/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs new file mode 100644 index 0000000000..5960ab92cc --- /dev/null +++ b/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs @@ -0,0 +1,37 @@ +/* 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/. */ + +/** + * This file is used to define constants related to messaging experiments. It is + * imported by both ASRouter as well as import-rollouts.js, a node script that + * imports Nimbus rollouts into tree. It doesn't have access to any Firefox + * APIs, XPCOM, etc. and should be kept that way. + */ + +/** + * These are the Nimbus feature IDs that correspond to messaging experiments. + * Other Nimbus features contain specific variables whose keys are enumerated in + * FeatureManifest.yaml. Conversely, messaging experiment features contain + * actual messages, with the usual message keys like `template` and `targeting`. + * @see FeatureManifest.yaml + */ +export const MESSAGING_EXPERIMENTS_DEFAULT_FEATURES = [ + "cfr", + "fxms-message-1", + "fxms-message-2", + "fxms-message-3", + "fxms-message-4", + "fxms-message-5", + "fxms-message-6", + "fxms-message-7", + "fxms-message-8", + "fxms-message-9", + "fxms-message-10", + "fxms-message-11", + "infobar", + "moments-page", + "pbNewtab", + "spotlight", + "featureCallout", +]; diff --git a/browser/components/asrouter/modules/MomentsPageHub.sys.mjs b/browser/components/asrouter/modules/MomentsPageHub.sys.mjs new file mode 100644 index 0000000000..84fee3b517 --- /dev/null +++ b/browser/components/asrouter/modules/MomentsPageHub.sys.mjs @@ -0,0 +1,171 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setInterval: "resource://gre/modules/Timer.sys.mjs", + clearInterval: "resource://gre/modules/Timer.sys.mjs", +}); + +// Frequency at which to check for new messages +const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000; +const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once"; + +// For the "reach" event of Messaging Experiments +const REACH_EVENT_CATEGORY = "messaging_experiments"; +const REACH_EVENT_METHOD = "reach"; +// Note it's not "moments-page" as Telemetry Events only accepts understores +// for the event `object` +const REACH_EVENT_OBJECT = "moments_page"; + +export class _MomentsPageHub { + constructor() { + this.id = "moments-page-hub"; + this.state = {}; + this.checkHomepageOverridePref = this.checkHomepageOverridePref.bind(this); + this._initialized = false; + } + + async init( + waitForInitialized, + { handleMessageRequest, addImpression, blockMessageById, sendTelemetry } + ) { + if (this._initialized) { + return; + } + + this._initialized = true; + this._handleMessageRequest = handleMessageRequest; + this._addImpression = addImpression; + this._blockMessageById = blockMessageById; + this._sendTelemetry = sendTelemetry; + + // Need to wait for ASRouter to initialize before trying to fetch messages + await waitForInitialized; + + this.messageRequest({ + triggerId: "momentsUpdate", + template: "update_action", + }); + + const _intervalId = lazy.setInterval( + () => this.checkHomepageOverridePref(), + SYSTEM_TICK_INTERVAL + ); + this.state = { _intervalId }; + } + + _sendPing(ping) { + this._sendTelemetry({ + type: "MOMENTS_PAGE_TELEMETRY", + data: { action: "moments_user_event", ...ping }, + }); + } + + sendUserEventTelemetry(message) { + this._sendPing({ + message_id: message.id, + bucket_id: message.id, + event: "MOMENTS_PAGE_SET", + }); + } + + /** + * If we don't have `expire` defined with the message it could be because + * it depends on user dependent parameters. Since the message matched + * targeting we calculate `expire` based on the current timestamp and the + * `expireDelta` which defines for how long it should be available. + * @param expireDelta {number} - Offset in milliseconds from the current date + */ + getExpirationDate(expireDelta) { + return Date.now() + expireDelta; + } + + executeAction(message) { + const { id, data } = message.content.action; + switch (id) { + case "moments-wnp": + const { url, expireDelta } = data; + let { expire } = data; + if (!expire) { + expire = this.getExpirationDate(expireDelta); + } + // In order to reset this action we can dispatch a new message that + // will overwrite the prev value with an expiration date from the past. + Services.prefs.setStringPref( + HOMEPAGE_OVERRIDE_PREF, + JSON.stringify({ message_id: message.id, url, expire }) + ); + // Add impression and block immediately after taking the action + this.sendUserEventTelemetry(message); + this._addImpression(message); + this._blockMessageById(message.id); + break; + } + } + + _recordReachEvent(message) { + const extra = { branches: message.branchSlug }; + Services.telemetry.recordEvent( + REACH_EVENT_CATEGORY, + REACH_EVENT_METHOD, + REACH_EVENT_OBJECT, + message.experimentSlug, + extra + ); + } + + async messageRequest({ triggerId, template }) { + const telemetryObject = { triggerId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + const messages = await this._handleMessageRequest({ + triggerId, + template, + returnAll: true, + }); + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + + // Record the "reach" event for all the messages with `forReachEvent`, + // only execute action for the first message without forReachEvent. + const nonReachMessages = []; + for (const message of messages) { + if (message.forReachEvent) { + if (!message.forReachEvent.sent) { + this._recordReachEvent(message); + message.forReachEvent.sent = true; + } + } else { + nonReachMessages.push(message); + } + } + if (nonReachMessages.length) { + this.executeAction(nonReachMessages[0]); + } + } + + /** + * Pref is set via Remote Settings message. We want to continously + * monitor new messages that come in to ensure the one with the + * highest priority is set. + */ + checkHomepageOverridePref() { + this.messageRequest({ + triggerId: "momentsUpdate", + template: "update_action", + }); + } + + uninit() { + lazy.clearInterval(this.state._intervalId); + this.state = {}; + this._initialized = false; + } +} + +/** + * ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate + * message requests and render messages. + */ +export const MomentsPageHub = new _MomentsPageHub(); diff --git a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs new file mode 100644 index 0000000000..6164e3e72a --- /dev/null +++ b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs @@ -0,0 +1,1414 @@ +/* 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 AppConstants, 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" +); + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +import { FeatureCalloutMessages } from "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + ShellService: "resource:///modules/ShellService.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "usesFirefoxSync", + "services.sync.username" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "mobileDevices", + "services.sync.clients.devices.mobile", + 0 +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "hidePrivatePin", + "browser.startup.upgradeDialog.pinPBM.disabled", + false +); + +const L10N = new Localization([ + "branding/brand.ftl", + "browser/newtab/onboarding.ftl", + "toolkit/branding/brandings.ftl", + "toolkit/branding/accounts.ftl", +]); + +const HOMEPAGE_PREF = "browser.startup.homepage"; +const NEWTAB_PREF = "browser.newtabpage.enabled"; +const FOURTEEN_DAYS_IN_MS = 14 * 24 * 60 * 60 * 1000; + +const BASE_MESSAGES = () => [ + { + id: "FXA_ACCOUNTS_BADGE", + template: "toolbar_badge", + content: { + delay: 10000, // delay for 10 seconds + target: "fxa-toolbar-menu-button", + }, + targeting: "false", + trigger: { id: "toolbarBadgeUpdate" }, + }, + { + id: "MILESTONE_MESSAGE_87", + groups: ["cfr"], + content: { + text: "", + layout: "short_message", + buttons: { + primary: { + event: "PROTECTION", + label: { + string_id: "cfr-doorhanger-milestone-ok-button", + }, + action: { + type: "OPEN_PROTECTION_REPORT", + }, + }, + secondary: [ + { + event: "DISMISS", + label: { + string_id: "cfr-doorhanger-milestone-close-button", + }, + action: { + type: "CANCEL", + }, + }, + ], + }, + category: "cfrFeatures", + anchor_id: "tracking-protection-icon-container", + bucket_id: "CFR_MILESTONE_MESSAGE", + heading_text: { + string_id: "cfr-doorhanger-milestone-heading2", + }, + notification_text: "", + skip_address_bar_notifier: true, + }, + trigger: { + id: "contentBlocking", + params: ["ContentBlockingMilestone"], + }, + template: "milestone_message", + frequency: { + lifetime: 7, + }, + targeting: "pageLoad >= 4 && userPrefs.cfrFeatures", + }, + { + id: "FX_MR_106_UPGRADE", + template: "spotlight", + targeting: "true", + content: { + template: "multistage", + id: "FX_MR_106_UPGRADE", + transitions: true, + modal: "tab", + screens: [ + { + id: "UPGRADE_PIN_FIREFOX", + content: { + position: "split", + split_narrow_bkg_position: "-155px", + image_alt_text: { + string_id: "mr2022-onboarding-pin-image-alt", + }, + progress_bar: "true", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-pintaskbar.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-onboarding-existing-pin-header", + }, + subtitle: { + string_id: "mr2022-onboarding-existing-pin-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-pin-primary-button-label", + }, + action: { + navigate: true, + type: "PIN_FIREFOX_TO_TASKBAR", + }, + }, + checkbox: { + label: { + string_id: "mr2022-onboarding-existing-pin-checkbox-label", + }, + defaultValue: true, + action: { + type: "MULTI_ACTION", + navigate: true, + data: { + actions: [ + { + type: "PIN_FIREFOX_TO_TASKBAR", + data: { + privatePin: true, + }, + }, + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + ], + }, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_SET_DEFAULT", + content: { + position: "split", + split_narrow_bkg_position: "-60px", + image_alt_text: { + string_id: "mr2022-onboarding-default-image-alt", + }, + progress_bar: "true", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-settodefault.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-onboarding-set-default-title", + }, + subtitle: { + string_id: "mr2022-onboarding-set-default-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-set-default-primary-button-label", + }, + action: { + navigate: true, + type: "SET_DEFAULT_BROWSER", + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_IMPORT_SETTINGS_EMBEDDED", + content: { + tiles: { type: "migration-wizard" }, + position: "split", + split_narrow_bkg_position: "-42px", + image_alt_text: { + string_id: "mr2022-onboarding-import-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-import.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + progress_bar: true, + hide_secondary_section: "responsive", + migrate_start: { + action: {}, + }, + migrate_close: { + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_MOBILE_DOWNLOAD", + content: { + position: "split", + split_narrow_bkg_position: "-160px", + image_alt_text: { + string_id: "mr2022-onboarding-mobile-download-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-mobilecrosspromo.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + progress_bar: true, + logo: {}, + title: { + string_id: + "onboarding-mobile-download-security-and-privacy-title", + }, + subtitle: { + string_id: + "onboarding-mobile-download-security-and-privacy-subtitle", + }, + hero_image: { + url: "chrome://activity-stream/content/data/content/assets/mobile-download-qr-existing-user.svg", + }, + cta_paragraph: { + text: { + string_id: "mr2022-onboarding-mobile-download-cta-text", + string_name: "download-label", + }, + action: { + type: "OPEN_URL", + data: { + args: "https://www.mozilla.org/firefox/mobile/get-app/?utm_medium=firefox-desktop&utm_source=onboarding-modal&utm_campaign=mr2022&utm_content=existing-global", + where: "tab", + }, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_PIN_PRIVATE_WINDOW", + content: { + position: "split", + split_narrow_bkg_position: "-100px", + image_alt_text: { + string_id: "mr2022-onboarding-pin-private-image-alt", + }, + progress_bar: "true", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-pinprivate.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-upgrade-onboarding-pin-private-window-header", + }, + subtitle: { + string_id: + "mr2022-upgrade-onboarding-pin-private-window-subtitle", + }, + primary_button: { + label: { + string_id: + "mr2022-upgrade-onboarding-pin-private-window-primary-button-label", + }, + action: { + type: "PIN_FIREFOX_TO_TASKBAR", + data: { + privatePin: true, + }, + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + { + id: "UPGRADE_DATA_RECOMMENDATION", + content: { + position: "split", + split_narrow_bkg_position: "-80px", + image_alt_text: { + string_id: "mr2022-onboarding-privacy-segmentation-image-alt", + }, + progress_bar: "true", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-privacysegmentation.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-onboarding-privacy-segmentation-title", + }, + subtitle: { + string_id: "mr2022-onboarding-privacy-segmentation-subtitle", + }, + cta_paragraph: { + text: { + string_id: "mr2022-onboarding-privacy-segmentation-text-cta", + }, + }, + primary_button: { + label: { + string_id: + "mr2022-onboarding-privacy-segmentation-button-primary-label", + }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "browser.dataFeatureRecommendations.enabled", + value: true, + }, + }, + navigate: true, + }, + }, + additional_button: { + label: { + string_id: + "mr2022-onboarding-privacy-segmentation-button-secondary-label", + }, + style: "secondary", + action: { + type: "SET_PREF", + data: { + pref: { + name: "browser.dataFeatureRecommendations.enabled", + value: false, + }, + }, + navigate: true, + }, + }, + }, + }, + { + id: "UPGRADE_GRATITUDE", + content: { + position: "split", + progress_bar: "true", + split_narrow_bkg_position: "-228px", + image_alt_text: { + string_id: "mr2022-onboarding-gratitude-image-alt", + }, + background: + "url('chrome://activity-stream/content/data/content/assets/mr-gratitude.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)", + logo: {}, + title: { + string_id: "mr2022-onboarding-gratitude-title", + }, + subtitle: { + string_id: "mr2022-onboarding-gratitude-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-gratitude-primary-button-label", + }, + action: { + type: "OPEN_FIREFOX_VIEW", + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-gratitude-secondary-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + }, + { + id: "FX_100_UPGRADE", + template: "spotlight", + targeting: "false", + content: { + template: "multistage", + id: "FX_100_UPGRADE", + transitions: true, + screens: [ + { + id: "UPGRADE_PIN_FIREFOX", + content: { + logo: { + imageURL: + "chrome://activity-stream/content/data/content/assets/heart.webp", + height: "73px", + }, + has_noodles: true, + title: { + fontSize: "36px", + string_id: "fx100-upgrade-thanks-header", + }, + title_style: "fancy shine", + background: + "url('chrome://activity-stream/content/data/content/assets/confetti.svg') top / 100% no-repeat var(--in-content-page-background)", + subtitle: { + string_id: "fx100-upgrade-thanks-keep-body", + }, + primary_button: { + label: { + string_id: "fx100-thank-you-pin-primary-button-label", + }, + action: { + navigate: true, + type: "PIN_FIREFOX_TO_TASKBAR", + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + }, + { + id: "PB_NEWTAB_FOCUS_PROMO", + type: "default", + template: "pb_newtab", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "FOCUS", + promoHeader: "fluent:about-private-browsing-focus-promo-header-c", + promoImageLarge: "chrome://browser/content/assets/focus-promo.png", + promoLinkText: "fluent:about-private-browsing-focus-promo-cta", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: "fluent:about-private-browsing-focus-promo-text-c", + promoTitleEnabled: true, + promoButton: { + action: { + type: "SHOW_SPOTLIGHT", + data: { + content: { + id: "FOCUS_PROMO", + template: "multistage", + modal: "tab", + backdrop: "transparent", + screens: [ + { + id: "DEFAULT_MODAL_UI", + content: { + logo: { + imageURL: + "chrome://browser/content/assets/focus-logo.svg", + height: "48px", + }, + title: { + string_id: "spotlight-focus-promo-title", + }, + subtitle: { + string_id: "spotlight-focus-promo-subtitle", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + ios: { + action: { + data: { + args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fus%2Fapp%2Ffirefox-focus-privacy-browser%2Fid1055677337", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + android: { + action: { + data: { + args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.focus", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: + "chrome://browser/content/assets/focus-qr-code.svg", + alt_text: { + string_id: "spotlight-focus-promo-qr-code", + }, + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + priority: 2, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + // Exclude the next 2 messages: 1) Klar for en 2) Klar for de + targeting: + "!(region in [ 'DE', 'AT', 'CH'] && localeLanguageCode == 'en') && localeLanguageCode != 'de'", + }, + { + id: "PB_NEWTAB_KLAR_PROMO", + type: "default", + template: "pb_newtab", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "FOCUS", + promoHeader: "fluent:about-private-browsing-focus-promo-header-c", + promoImageLarge: "chrome://browser/content/assets/focus-promo.png", + promoLinkText: "Download Firefox Klar", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: + "Firefox Klar clears your history every time while blocking ads and trackers.", + promoTitleEnabled: true, + promoButton: { + action: { + type: "SHOW_SPOTLIGHT", + data: { + content: { + id: "KLAR_PROMO", + template: "multistage", + modal: "tab", + backdrop: "transparent", + screens: [ + { + id: "DEFAULT_MODAL_UI", + order: 0, + content: { + logo: { + imageURL: + "chrome://browser/content/assets/focus-logo.svg", + height: "48px", + }, + title: "Get Firefox Klar", + subtitle: { + string_id: "spotlight-focus-promo-subtitle", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + ios: { + action: { + data: { + args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fde%2Fapp%2Fklar-by-firefox%2Fid1073435754", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + android: { + action: { + data: { + args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.klar", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: + "chrome://browser/content/assets/klar-qr-code.svg", + alt_text: "Scan the QR code to get Firefox Klar", + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + priority: 2, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: "region in [ 'DE', 'AT', 'CH'] && localeLanguageCode == 'en'", + }, + { + id: "PB_NEWTAB_KLAR_PROMO_DE", + type: "default", + template: "pb_newtab", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "FOCUS", + promoHeader: "fluent:about-private-browsing-focus-promo-header-c", + promoImageLarge: "chrome://browser/content/assets/focus-promo.png", + promoLinkText: "fluent:about-private-browsing-focus-promo-cta", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: "fluent:about-private-browsing-focus-promo-text-c", + promoTitleEnabled: true, + promoButton: { + action: { + type: "SHOW_SPOTLIGHT", + data: { + content: { + id: "FOCUS_PROMO", + template: "multistage", + modal: "tab", + backdrop: "transparent", + screens: [ + { + id: "DEFAULT_MODAL_UI", + content: { + logo: { + imageURL: + "chrome://browser/content/assets/focus-logo.svg", + height: "48px", + }, + title: { + string_id: "spotlight-focus-promo-title", + }, + subtitle: { + string_id: "spotlight-focus-promo-subtitle", + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + ios: { + action: { + data: { + args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fde%2Fapp%2Fklar-by-firefox%2Fid1073435754", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + android: { + action: { + data: { + args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.klar", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: + "chrome://browser/content/assets/klar-qr-code.svg", + alt_text: { + string_id: "spotlight-focus-promo-qr-code", + }, + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + priority: 2, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: "localeLanguageCode == 'de'", + }, + { + id: "PB_NEWTAB_PIN_PROMO", + template: "pb_newtab", + type: "default", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "PIN", + promoHeader: "fluent:about-private-browsing-pin-promo-header", + promoImageLarge: + "chrome://browser/content/assets/private-promo-asset.svg", + promoLinkText: "fluent:about-private-browsing-pin-promo-link-text", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: "fluent:about-private-browsing-pin-promo-title", + promoTitleEnabled: true, + promoButton: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.privateWindowSeparation.enabled", + value: true, + }, + }, + }, + { + type: "PIN_FIREFOX_TO_TASKBAR", + data: { + privatePin: true, + }, + }, + { + type: "BLOCK_MESSAGE", + data: { + id: "PB_NEWTAB_PIN_PROMO", + }, + }, + { + type: "OPEN_ABOUT_PAGE", + data: { args: "privatebrowsing", where: "current" }, + }, + ], + }, + }, + }, + }, + priority: 3, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: "!inMr2022Holdback && doesAppNeedPrivatePin", + }, + { + id: "PB_NEWTAB_COOKIE_BANNERS_PROMO", + template: "pb_newtab", + type: "default", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "COOKIE_BANNERS", + promoHeader: "fluent:about-private-browsing-cookie-banners-promo-heading", + promoImageLarge: + "chrome://browser/content/assets/cookie-banners-begone.svg", + promoLinkText: "fluent:about-private-browsing-learn-more-link", + promoLinkType: "link", + promoSectionStyle: "below-search", + promoTitle: "fluent:about-private-browsing-cookie-banners-promo-body", + promoTitleEnabled: true, + promoButton: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "OPEN_URL", + data: { + args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/cookie-banner-reduction", + where: "tabshifted", + }, + }, + { + type: "BLOCK_MESSAGE", + data: { + id: "PB_NEWTAB_COOKIE_BANNERS_PROMO", + }, + }, + ], + }, + }, + }, + }, + priority: 4, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: `'cookiebanners.service.mode.privateBrowsing'|preferenceValue != 0 || 'cookiebanners.service.mode'|preferenceValue != 0`, + }, + { + id: "INFOBAR_LAUNCH_ON_LOGIN", + groups: ["cfr"], + template: "infobar", + content: { + type: "global", + text: { + string_id: "launch-on-login-infobar-message", + }, + buttons: [ + { + label: { + string_id: "launch-on-login-learnmore", + }, + supportPage: "make-firefox-automatically-open-when-you-start", + action: { + type: "CANCEL", + }, + }, + { + label: { string_id: "launch-on-login-infobar-reject-button" }, + action: { + type: "CANCEL", + }, + }, + { + label: { string_id: "launch-on-login-infobar-confirm-button" }, + primary: true, + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt", + value: true, + }, + }, + }, + { + type: "CONFIRM_LAUNCH_ON_LOGIN", + }, + ], + }, + }, + }, + ], + }, + frequency: { + lifetime: 1, + }, + trigger: { id: "defaultBrowserCheck" }, + targeting: `source == 'newtab' + && 'browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt'|preferenceValue == false + && 'browser.startup.windowsLaunchOnLogin.enabled'|preferenceValue == true && isDefaultBrowser && !activeNotifications + && !launchOnLoginEnabled`, + }, + { + id: "INFOBAR_LAUNCH_ON_LOGIN_FINAL", + groups: ["cfr"], + template: "infobar", + content: { + type: "global", + text: { + string_id: "launch-on-login-infobar-final-message", + }, + buttons: [ + { + label: { + string_id: "launch-on-login-learnmore", + }, + supportPage: "make-firefox-automatically-open-when-you-start", + action: { + type: "CANCEL", + }, + }, + { + label: { string_id: "launch-on-login-infobar-final-reject-button" }, + action: { + type: "SET_PREF", + data: { + pref: { + name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt", + value: true, + }, + }, + }, + }, + { + label: { string_id: "launch-on-login-infobar-confirm-button" }, + primary: true, + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt", + value: true, + }, + }, + }, + { + type: "CONFIRM_LAUNCH_ON_LOGIN", + }, + ], + }, + }, + }, + ], + }, + frequency: { + lifetime: 1, + }, + trigger: { id: "defaultBrowserCheck" }, + targeting: `source == 'newtab' + && 'browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt'|preferenceValue == false + && 'browser.startup.windowsLaunchOnLogin.enabled'|preferenceValue == true && isDefaultBrowser && !activeNotifications + && messageImpressions.INFOBAR_LAUNCH_ON_LOGIN[messageImpressions.INFOBAR_LAUNCH_ON_LOGIN | length - 1] + && messageImpressions.INFOBAR_LAUNCH_ON_LOGIN[messageImpressions.INFOBAR_LAUNCH_ON_LOGIN | length - 1] < + currentDate|date - ${FOURTEEN_DAYS_IN_MS} + && !launchOnLoginEnabled`, + }, + { + id: "FOX_DOODLE_SET_DEFAULT", + template: "spotlight", + groups: ["eco"], + skip_in_tests: "it fails unrelated tests", + content: { + backdrop: "transparent", + id: "FOX_DOODLE_SET_DEFAULT", + screens: [ + { + id: "FOX_DOODLE_SET_DEFAULT_SCREEN", + content: { + logo: { + height: "125px", + imageURL: + "chrome://activity-stream/content/data/content/assets/fox-doodle-waving.gif", + reducedMotionImageURL: + "chrome://activity-stream/content/data/content/assets/fox-doodle-waving-static.png", + }, + title: { + fontSize: "22px", + fontWeight: 590, + letterSpacing: 0, + paddingInline: "24px", + paddingBlock: "4px 0", + string_id: "fox-doodle-pin-headline", + }, + subtitle: { + fontSize: "15px", + letterSpacing: 0, + lineHeight: "1.4", + marginBlock: "8px 16px", + paddingInline: "24px", + string_id: "fox-doodle-pin-body", + }, + primary_button: { + action: { + navigate: true, + type: "SET_DEFAULT_BROWSER", + }, + label: { + paddingBlock: "0", + paddingInline: "16px", + marginBlock: "4px 0", + string_id: "fox-doodle-pin-primary", + }, + }, + secondary_button: { + action: { + navigate: true, + }, + label: { + marginBlock: "0 -20px", + string_id: "fox-doodle-pin-secondary", + }, + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + }, + }, + ], + template: "multistage", + transitions: true, + }, + frequency: { + lifetime: 2, + }, + targeting: + "source == 'startup' && !isMajorUpgrade && !activeNotifications && !isDefaultBrowser && !willShowDefaultPrompt && (currentDate|date - profileAgeCreated|date) / 86400000 >= 28 && userPrefs.cfrFeatures == true", + trigger: { + id: "defaultBrowserCheck", + }, + }, + { + id: "TAIL_FOX_SET_DEFAULT", + template: "spotlight", + groups: ["eco"], + skip_in_tests: "it fails unrelated tests", + content: { + backdrop: "transparent", + id: "TAIL_FOX_SET_DEFAULT_CONTENT", + screens: [ + { + id: "TAIL_FOX_SET_DEFAULT_SCREEN", + content: { + logo: { + height: "140px", + imageURL: + "chrome://activity-stream/content/data/content/assets/fox-doodle-tail.png", + reducedMotionImageURL: + "chrome://activity-stream/content/data/content/assets/fox-doodle-tail.png", + }, + title: { + fontSize: "22px", + fontWeight: 590, + letterSpacing: 0, + paddingInline: "24px", + paddingBlock: "4px 0", + string_id: "tail-fox-spotlight-title", + }, + subtitle: { + fontSize: "15px", + letterSpacing: 0, + lineHeight: "1.4", + marginBlock: "8px 16px", + paddingInline: "24px", + string_id: "tail-fox-spotlight-subtitle", + }, + primary_button: { + action: { + navigate: true, + type: "SET_DEFAULT_BROWSER", + }, + label: { + paddingBlock: "0", + paddingInline: "16px", + marginBlock: "4px 0", + string_id: "tail-fox-spotlight-primary-button", + }, + }, + secondary_button: { + action: { + navigate: true, + }, + label: { + marginBlock: "0 -20px", + string_id: "tail-fox-spotlight-secondary-button", + }, + }, + dismiss_button: { + action: { + navigate: true, + }, + }, + }, + }, + ], + template: "multistage", + transitions: true, + }, + frequency: { + lifetime: 1, + }, + targeting: + "source == 'startup' && !isMajorUpgrade && !activeNotifications && !isDefaultBrowser && !willShowDefaultPrompt && (currentDate|date - profileAgeCreated|date) / 86400000 <= 28 && (currentDate|date - profileAgeCreated|date) / 86400000 >= 7 && userPrefs.cfrFeatures == true", + trigger: { + id: "defaultBrowserCheck", + }, + }, +]; + +// Eventually, move Feature Callout messages to their own provider +const ONBOARDING_MESSAGES = () => + BASE_MESSAGES().concat(FeatureCalloutMessages.getMessages()); + +export const OnboardingMessageProvider = { + async getExtraAttributes() { + const [header, button_label] = await L10N.formatMessages([ + { id: "onboarding-welcome-header" }, + { id: "onboarding-start-browsing-button-label" }, + ]); + return { header: header.value, button_label: button_label.value }; + }, + async getMessages() { + const messages = await this.translateMessages(await ONBOARDING_MESSAGES()); + return messages; + }, + async getUntranslatedMessages() { + // This is helpful for jsonSchema testing - since we are localizing in the provider + const messages = await ONBOARDING_MESSAGES(); + return messages; + }, + async translateMessages(messages) { + let translatedMessages = []; + for (const msg of messages) { + let translatedMessage = { ...msg }; + + // If the message has no content, do not attempt to translate it + if (!translatedMessage.content) { + translatedMessages.push(translatedMessage); + continue; + } + + // Translate any secondary buttons separately + if (msg.content.secondary_button) { + const [secondary_button_string] = await L10N.formatMessages([ + { id: msg.content.secondary_button.label.string_id }, + ]); + translatedMessage.content.secondary_button.label = + secondary_button_string.value; + } + if (msg.content.header) { + const [header_string] = await L10N.formatMessages([ + { id: msg.content.header.string_id }, + ]); + translatedMessage.content.header = header_string.value; + } + translatedMessages.push(translatedMessage); + } + return translatedMessages; + }, + async _doesAppNeedPin(privateBrowsing = false) { + const needPin = await lazy.ShellService.doesAppNeedPin(privateBrowsing); + return needPin; + }, + async _doesAppNeedDefault() { + let checkDefault = Services.prefs.getBoolPref( + "browser.shell.checkDefaultBrowser", + false + ); + let isDefault = await lazy.ShellService.isDefaultBrowser(); + return checkDefault && !isDefault; + }, + _shouldShowPrivacySegmentationScreen() { + // Fall back to pref: browser.privacySegmentation.preferences.show + return lazy.NimbusFeatures.majorRelease2022.getVariable( + "feltPrivacyShowPreferencesSection" + ); + }, + _doesHomepageNeedReset() { + return ( + Services.prefs.prefHasUserValue(HOMEPAGE_PREF) || + Services.prefs.prefHasUserValue(NEWTAB_PREF) + ); + }, + + async getUpgradeMessage() { + let message = (await OnboardingMessageProvider.getMessages()).find( + ({ id }) => id === "FX_MR_106_UPGRADE" + ); + + let { content } = message; + // Helper to find screens and remove them where applicable. + function removeScreens(check) { + const { screens } = content; + for (let i = 0; i < screens?.length; i++) { + if (check(screens[i])) { + screens.splice(i--, 1); + } + } + } + + // Helper to prepare mobile download screen content + function prepareMobileDownload() { + let mobileContent = content.screens.find( + screen => screen.id === "UPGRADE_MOBILE_DOWNLOAD" + )?.content; + + if (!mobileContent) { + return; + } + if (!lazy.BrowserUtils.sendToDeviceEmailsSupported()) { + // If send to device emails are not supported for a user's locale, + // remove the send to device link and update the screen text + delete mobileContent.cta_paragraph.action; + mobileContent.cta_paragraph.text = { + string_id: "mr2022-onboarding-no-mobile-download-cta-text", + }; + } + // Update CN specific QRCode url + if (AppConstants.isChinaRepack()) { + mobileContent.hero_image.url = `${mobileContent.hero_image.url.slice( + 0, + mobileContent.hero_image.url.indexOf(".svg") + )}-cn.svg`; + } + } + + let pinScreen = content.screens?.find( + screen => screen.id === "UPGRADE_PIN_FIREFOX" + ); + const needPin = await this._doesAppNeedPin(); + const needDefault = await this._doesAppNeedDefault(); + const needPrivatePin = + !lazy.hidePrivatePin && (await this._doesAppNeedPin(true)); + const showSegmentation = this._shouldShowPrivacySegmentationScreen(); + + //If a user has Firefox as default remove import screen + if (!needDefault) { + removeScreens(screen => + screen.id?.startsWith("UPGRADE_IMPORT_SETTINGS_EMBEDDED") + ); + } + + // If already pinned, convert "pin" screen to "welcome" with desired action. + let removeDefault = !needDefault; + // If user doesn't need pin, update screen to set "default" or "get started" configuration + if (!needPin && pinScreen) { + // don't need to show the checkbox + delete pinScreen.content.checkbox; + + removeDefault = true; + let primary = pinScreen.content.primary_button; + if (needDefault) { + pinScreen.id = "UPGRADE_ONLY_DEFAULT"; + pinScreen.content.subtitle = { + string_id: "mr2022-onboarding-existing-set-default-only-subtitle", + }; + primary.label.string_id = + "mr2022-onboarding-set-default-primary-button-label"; + + // The "pin" screen will now handle "default" so remove other "default." + primary.action.type = "SET_DEFAULT_BROWSER"; + } else { + pinScreen.id = "UPGRADE_GET_STARTED"; + pinScreen.content.subtitle = { + string_id: "mr2022-onboarding-get-started-primary-subtitle", + }; + primary.label = { + string_id: "mr2022-onboarding-get-started-primary-button-label", + }; + delete primary.action.type; + } + } + + // If a user has Firefox private pinned remove pin private window screen + // We also remove standalone pin private window screen if a user doesn't have + // Firefox pinned in which case the option is shown as checkbox with UPGRADE_PIN_FIREFOX screen + if (!needPrivatePin || needPin) { + removeScreens(screen => + screen.id?.startsWith("UPGRADE_PIN_PRIVATE_WINDOW") + ); + } + + if (!showSegmentation) { + removeScreens(screen => + screen.id?.startsWith("UPGRADE_DATA_RECOMMENDATION") + ); + } + + //If privatePin, remove checkbox from pinscreen + if (!needPrivatePin) { + delete content.screens?.find( + screen => screen.id === "UPGRADE_PIN_FIREFOX" + )?.content?.checkbox; + } + + if (removeDefault) { + removeScreens(screen => screen.id?.startsWith("UPGRADE_SET_DEFAULT")); + } + + // Remove mobile download screen if user has sync enabled + if (lazy.usesFirefoxSync && lazy.mobileDevices > 0) { + removeScreens(screen => screen.id === "UPGRADE_MOBILE_DOWNLOAD"); + } else { + prepareMobileDownload(); + } + + return message; + }, +}; diff --git a/browser/components/asrouter/modules/PageEventManager.sys.mjs b/browser/components/asrouter/modules/PageEventManager.sys.mjs new file mode 100644 index 0000000000..44f1293385 --- /dev/null +++ b/browser/components/asrouter/modules/PageEventManager.sys.mjs @@ -0,0 +1,135 @@ +/* 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/. */ + +/** + * Methods for setting up and tearing down page event listeners. These are used + * to dismiss Feature Callouts when the callout's anchor element is clicked. + */ +export class PageEventManager { + /** + * A set of parameters defining a page event listener. + * @typedef {Object} PageEventListenerParams + * @property {String} type Event type string e.g. `click` + * @property {String} [selectors] Target selector, e.g. `tag.class, #id[attr]` + * @property {PageEventListenerOptions} [options] addEventListener options + * + * @typedef {Object} PageEventListenerOptions + * @property {Boolean} [capture] Use event capturing phase? + * @property {Boolean} [once] Remove listener after first event? + * @property {Boolean} [preventDefault] Inverted value for `passive` option + * @property {Number} [interval] Used only for `timeout` and `interval` event + * types. These don't set up real event listeners, but instead invoke the + * action on a timer. + * + * @typedef {Object} PageEventListener + * @property {Function} callback Function to call when event is triggered + * @property {AbortController} controller Handle for aborting the listener + * + * @typedef {Object} PageEvent + * @property {String} type Event type string e.g. `click` + * @property {Element} [target] Event target + */ + + /** + * Maps event listener params to their PageEventListeners, so they can be + * called and cancelled. + * @type {Map} + */ + _listeners = new Map(); + + /** + * @param {Window} win Window containing the document to listen to + */ + constructor(win) { + this.win = win; + this.doc = win.document; + } + + /** + * Adds a page event listener. + * @param {PageEventListenerParams} params + * @param {Function} callback Function to call when event is triggered + */ + on(params, callback) { + if (this._listeners.has(params)) { + return; + } + const { type, selectors, options = {} } = params; + const listener = { callback }; + if (selectors) { + const controller = new AbortController(); + const opt = { + capture: !!options.capture, + passive: !options.preventDefault, + signal: controller.signal, + }; + const targets = this.doc.querySelectorAll(selectors); + for (const target of targets) { + target.addEventListener(type, callback, opt); + } + listener.controller = controller; + } else if (["timeout", "interval"].includes(type) && options.interval) { + let interval; + const abort = () => this.win.clearInterval(interval); + const onInterval = () => { + callback({ type, target: type }); + if (type === "timeout") { + abort(); + } + }; + interval = this.win.setInterval(onInterval, options.interval); + listener.callback = onInterval; + listener.controller = { abort }; + } + this._listeners.set(params, listener); + } + + /** + * Removes a page event listener. + * @param {PageEventListenerParams} params + */ + off(params) { + const listener = this._listeners.get(params); + if (!listener) { + return; + } + listener.controller?.abort(); + this._listeners.delete(params); + } + + /** + * Adds a page event listener that is removed after the first event. + * @param {PageEventListenerParams} params + * @param {Function} callback Function to call when event is triggered + */ + once(params, callback) { + const wrappedCallback = (...args) => { + this.off(params); + callback(...args); + }; + this.on(params, wrappedCallback); + } + + /** + * Removes all page event listeners. + */ + clear() { + for (const listener of this._listeners.values()) { + listener.controller?.abort(); + } + this._listeners.clear(); + } + + /** + * Calls matching page event listeners. A way to dispatch a "fake" event. + * @param {PageEvent} event + */ + emit(event) { + for (const [params, listener] of this._listeners) { + if (params.type === event.type) { + listener.callback(event); + } + } + } +} diff --git a/browser/components/asrouter/modules/PanelTestProvider.sys.mjs b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs new file mode 100644 index 0000000000..7a7ff1e1fc --- /dev/null +++ b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs @@ -0,0 +1,771 @@ +/* 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/. */ + +const TWO_DAYS = 2 * 24 * 3600 * 1000; + +const MESSAGES = () => [ + { + id: "WNP_THANK_YOU", + template: "update_action", + content: { + action: { + id: "moments-wnp", + data: { + url: "https://www.mozilla.org/%LOCALE%/etc/firefox/retention/thank-you-a/", + expireDelta: TWO_DAYS, + }, + }, + }, + trigger: { id: "momentsUpdate" }, + }, + { + id: "WHATS_NEW_FINGERPRINTER_COUNTER_ALT", + template: "whatsnew_panel_message", + order: 6, + content: { + bucket_id: "WHATS_NEW_72", + published_date: 1574776601000, + title: "Title", + icon_url: + "chrome://activity-stream/content/data/content/assets/protection-report-icon.png", + icon_alt: { string_id: "cfr-badge-reader-label-newfeature" }, + body: "Message body", + link_text: "Click here", + cta_url: "about:blank", + cta_type: "OPEN_PROTECTION_REPORT", + }, + targeting: `firefoxVersion >= 72`, + trigger: { id: "whatsNewPanelOpened" }, + }, + { + id: "WHATS_NEW_70_1", + template: "whatsnew_panel_message", + order: 3, + content: { + bucket_id: "WHATS_NEW_70_1", + published_date: 1560969794394, + title: "Protection Is Our Focus", + icon_url: + "chrome://activity-stream/content/data/content/assets/whatsnew-send-icon.png", + icon_alt: "Firefox Send Logo", + body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", + cta_url: "https://blog.mozilla.org/", + cta_type: "OPEN_URL", + }, + targeting: `firefoxVersion > 69`, + trigger: { id: "whatsNewPanelOpened" }, + }, + { + id: "WHATS_NEW_70_2", + template: "whatsnew_panel_message", + order: 1, + content: { + bucket_id: "WHATS_NEW_70_1", + published_date: 1560969794394, + title: "Another thing new in Firefox 70", + body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", + link_text: "Learn more on our blog", + cta_url: "https://blog.mozilla.org/", + cta_type: "OPEN_URL", + }, + targeting: `firefoxVersion > 69`, + trigger: { id: "whatsNewPanelOpened" }, + }, + { + id: "WHATS_NEW_SEARCH_SHORTCUTS_84", + template: "whatsnew_panel_message", + order: 2, + content: { + bucket_id: "WHATS_NEW_SEARCH_SHORTCUTS_84", + published_date: 1560969794394, + title: "Title", + icon_url: "chrome://global/skin/icons/check.svg", + icon_alt: "", + body: "Message content", + cta_url: + "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/search-shortcuts", + cta_type: "OPEN_URL", + link_text: "Click here", + }, + targeting: "firefoxVersion >= 84", + trigger: { + id: "whatsNewPanelOpened", + }, + }, + { + id: "WHATS_NEW_PIONEER_82", + template: "whatsnew_panel_message", + order: 1, + content: { + bucket_id: "WHATS_NEW_PIONEER_82", + published_date: 1603152000000, + title: "Put your data to work for a better internet", + body: "Contribute your data to Mozilla's Pioneer program to help researchers understand pressing technology issues like misinformation, data privacy, and ethical AI.", + cta_url: "about:blank", + cta_where: "tab", + cta_type: "OPEN_ABOUT_PAGE", + link_text: "Join Pioneer", + }, + targeting: "firefoxVersion >= 82", + trigger: { + id: "whatsNewPanelOpened", + }, + }, + { + id: "WHATS_NEW_MEDIA_SESSION_82", + template: "whatsnew_panel_message", + order: 3, + content: { + bucket_id: "WHATS_NEW_MEDIA_SESSION_82", + published_date: 1603152000000, + title: "Title", + body: "Message content", + cta_url: + "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/media-keyboard-control", + cta_type: "OPEN_URL", + link_text: "Click here", + }, + targeting: "firefoxVersion >= 82", + trigger: { + id: "whatsNewPanelOpened", + }, + }, + { + id: "WHATS_NEW_69_1", + template: "whatsnew_panel_message", + order: 1, + content: { + bucket_id: "WHATS_NEW_69_1", + published_date: 1557346235089, + title: "Something new in Firefox 69", + body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.", + link_text: "Learn more on our blog", + cta_url: "https://blog.mozilla.org/", + cta_type: "OPEN_URL", + }, + targeting: `firefoxVersion > 68`, + trigger: { id: "whatsNewPanelOpened" }, + }, + { + id: "PERSONALIZED_CFR_MESSAGE", + template: "cfr_doorhanger", + groups: ["cfr"], + content: { + layout: "icon_and_message", + category: "cfrFeatures", + bucket_id: "PERSONALIZED_CFR_MESSAGE", + notification_text: "Personalized CFR Recommendation", + heading_text: { string_id: "cfr-doorhanger-bookmark-fxa-header" }, + info_icon: { + label: { + attributes: { + tooltiptext: { string_id: "cfr-doorhanger-fxa-close-btn-tooltip" }, + }, + }, + sumo_path: "https://example.com", + }, + text: { string_id: "cfr-doorhanger-bookmark-fxa-body" }, + icon: "chrome://branding/content/icon64.png", + icon_class: "cfr-doorhanger-large-icon", + persistent_doorhanger: true, + buttons: { + primary: { + label: { string_id: "cfr-doorhanger-milestone-ok-button" }, + action: { + type: "OPEN_URL", + data: { + args: "https://send.firefox.com/login/?utm_source=activity-stream&entrypoint=activity-stream-cfr-pdf", + where: "tabshifted", + }, + }, + }, + secondary: [ + { + label: { string_id: "cfr-doorhanger-extension-cancel-button" }, + action: { type: "CANCEL" }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-never-show-recommendation", + }, + }, + { + label: { + string_id: "cfr-doorhanger-extension-manage-settings-button", + }, + action: { + type: "OPEN_PREFERENCES_PAGE", + data: { category: "general-cfrfeatures" }, + }, + }, + ], + }, + }, + targeting: "scores.PERSONALIZED_CFR_MESSAGE.score > scoreThreshold", + trigger: { + id: "openURL", + patterns: ["*://*/*.pdf"], + }, + }, + { + id: "MULTISTAGE_SPOTLIGHT_MESSAGE", + groups: ["panel-test-provider"], + template: "spotlight", + content: { + id: "MULTISTAGE_SPOTLIGHT_MESSAGE", + template: "multistage", + backdrop: "transparent", + transitions: true, + screens: [ + { + id: "AW_PIN_FIREFOX", + content: { + has_noodles: true, + title: { + string_id: "onboarding-easy-setup-security-and-privacy-title", + }, + logo: { + imageURL: "chrome://browser/content/callout-tab-pickup.svg", + darkModeImageURL: + "chrome://browser/content/callout-tab-pickup-dark.svg", + reducedMotionImageURL: + "chrome://activity-stream/content/data/content/assets/glyph-pin-16.svg", + darkModeReducedMotionImageURL: + "chrome://activity-stream/content/data/content/assets/firefox.svg", + alt: "sample alt text", + }, + hero_text: { + string_id: "fx100-thank-you-hero-text", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-pin-primary-button-label", + }, + action: { + navigate: true, + type: "PIN_FIREFOX_TO_TASKBAR", + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + dismiss_button: { + action: { + dismiss: true, + }, + }, + }, + }, + { + id: "AW_SET_DEFAULT", + content: { + has_noodles: true, + logo: { + imageURL: "chrome://browser/content/logos/vpn-promo-logo.svg", + height: "100px", + }, + title: { + fontSize: "36px", + fontWeight: 276, + string_id: "mr2022-onboarding-set-default-title", + }, + subtitle: { + string_id: "mr2022-onboarding-set-default-subtitle", + }, + primary_button: { + label: { + string_id: "mr2022-onboarding-set-default-primary-button-label", + }, + action: { + navigate: true, + type: "SET_DEFAULT_BROWSER", + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + { + id: "BACKGROUND_IMAGE", + content: { + background: "#000", + text_color: "light", + progress_bar: true, + logo: { + imageURL: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif", + }, + title: "A dialog with a background", + subtitle: + "The text color is configurable and a progress bar style step indicator is used", + primary_button: { + label: "Continue", + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + { + id: "BACKGROUND_COLOR", + content: { + background: "white", + progress_bar: true, + logo: { + height: "200px", + imageURL: "", + }, + title: { + fontSize: "36px", + fontWeight: 276, + raw: "Peace of mind.", + }, + title_style: "fancy shine", + text_color: "dark", + subtitle: "Using progress bar style step indicator", + primary_button: { + label: "Continue", + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + action: { + navigate: true, + }, + }, + }, + }, + ], + }, + frequency: { lifetime: 3 }, + trigger: { id: "defaultBrowserCheck" }, + }, + { + id: "PB_FOCUS_PROMO", + groups: ["panel-test-provider"], + template: "spotlight", + content: { + template: "multistage", + backdrop: "transparent", + screens: [ + { + id: "PBM_FIREFOX_FOCUS", + order: 0, + content: { + logo: { + imageURL: "chrome://browser/content/assets/focus-logo.svg", + height: "48px", + }, + title: { + string_id: "spotlight-focus-promo-title", + }, + subtitle: { + string_id: "spotlight-focus-promo-subtitle", + }, + dismiss_button: { + action: { + dismiss: true, + }, + }, + ios: { + action: { + data: { + args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fus%2Fapp%2Ffirefox-focus-privacy-browser%2Fid1055677337", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + android: { + action: { + data: { + args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.focus", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + email_link: { + action: { + data: { + args: "https://mozilla.org", + where: "tabshifted", + }, + type: "OPEN_URL", + navigate: true, + }, + }, + tiles: { + type: "mobile_downloads", + data: { + QR_code: { + image_url: + "chrome://browser/content/assets/focus-qr-code.svg", + alt_text: { + string_id: "spotlight-focus-promo-qr-code", + }, + }, + email: { + link_text: "Email yourself a link", + }, + marketplace_buttons: ["ios", "android"], + }, + }, + }, + }, + ], + }, + trigger: { id: "defaultBrowserCheck" }, + }, + { + id: "PB_NEWTAB_VPN_PROMO", + template: "pb_newtab", + content: { + promoEnabled: true, + promoType: "VPN", + infoEnabled: true, + infoBody: "fluent:about-private-browsing-info-description-private-window", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitleEnabled: false, + promoLinkType: "button", + promoLinkText: "fluent:about-private-browsing-prominent-cta", + promoSectionStyle: "below-search", + promoHeader: "fluent:about-private-browsing-get-privacy", + promoTitle: "fluent:about-private-browsing-hide-activity-1", + promoTitleEnabled: true, + promoImageLarge: "chrome://browser/content/assets/moz-vpn.svg", + promoButton: { + action: { + type: "OPEN_URL", + data: { + args: "https://vpn.mozilla.org/", + }, + }, + }, + }, + groups: ["panel-test-provider"], + targeting: "region != 'CN' && !hasActiveEnterprisePolicies", + frequency: { lifetime: 3 }, + }, + { + id: "PB_PIN_PROMO", + template: "pb_newtab", + groups: ["pbNewtab"], + content: { + infoBody: "fluent:about-private-browsing-info-description-simplified", + infoEnabled: true, + infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg", + infoLinkText: "fluent:about-private-browsing-learn-more-link", + infoTitle: "", + infoTitleEnabled: false, + promoEnabled: true, + promoType: "PIN", + promoHeader: "Private browsing freedom in one click", + promoImageLarge: + "chrome://browser/content/assets/private-promo-asset.svg", + promoLinkText: "Pin To Taskbar", + promoLinkType: "button", + promoSectionStyle: "below-search", + promoTitle: + "No saved cookies or history, right from your desktop. Browse like no one’s watching.", + promoTitleEnabled: true, + promoButton: { + action: { + type: "MULTI_ACTION", + data: { + actions: [ + { + type: "SET_PREF", + data: { + pref: { + name: "browser.privateWindowSeparation.enabled", + value: true, + }, + }, + }, + { + type: "PIN_FIREFOX_TO_TASKBAR", + }, + { + type: "BLOCK_MESSAGE", + data: { + id: "PB_PIN_PROMO", + }, + }, + { + type: "OPEN_ABOUT_PAGE", + data: { args: "privatebrowsing", where: "current" }, + }, + ], + }, + }, + }, + }, + priority: 3, + frequency: { + custom: [ + { + cap: 3, + period: 604800000, // Max 3 per week + }, + ], + lifetime: 12, + }, + targeting: + "region != 'CN' && !hasActiveEnterprisePolicies && doesAppNeedPin", + }, + { + id: "TEST_TOAST_NOTIFICATION1", + weight: 100, + template: "toast_notification", + content: { + title: { + string_id: "cfr-doorhanger-bookmark-fxa-header", + }, + body: "Body", + image_url: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif", + launch_url: "https://mozilla.org", + requireInteraction: true, + actions: [ + { + action: "dismiss", + title: "Dismiss", + windowsSystemActivationType: true, + }, + { + action: "snooze", + title: "Snooze", + windowsSystemActivationType: true, + }, + { action: "callback", title: "Callback" }, + ], + tag: "test_toast_notification", + }, + groups: ["panel-test-provider"], + targeting: "!hasActiveEnterprisePolicies", + trigger: { id: "backgroundTaskMessage" }, + frequency: { lifetime: 3 }, + }, + { + id: "TEST_TOAST_NOTIFICATION2", + weight: 100, + template: "toast_notification", + content: { + title: "Launch action on toast click and on action button click", + body: "Body", + image_url: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif", + launch_action: { + type: "OPEN_URL", + data: { args: "https://mozilla.org", where: "window" }, + }, + requireInteraction: true, + actions: [ + { + action: "dismiss", + title: "Dismiss", + windowsSystemActivationType: true, + }, + { + action: "snooze", + title: "Snooze", + windowsSystemActivationType: true, + }, + { + action: "private", + title: "Private Window", + launch_action: { type: "OPEN_PRIVATE_BROWSER_WINDOW" }, + }, + ], + tag: "test_toast_notification", + }, + groups: ["panel-test-provider"], + targeting: "!hasActiveEnterprisePolicies", + trigger: { id: "backgroundTaskMessage" }, + frequency: { lifetime: 3 }, + }, + { + id: "MR2022_BACKGROUND_UPDATE_TOAST_NOTIFICATION", + weight: 100, + template: "toast_notification", + content: { + title: { + string_id: "mr2022-background-update-toast-title", + }, + body: { + string_id: "mr2022-background-update-toast-text", + }, + image_url: + "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/673d2808-e5d8-41b9-957e-f60d53233b97.png", + requireInteraction: true, + actions: [ + { + action: "open", + title: { + string_id: "mr2022-background-update-toast-primary-button-label", + }, + }, + { + action: "snooze", + windowsSystemActivationType: true, + title: { + string_id: "mr2022-background-update-toast-secondary-button-label", + }, + }, + ], + tag: "mr2022_background_update", + }, + groups: ["panel-test-provider"], + targeting: "!hasActiveEnterprisePolicies", + trigger: { id: "backgroundTaskMessage" }, + frequency: { lifetime: 3 }, + }, + { + id: "IMPORT_SETTINGS_EMBEDDED", + groups: ["panel-test-provider"], + template: "spotlight", + content: { + template: "multistage", + backdrop: "transparent", + screens: [ + { + id: "IMPORT_SETTINGS_EMBEDDED", + content: { + logo: {}, + tiles: { type: "migration-wizard" }, + progress_bar: true, + migrate_start: { + action: {}, + }, + migrate_close: { + action: { + navigate: true, + }, + }, + secondary_button: { + label: { + string_id: "mr2022-onboarding-secondary-skip-button-label", + }, + action: { + navigate: true, + }, + has_arrow_icon: true, + }, + }, + }, + ], + }, + }, + { + id: "TEST_FEATURE_TOUR", + template: "feature_callout", + groups: [], + content: { + id: "TEST_FEATURE_TOUR", + template: "multistage", + backdrop: "transparent", + transitions: false, + disableHistoryUpdates: true, + screens: [ + { + id: "FEATURE_CALLOUT_1", + anchors: [ + { + selector: "#PanelUI-menu-button", + panel_position: { + anchor_attachment: "bottomcenter", + callout_attachment: "topright", + }, + }, + ], + content: { + position: "callout", + title: { raw: "Panel Feature Callout" }, + subtitle: { raw: "Hello!" }, + secondary_button: { + label: { raw: "Advance" }, + action: { navigate: true }, + }, + submenu_button: { + submenu: [ + { + type: "action", + label: { raw: "Item 1" }, + action: { navigate: true }, + id: "item1", + }, + { + type: "action", + label: { raw: "Item 2" }, + action: { navigate: true }, + id: "item2", + }, + { + type: "menu", + label: { raw: "Menu 1" }, + submenu: [ + { + type: "action", + label: { raw: "Item 3" }, + action: { navigate: true }, + id: "item3", + }, + { + type: "action", + label: { raw: "Item 4" }, + action: { navigate: true }, + id: "item4", + }, + ], + id: "menu1", + }, + ], + attached_to: "secondary_button", + }, + dismiss_button: { + action: { dismiss: true }, + }, + }, + }, + ], + }, + }, +]; + +export const PanelTestProvider = { + getMessages() { + return Promise.resolve( + MESSAGES().map(message => ({ + ...message, + targeting: `providerCohorts.panel_local_testing == "SHOW_TEST"`, + })) + ); + }, +}; diff --git a/browser/components/asrouter/modules/RemoteL10n.sys.mjs b/browser/components/asrouter/modules/RemoteL10n.sys.mjs new file mode 100644 index 0000000000..1df10fbd72 --- /dev/null +++ b/browser/components/asrouter/modules/RemoteL10n.sys.mjs @@ -0,0 +1,249 @@ +/* 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/. */ + +/** + * The downloaded Fluent file is located in this sub-directory of the local + * profile directory. + */ +const USE_REMOTE_L10N_PREF = + "browser.newtabpage.activity-stream.asrouter.useRemoteL10n"; + +/** + * All supported locales for remote l10n + * + * This is used by ASRouter.sys.mjs to check if the locale is supported before + * issuing the request for remote fluent files to RemoteSettings. + * + * Note: + * * this is generated based on "browser/locales/all-locales" as l10n doesn't + * provide an API to fetch that list + * + * * this list doesn't include "en-US", though "en-US" is well supported and + * `_RemoteL10n.isLocaleSupported()` will handle it properly + */ +const ALL_LOCALES = new Set([ + "ach", + "af", + "an", + "ar", + "ast", + "az", + "be", + "bg", + "bn", + "bo", + "br", + "brx", + "bs", + "ca", + "ca-valencia", + "cak", + "ckb", + "cs", + "cy", + "da", + "de", + "dsb", + "el", + "en-CA", + "en-GB", + "eo", + "es-AR", + "es-CL", + "es-ES", + "es-MX", + "et", + "eu", + "fa", + "ff", + "fi", + "fr", + "fy-NL", + "ga-IE", + "gd", + "gl", + "gn", + "gu-IN", + "he", + "hi-IN", + "hr", + "hsb", + "hu", + "hy-AM", + "hye", + "ia", + "id", + "is", + "it", + "ja", + "ja-JP-mac", + "ka", + "kab", + "kk", + "km", + "kn", + "ko", + "lij", + "lo", + "lt", + "ltg", + "lv", + "meh", + "mk", + "mr", + "ms", + "my", + "nb-NO", + "ne-NP", + "nl", + "nn-NO", + "oc", + "pa-IN", + "pl", + "pt-BR", + "pt-PT", + "rm", + "ro", + "ru", + "scn", + "si", + "sk", + "sl", + "son", + "sq", + "sr", + "sv-SE", + "szl", + "ta", + "te", + "th", + "tl", + "tr", + "trs", + "uk", + "ur", + "uz", + "vi", + "wo", + "xh", + "zh-CN", + "zh-TW", +]); + +export class _RemoteL10n { + constructor() { + this._l10n = null; + } + + createElement(doc, elem, options = {}) { + let node; + if (options.content && options.content.string_id) { + node = doc.createElement("remote-text"); + } else { + node = doc.createElementNS("http://www.w3.org/1999/xhtml", elem); + } + if (options.classList) { + node.classList.add(options.classList); + } + this.setString(node, options); + + return node; + } + + // If `string_id` is present it means we are relying on fluent for translations. + // Otherwise, we have a vanilla string. + setString(el, { content, attributes = {} }) { + if (content && content.string_id) { + for (let [fluentId, value] of Object.entries(attributes)) { + el.setAttribute(`fluent-variable-${fluentId}`, value); + } + el.setAttribute("fluent-remote-id", content.string_id); + } else { + el.textContent = content; + } + } + + /** + * Creates a new DOMLocalization instance with the Fluent file from Remote Settings. + * + * Note: it will use the local Fluent file in any of following cases: + * * the remote Fluent file is not available + * * it was told to use the local Fluent file + */ + _createDOML10n() { + /* istanbul ignore next */ + let useRemoteL10n = Services.prefs.getBoolPref(USE_REMOTE_L10N_PREF, true); + if (useRemoteL10n && !L10nRegistry.getInstance().hasSource("cfr")) { + const appLocale = Services.locale.appLocaleAsBCP47; + const l10nFluentDir = PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "settings", + "main", + "ms-language-packs" + ); + let cfrIndexedFileSource = new L10nFileSource( + "cfr", + "app", + [appLocale], + `file://${l10nFluentDir}/`, + { + addResourceOptions: { + allowOverrides: true, + }, + }, + [`file://${l10nFluentDir}/browser/newtab/asrouter.ftl`] + ); + L10nRegistry.getInstance().registerSources([cfrIndexedFileSource]); + } else if (!useRemoteL10n && L10nRegistry.getInstance().hasSource("cfr")) { + L10nRegistry.getInstance().removeSources(["cfr"]); + } + + return new DOMLocalization( + [ + "branding/brand.ftl", + "browser/defaultBrowserNotification.ftl", + "browser/newtab/asrouter.ftl", + "toolkit/branding/accounts.ftl", + "toolkit/branding/brandings.ftl", + ], + false + ); + } + + get l10n() { + if (!this._l10n) { + this._l10n = this._createDOML10n(); + } + return this._l10n; + } + + reloadL10n() { + this._l10n = null; + } + + isLocaleSupported(locale) { + return locale === "en-US" || ALL_LOCALES.has(locale); + } + + /** + * Format given `localizableText`. + * + * Format `localizableText` if it is an object using any `string_id` field, + * otherwise return `localizableText` unmodified. + * + * @param {object|string} `localizableText` to format. + * @return {string} formatted text. + */ + async formatLocalizableText(localizableText) { + if (typeof localizableText !== "string") { + // It's more useful to get an error than passing through an object without + // a `string_id` field. + let value = await this.l10n.formatValue(localizableText.string_id); + return value; + } + return localizableText; + } +} + +export const RemoteL10n = new _RemoteL10n(); diff --git a/browser/components/asrouter/modules/Spotlight.sys.mjs b/browser/components/asrouter/modules/Spotlight.sys.mjs new file mode 100644 index 0000000000..65453a4397 --- /dev/null +++ b/browser/components/asrouter/modules/Spotlight.sys.mjs @@ -0,0 +1,78 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutWelcomeTelemetry: + "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "AWTelemetry", + () => new lazy.AboutWelcomeTelemetry() +); + +export const Spotlight = { + sendUserEventTelemetry(event, message, dispatch) { + const ping = { + message_id: message.content.id, + event, + }; + dispatch({ + type: "SPOTLIGHT_TELEMETRY", + data: { action: "spotlight_user_event", ...ping }, + }); + }, + + defaultDispatch(message) { + if (message.type === "SPOTLIGHT_TELEMETRY") { + const { message_id, event } = message.data; + lazy.AWTelemetry.sendTelemetry({ message_id, event }); + } + }, + + /** + * Shows spotlight tab or window modal specific to the given browser + * @param browser The browser for spotlight display + * @param message Message containing content to show + * @param dispatchCFRAction A function to dispatch resulting actions + * @return boolean value capturing if spotlight was displayed + */ + async showSpotlightDialog(browser, message, dispatch = this.defaultDispatch) { + const win = browser?.ownerGlobal; + if (!win || win.gDialogBox.isOpen) { + return false; + } + const spotlight_url = "chrome://browser/content/spotlight.html"; + + const dispatchCFRAction = + // This also blocks CFR impressions, which is fine for current use cases. + message.content?.metrics === "block" ? () => {} : dispatch; + + // This handles `IMPRESSION` events used by ASRouter for frequency caps. + // AboutWelcome handles `IMPRESSION` events for telemetry. + this.sendUserEventTelemetry("IMPRESSION", message, dispatchCFRAction); + dispatchCFRAction({ type: "IMPRESSION", data: message }); + + if (message.content?.modal === "tab") { + let { closedPromise } = win.gBrowser.getTabDialogBox(browser).open( + spotlight_url, + { + features: "resizable=no", + allowDuplicateDialogs: false, + }, + message.content + ); + await closedPromise; + } else { + await win.gDialogBox.open(spotlight_url, message.content); + } + + // If dismissed report telemetry and exit + this.sendUserEventTelemetry("DISMISS", message, dispatchCFRAction); + return true; + }, +}; diff --git a/browser/components/asrouter/modules/ToastNotification.sys.mjs b/browser/components/asrouter/modules/ToastNotification.sys.mjs new file mode 100644 index 0000000000..136225cf61 --- /dev/null +++ b/browser/components/asrouter/modules/ToastNotification.sys.mjs @@ -0,0 +1,138 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + AlertsService: ["@mozilla.org/alerts-service;1", "nsIAlertsService"], +}); + +export const ToastNotification = { + // Allow testing to stub the alerts service. + get AlertsService() { + return lazy.AlertsService; + }, + + sendUserEventTelemetry(event, message, dispatch) { + const ping = { + message_id: message.id, + event, + }; + dispatch({ + type: "TOAST_NOTIFICATION_TELEMETRY", + data: { action: "toast_notification_user_event", ...ping }, + }); + }, + + /** + * Show a toast notification. + * @param message Message containing content to show. + * @param dispatch A function to dispatch resulting actions. + * @return boolean value capturing if toast notification was displayed. + */ + async showToastNotification(message, dispatch) { + let { content } = message; + let title = await lazy.RemoteL10n.formatLocalizableText(content.title); + let body = await lazy.RemoteL10n.formatLocalizableText(content.body); + + // The only link between background task message experiment and user + // re-engagement via the notification is the associated "tag". Said tag is + // usually controlled by the message content, but for message experiments, + // we want to avoid a missing tag and to ensure a deterministic tag for + // easier analysis, including across branches. + let { tag } = content; + + let experimentMetadata = + lazy.ExperimentAPI.getExperimentMetaData({ + featureId: "backgroundTaskMessage", + }) || {}; + + if ( + experimentMetadata?.active && + experimentMetadata?.slug && + experimentMetadata?.branch?.slug + ) { + // Like `my-experiment:my-branch`. + tag = `${experimentMetadata?.slug}:${experimentMetadata?.branch?.slug}`; + } + + // There are two events named `IMPRESSION` the first one refers to telemetry + // while the other refers to ASRouter impressions used for the frequency cap + this.sendUserEventTelemetry("IMPRESSION", message, dispatch); + dispatch({ type: "IMPRESSION", data: message }); + + let alert = Cc["@mozilla.org/alert-notification;1"].createInstance( + Ci.nsIAlertNotification + ); + let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + alert.init( + tag, + content.image_url + ? Services.urlFormatter.formatURL(content.image_url) + : content.image_url, + title, + body, + true /* aTextClickable */, + content.data, + null /* aDir */, + null /* aLang */, + null /* aData */, + systemPrincipal, + null /* aInPrivateBrowsing */, + content.requireInteraction + ); + + if (content.actions) { + let actions = Cu.cloneInto(content.actions, {}); + for (let action of actions) { + if (action.title) { + action.title = await lazy.RemoteL10n.formatLocalizableText( + action.title + ); + } + if (action.launch_action) { + action.opaqueRelaunchData = JSON.stringify(action.launch_action); + delete action.launch_action; + } + } + alert.actions = actions; + } + + // Populate `opaqueRelaunchData`, prefering `launch_action` if given, + // falling back to `launch_url` if given. + let relaunchAction = content.launch_action; + if (!relaunchAction && content.launch_url) { + relaunchAction = { + type: "OPEN_URL", + data: { + args: content.launch_url, + where: "tab", + }, + }; + } + if (relaunchAction) { + alert.opaqueRelaunchData = JSON.stringify(relaunchAction); + } + + let shownPromise = Promise.withResolvers(); + let obs = (subject, topic, data) => { + if (topic === "alertshow") { + shownPromise.resolve(); + } + }; + + this.AlertsService.showAlert(alert, obs); + + await shownPromise; + + return true; + }, +}; diff --git a/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs b/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs new file mode 100644 index 0000000000..7832ae9456 --- /dev/null +++ b/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs @@ -0,0 +1,308 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EveryWindow: "resource:///modules/EveryWindow.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + requestIdleCallback: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.sys.mjs", +}); + +let notificationsByWindow = new WeakMap(); + +export class _ToolbarBadgeHub { + constructor() { + this.id = "toolbar-badge-hub"; + this.state = {}; + this.prefs = { + WHATSNEW_TOOLBAR_PANEL: "browser.messaging-system.whatsNewPanel.enabled", + }; + this.removeAllNotifications = this.removeAllNotifications.bind(this); + this.removeToolbarNotification = this.removeToolbarNotification.bind(this); + this.addToolbarNotification = this.addToolbarNotification.bind(this); + this.registerBadgeToAllWindows = this.registerBadgeToAllWindows.bind(this); + this._sendPing = this._sendPing.bind(this); + this.sendUserEventTelemetry = this.sendUserEventTelemetry.bind(this); + + this._handleMessageRequest = null; + this._addImpression = null; + this._blockMessageById = null; + this._sendTelemetry = null; + this._initialized = false; + } + + async init( + waitForInitialized, + { + handleMessageRequest, + addImpression, + blockMessageById, + unblockMessageById, + sendTelemetry, + } + ) { + if (this._initialized) { + return; + } + + this._initialized = true; + this._handleMessageRequest = handleMessageRequest; + this._blockMessageById = blockMessageById; + this._unblockMessageById = unblockMessageById; + this._addImpression = addImpression; + this._sendTelemetry = sendTelemetry; + // Need to wait for ASRouter to initialize before trying to fetch messages + await waitForInitialized; + this.messageRequest({ + triggerId: "toolbarBadgeUpdate", + template: "toolbar_badge", + }); + // Listen for pref changes that could trigger new badges + Services.prefs.addObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this); + } + + observe(aSubject, aTopic, aPrefName) { + switch (aPrefName) { + case this.prefs.WHATSNEW_TOOLBAR_PANEL: + this.messageRequest({ + triggerId: "toolbarBadgeUpdate", + template: "toolbar_badge", + }); + break; + } + } + + maybeInsertFTL(win) { + win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); + } + + executeAction({ id, data, message_id }) { + switch (id) { + case "show-whatsnew-button": + lazy.ToolbarPanelHub.enableToolbarButton(); + lazy.ToolbarPanelHub.enableAppmenuButton(); + break; + } + } + + _clearBadgeTimeout() { + if (this.state.showBadgeTimeoutId) { + lazy.clearTimeout(this.state.showBadgeTimeoutId); + } + } + + removeAllNotifications(event) { + if (event) { + // ignore right clicks + if ( + (event.type === "mousedown" || event.type === "click") && + event.button !== 0 + ) { + return; + } + // ignore keyboard access that is not one of the usual accessor keys + if ( + event.type === "keypress" && + event.key !== " " && + event.key !== "Enter" + ) { + return; + } + + event.target.removeEventListener( + "mousedown", + this.removeAllNotifications + ); + event.target.removeEventListener("keypress", this.removeAllNotifications); + // If we have an event it means the user interacted with the badge + // we should send telemetry + if (this.state.notification) { + this.sendUserEventTelemetry("CLICK", this.state.notification); + } + } + // Will call uninit on every window + lazy.EveryWindow.unregisterCallback(this.id); + if (this.state.notification) { + this._blockMessageById(this.state.notification.id); + } + this._clearBadgeTimeout(); + this.state = {}; + } + + removeToolbarNotification(toolbarButton) { + // Remove it from the element that displays the badge + toolbarButton + .querySelector(".toolbarbutton-badge") + .classList.remove("feature-callout"); + toolbarButton.removeAttribute("badged"); + // Remove id used for for aria-label badge description + const notificationDescription = toolbarButton.querySelector( + "#toolbarbutton-notification-description" + ); + if (notificationDescription) { + notificationDescription.remove(); + toolbarButton.removeAttribute("aria-labelledby"); + toolbarButton.removeAttribute("aria-describedby"); + } + } + + addToolbarNotification(win, message) { + const document = win.browser.ownerDocument; + if (message.content.action) { + this.executeAction({ ...message.content.action, message_id: message.id }); + } + let toolbarbutton = document.getElementById(message.content.target); + if (toolbarbutton) { + const badge = toolbarbutton.querySelector(".toolbarbutton-badge"); + badge.classList.add("feature-callout"); + toolbarbutton.setAttribute("badged", true); + // If we have additional aria-label information for the notification + // we add this content to the hidden `toolbarbutton-text` node. + // We then use `aria-labelledby` to link this description to the button + // that received the notification badge. + if (message.content.badgeDescription) { + // Insert strings as soon as we know we're showing them + this.maybeInsertFTL(win); + toolbarbutton.setAttribute( + "aria-labelledby", + `toolbarbutton-notification-description ${message.content.target}` + ); + // Because tooltiptext is different to the label, it gets duplicated as + // the description. Setting `describedby` to the same value as + // `labelledby` will be detected by the a11y code and the description + // will be removed. + toolbarbutton.setAttribute( + "aria-describedby", + `toolbarbutton-notification-description ${message.content.target}` + ); + const descriptionEl = document.createElement("span"); + descriptionEl.setAttribute( + "id", + "toolbarbutton-notification-description" + ); + descriptionEl.hidden = true; + document.l10n.setAttributes( + descriptionEl, + message.content.badgeDescription.string_id + ); + toolbarbutton.appendChild(descriptionEl); + } + // `mousedown` event required because of the `onmousedown` defined on + // the button that prevents `click` events from firing + toolbarbutton.addEventListener("mousedown", this.removeAllNotifications); + // `keypress` event required for keyboard accessibility + toolbarbutton.addEventListener("keypress", this.removeAllNotifications); + this.state = { notification: { id: message.id } }; + + // Impression should be added when the badge becomes visible + this._addImpression(message); + // Send a telemetry ping when adding the notification badge + this.sendUserEventTelemetry("IMPRESSION", message); + + return toolbarbutton; + } + + return null; + } + + registerBadgeToAllWindows(message) { + if (message.template === "update_action") { + this.executeAction({ ...message.content.action, message_id: message.id }); + // No badge to set only an action to execute + return; + } + + lazy.EveryWindow.registerCallback( + this.id, + win => { + if (notificationsByWindow.has(win)) { + // nothing to do + return; + } + const el = this.addToolbarNotification(win, message); + notificationsByWindow.set(win, el); + }, + win => { + const el = notificationsByWindow.get(win); + if (el) { + this.removeToolbarNotification(el); + } + notificationsByWindow.delete(win); + } + ); + } + + registerBadgeNotificationListener(message, options = {}) { + // We need to clear any existing notifications and only show + // the one set by devtools + if (options.force) { + this.removeAllNotifications(); + // When debugging immediately show the badge + this.registerBadgeToAllWindows(message); + return; + } + + if (message.content.delay) { + this.state.showBadgeTimeoutId = lazy.setTimeout(() => { + lazy.requestIdleCallback(() => this.registerBadgeToAllWindows(message)); + }, message.content.delay); + } else { + this.registerBadgeToAllWindows(message); + } + } + + async messageRequest({ triggerId, template }) { + const telemetryObject = { triggerId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + const message = await this._handleMessageRequest({ + triggerId, + template, + }); + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + if (message) { + this.registerBadgeNotificationListener(message); + } + } + + _sendPing(ping) { + this._sendTelemetry({ + type: "TOOLBAR_BADGE_TELEMETRY", + data: { action: "badge_user_event", ...ping }, + }); + } + + sendUserEventTelemetry(event, message) { + const win = Services.wm.getMostRecentWindow("navigator:browser"); + // Only send pings for non private browsing windows + if ( + win && + !lazy.PrivateBrowsingUtils.isBrowserPrivate( + win.ownerGlobal.gBrowser.selectedBrowser + ) + ) { + this._sendPing({ + message_id: message.id, + event, + }); + } + } + + uninit() { + this._clearBadgeTimeout(); + this.state = {}; + this._initialized = false; + notificationsByWindow = new WeakMap(); + Services.prefs.removeObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this); + } +} + +/** + * ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate + * message requests and render messages. + */ +export const ToolbarBadgeHub = new _ToolbarBadgeHub(); diff --git a/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs b/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs new file mode 100644 index 0000000000..519bca8a89 --- /dev/null +++ b/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs @@ -0,0 +1,544 @@ +/* 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/. */ + +const lazy = {}; + +// 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. That environment 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" +); + +ChromeUtils.defineESModuleGetters(lazy, { + EveryWindow: "resource:///modules/EveryWindow.sys.mjs", + PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", + RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "TrackingDBService", + "@mozilla.org/tracking-db-service;1", + "nsITrackingDBService" +); + +const idToTextMap = new Map([ + [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"], + [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"], + [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"], + [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"], + [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"], +]); + +const WHATSNEW_ENABLED_PREF = "browser.messaging-system.whatsNewPanel.enabled"; +const PROTECTIONS_PANEL_INFOMSG_PREF = + "browser.protections_panel.infoMessage.seen"; + +const TOOLBAR_BUTTON_ID = "whats-new-menu-button"; +const APPMENU_BUTTON_ID = "appMenu-whatsnew-button"; + +const BUTTON_STRING_ID = "cfr-whatsnew-button"; +const WHATS_NEW_PANEL_SELECTOR = "PanelUI-whatsNew-message-container"; + +export class _ToolbarPanelHub { + constructor() { + this.triggerId = "whatsNewPanelOpened"; + this._showAppmenuButton = this._showAppmenuButton.bind(this); + this._hideAppmenuButton = this._hideAppmenuButton.bind(this); + this._showToolbarButton = this._showToolbarButton.bind(this); + this._hideToolbarButton = this._hideToolbarButton.bind(this); + + this.state = {}; + this._initialized = false; + } + + async init(waitForInitialized, { getMessages, sendTelemetry }) { + if (this._initialized) { + return; + } + + this._initialized = true; + this._getMessages = getMessages; + this._sendTelemetry = sendTelemetry; + // Wait for ASRouter messages to become available in order to know + // if we can show the What's New panel + await waitForInitialized; + // Enable the application menu button so that the user can access + // the panel outside of the toolbar button + await this.enableAppmenuButton(); + + this.state = { + protectionPanelMessageSeen: Services.prefs.getBoolPref( + PROTECTIONS_PANEL_INFOMSG_PREF, + false + ), + }; + } + + uninit() { + this._initialized = false; + lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID); + lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID); + } + + get messages() { + return this._getMessages({ + template: "whatsnew_panel_message", + triggerId: "whatsNewPanelOpened", + returnAll: true, + }); + } + + toggleWhatsNewPref(event) { + // Checkbox onclick handler gets called before the checkbox state gets toggled, + // so we have to call it with the opposite value. + let newValue = !event.target.checked; + Services.prefs.setBoolPref(WHATSNEW_ENABLED_PREF, newValue); + + this.sendUserEventTelemetry( + event.target.ownerGlobal, + "WNP_PREF_TOGGLE", + // Message id is not applicable in this case, the notification state + // is not related to a particular message + { id: "n/a" }, + { value: { prefValue: newValue } } + ); + } + + maybeInsertFTL(win) { + win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl"); + win.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); + win.MozXULElement.insertFTLIfNeeded("toolkit/branding/accounts.ftl"); + } + + maybeLoadCustomElement(win) { + if (!win.customElements.get("remote-text")) { + Services.scriptloader.loadSubScript( + "resource://activity-stream/data/custom-elements/paragraph.js", + win + ); + } + } + + // Turns on the Appmenu (hamburger menu) button for all open windows and future windows. + async enableAppmenuButton() { + if ((await this.messages).length) { + lazy.EveryWindow.registerCallback( + APPMENU_BUTTON_ID, + this._showAppmenuButton, + this._hideAppmenuButton + ); + } + } + + // Removes the button from the Appmenu. + // Only used in tests. + disableAppmenuButton() { + lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID); + } + + // Turns on the Toolbar button for all open windows and future windows. + async enableToolbarButton() { + if ((await this.messages).length) { + lazy.EveryWindow.registerCallback( + TOOLBAR_BUTTON_ID, + this._showToolbarButton, + this._hideToolbarButton + ); + } + } + + // When the panel is hidden we want to run some cleanup + _onPanelHidden(win) { + const panelContainer = win.document.getElementById( + "customizationui-widget-panel" + ); + // When the panel is hidden we want to remove any toolbar buttons that + // might have been added as an entry point to the panel + const removeToolbarButton = () => { + lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID); + }; + if (!panelContainer) { + return; + } + panelContainer.addEventListener("popuphidden", removeToolbarButton, { + once: true, + }); + } + + // Newer messages first and use `order` field to decide between messages + // with the same timestamp + _sortWhatsNewMessages(m1, m2) { + // Sort by published_date in descending order. + if (m1.content.published_date === m2.content.published_date) { + // Ascending order + return m1.order - m2.order; + } + if (m1.content.published_date > m2.content.published_date) { + return -1; + } + return 1; + } + + // Render what's new messages into the panel. + async renderMessages(win, doc, containerId, options = {}) { + // Set the checked status of the footer checkbox + let value = Services.prefs.getBoolPref(WHATSNEW_ENABLED_PREF); + let checkbox = win.document.getElementById("panelMenu-toggleWhatsNew"); + + checkbox.checked = value; + + this.maybeLoadCustomElement(win); + const messages = + (options.force && options.messages) || + (await this.messages).sort(this._sortWhatsNewMessages); + const container = lazy.PanelMultiView.getViewNode(doc, containerId); + + if (messages) { + // Targeting attribute state might have changed making new messages + // available and old messages invalid, we need to refresh + this.removeMessages(win, containerId); + let previousDate = 0; + // Get and store any variable part of the message content + this.state.contentArguments = await this._contentArguments(); + for (let message of messages) { + container.appendChild( + this._createMessageElements(win, doc, message, previousDate) + ); + previousDate = message.content.published_date; + } + } + + this._onPanelHidden(win); + + // Panel impressions are not associated with one particular message + // but with a set of messages. We concatenate message ids and send them + // back for every impression. + const eventId = { + id: messages + .map(({ id }) => id) + .sort() + .join(","), + }; + // Check `mainview` attribute to determine if the panel is shown as a + // subview (inside the application menu) or as a toolbar dropdown. + // https://searchfox.org/mozilla-central/rev/07f7390618692fa4f2a674a96b9b677df3a13450/browser/components/customizableui/PanelMultiView.jsm#1268 + const mainview = win.PanelUI.whatsNewPanel.hasAttribute("mainview"); + this.sendUserEventTelemetry(win, "IMPRESSION", eventId, { + value: { view: mainview ? "toolbar_dropdown" : "application_menu" }, + }); + } + + removeMessages(win, containerId) { + const doc = win.document; + const messageNodes = lazy.PanelMultiView.getViewNode( + doc, + containerId + ).querySelectorAll(".whatsNew-message"); + for (const messageNode of messageNodes) { + messageNode.remove(); + } + } + + /** + * Dispatch the action defined in the message and user telemetry event. + */ + _dispatchUserAction(win, message) { + let url; + try { + // Set platform specific path variables for SUMO articles + url = Services.urlFormatter.formatURL(message.content.cta_url); + } catch (e) { + console.error(e); + url = message.content.cta_url; + } + lazy.SpecialMessageActions.handleAction( + { + type: message.content.cta_type, + data: { + args: url, + where: message.content.cta_where || "tabshifted", + }, + }, + win.browser + ); + + this.sendUserEventTelemetry(win, "CLICK", message); + } + + /** + * Attach event listener to dispatch message defined action. + */ + _attachCommandListener(win, element, message) { + // Add event listener for `mouseup` not to overlap with the + // `mousedown` & `click` events dispatched from PanelMultiView.sys.mjs + // https://searchfox.org/mozilla-central/rev/7531325c8660cfa61bf71725f83501028178cbb9/browser/components/customizableui/PanelMultiView.jsm#1830-1837 + element.addEventListener("mouseup", () => { + this._dispatchUserAction(win, message); + }); + element.addEventListener("keyup", e => { + if (e.key === "Enter" || e.key === " ") { + this._dispatchUserAction(win, message); + } + }); + } + + _createMessageElements(win, doc, message, previousDate) { + const { content } = message; + const messageEl = lazy.RemoteL10n.createElement(doc, "div"); + messageEl.classList.add("whatsNew-message"); + + // Only render date if it is different from the one rendered before. + if (content.published_date !== previousDate) { + messageEl.appendChild( + lazy.RemoteL10n.createElement(doc, "p", { + classList: "whatsNew-message-date", + content: new Date(content.published_date).toLocaleDateString( + "default", + { + month: "long", + day: "numeric", + year: "numeric", + } + ), + }) + ); + } + + const wrapperEl = lazy.RemoteL10n.createElement(doc, "div"); + wrapperEl.doCommand = () => this._dispatchUserAction(win, message); + wrapperEl.classList.add("whatsNew-message-body"); + messageEl.appendChild(wrapperEl); + + if (content.icon_url) { + wrapperEl.classList.add("has-icon"); + const iconEl = lazy.RemoteL10n.createElement(doc, "img"); + iconEl.src = content.icon_url; + iconEl.classList.add("whatsNew-message-icon"); + if (content.icon_alt && content.icon_alt.string_id) { + doc.l10n.setAttributes(iconEl, content.icon_alt.string_id); + } else { + iconEl.setAttribute("alt", content.icon_alt); + } + wrapperEl.appendChild(iconEl); + } + + wrapperEl.appendChild(this._createMessageContent(win, doc, content)); + + if (content.link_text) { + const anchorEl = lazy.RemoteL10n.createElement(doc, "a", { + classList: "text-link", + content: content.link_text, + }); + anchorEl.doCommand = () => this._dispatchUserAction(win, message); + wrapperEl.appendChild(anchorEl); + } + + // Attach event listener on entire message container + this._attachCommandListener(win, messageEl, message); + + return messageEl; + } + + /** + * Return message title (optional subtitle) and body + */ + _createMessageContent(win, doc, content) { + const wrapperEl = new win.DocumentFragment(); + + wrapperEl.appendChild( + lazy.RemoteL10n.createElement(doc, "h2", { + classList: "whatsNew-message-title", + content: content.title, + attributes: this.state.contentArguments, + }) + ); + + wrapperEl.appendChild( + lazy.RemoteL10n.createElement(doc, "p", { + content: content.body, + classList: "whatsNew-message-content", + attributes: this.state.contentArguments, + }) + ); + + return wrapperEl; + } + + _createHeroElement(win, doc, message) { + this.maybeLoadCustomElement(win); + + const messageEl = lazy.RemoteL10n.createElement(doc, "div"); + messageEl.setAttribute("id", "protections-popup-message"); + messageEl.classList.add("whatsNew-hero-message"); + const wrapperEl = lazy.RemoteL10n.createElement(doc, "div"); + wrapperEl.classList.add("whatsNew-message-body"); + messageEl.appendChild(wrapperEl); + + wrapperEl.appendChild( + lazy.RemoteL10n.createElement(doc, "h2", { + classList: "whatsNew-message-title", + content: message.content.title, + }) + ); + wrapperEl.appendChild( + lazy.RemoteL10n.createElement(doc, "p", { + classList: "protections-popup-content", + content: message.content.body, + }) + ); + + if (message.content.link_text) { + let linkEl = lazy.RemoteL10n.createElement(doc, "a", { + classList: "text-link", + content: message.content.link_text, + }); + linkEl.disabled = true; + wrapperEl.appendChild(linkEl); + this._attachCommandListener(win, linkEl, message); + } else { + this._attachCommandListener(win, wrapperEl, message); + } + + return messageEl; + } + + async _contentArguments() { + const { defaultEngine } = Services.search; + // Between now and 6 weeks ago + const dateTo = new Date(); + const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000); + const eventsByDate = await lazy.TrackingDBService.getEventsByDateRange( + dateFrom, + dateTo + ); + // Make sure we set all types of possible values to 0 because they might + // be referenced by fluent strings + let totalEvents = { blockedCount: 0 }; + for (let blockedType of idToTextMap.values()) { + totalEvents[blockedType] = 0; + } + // Count all events in the past 6 weeks. Returns an object with: + // `blockedCount` total number of blocked resources + // {tracker|cookie|social...} breakdown by event type as defined by `idToTextMap` + totalEvents = eventsByDate.reduce((acc, day) => { + const type = day.getResultByName("type"); + const count = day.getResultByName("count"); + acc[idToTextMap.get(type)] = (acc[idToTextMap.get(type)] || 0) + count; + acc.blockedCount += count; + return acc; + }, totalEvents); + return { + // Keys need to match variable names used in asrouter.ftl + // `earliestDate` will be either 6 weeks ago or when tracking recording + // started. Whichever is more recent. + earliestDate: Math.max( + new Date(await lazy.TrackingDBService.getEarliestRecordedDate()), + dateFrom + ), + ...totalEvents, + // Passing in `undefined` as string for the Fluent variable name + // in order to match and select the message that does not require + // the variable. + searchEngineName: defaultEngine ? defaultEngine.name : "undefined", + }; + } + + async _showAppmenuButton(win) { + this.maybeInsertFTL(win); + await this._showElement( + win.browser.ownerDocument, + APPMENU_BUTTON_ID, + BUTTON_STRING_ID + ); + } + + _hideAppmenuButton(win, windowClosed) { + // No need to do something if the window is going away + if (!windowClosed) { + this._hideElement(win.browser.ownerDocument, APPMENU_BUTTON_ID); + } + } + + _showToolbarButton(win) { + const document = win.browser.ownerDocument; + this.maybeInsertFTL(win); + return this._showElement(document, TOOLBAR_BUTTON_ID, BUTTON_STRING_ID); + } + + _hideToolbarButton(win) { + this._hideElement(win.browser.ownerDocument, TOOLBAR_BUTTON_ID); + } + + _showElement(document, id, string_id) { + const el = lazy.PanelMultiView.getViewNode(document, id); + document.l10n.setAttributes(el, string_id); + el.hidden = false; + } + + _hideElement(document, id) { + const el = lazy.PanelMultiView.getViewNode(document, id); + if (el) { + el.hidden = true; + } + } + + _sendPing(ping) { + this._sendTelemetry({ + type: "TOOLBAR_PANEL_TELEMETRY", + data: { action: "whats-new-panel_user_event", ...ping }, + }); + } + + sendUserEventTelemetry(win, event, message, options = {}) { + // Only send pings for non private browsing windows + if ( + win && + !lazy.PrivateBrowsingUtils.isBrowserPrivate( + win.ownerGlobal.gBrowser.selectedBrowser + ) + ) { + this._sendPing({ + message_id: message.id, + event, + event_context: options.value, + }); + } + } + + /** + * @param {object} [browser] MessageChannel target argument as a response to a + * user action. No message is shown if undefined. + * @param {object[]} messages Messages selected from devtools page + */ + forceShowMessage(browser, messages) { + if (!browser) { + return; + } + const win = browser.ownerGlobal; + const doc = browser.ownerDocument; + this.removeMessages(win, WHATS_NEW_PANEL_SELECTOR); + this.renderMessages(win, doc, WHATS_NEW_PANEL_SELECTOR, { + force: true, + messages: Array.isArray(messages) ? messages : [messages], + }); + win.PanelUI.panel.addEventListener("popuphidden", event => + this.removeMessages(event.target.ownerGlobal, WHATS_NEW_PANEL_SELECTOR) + ); + } +} + +/** + * ToolbarPanelHub - singleton instance of _ToolbarPanelHub that can initiate + * message requests and render messages. + */ +export const ToolbarPanelHub = new _ToolbarPanelHub(); -- cgit v1.2.3