diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/newtab/lib/ASRouter.jsm | 2096 |
1 files changed, 2096 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/ASRouter.jsm b/browser/components/newtab/lib/ASRouter.jsm new file mode 100644 index 0000000000..964fa1f011 --- /dev/null +++ b/browser/components/newtab/lib/ASRouter.jsm @@ -0,0 +1,2096 @@ +/* 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/. */ +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const lazy = {}; +XPCOMUtils.defineLazyModuleGetters(lazy, { + SnippetsTestMessageProvider: + "resource://activity-stream/lib/SnippetsTestMessageProvider.jsm", + PanelTestProvider: "resource://activity-stream/lib/PanelTestProvider.jsm", + Spotlight: "resource://activity-stream/lib/Spotlight.jsm", + ToastNotification: "resource://activity-stream/lib/ToastNotification.jsm", + ToolbarBadgeHub: "resource://activity-stream/lib/ToolbarBadgeHub.jsm", + ToolbarPanelHub: "resource://activity-stream/lib/ToolbarPanelHub.jsm", + MomentsPageHub: "resource://activity-stream/lib/MomentsPageHub.jsm", + InfoBar: "resource://activity-stream/lib/InfoBar.jsm", + ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm", + ASRouterPreferences: "resource://activity-stream/lib/ASRouterPreferences.jsm", + TARGETING_PREFERENCES: + "resource://activity-stream/lib/ASRouterPreferences.jsm", + ASRouterTriggerListeners: + "resource://activity-stream/lib/ASRouterTriggerListeners.jsm", + KintoHttpClient: "resource://services-common/kinto-http-client.js", + Downloader: "resource://services-settings/Attachments.jsm", + RemoteImages: "resource://activity-stream/lib/RemoteImages.jsm", + RemoteL10n: "resource://activity-stream/lib/RemoteL10n.jsm", + ExperimentAPI: "resource://nimbus/ExperimentAPI.jsm", + setTimeout: "resource://gre/modules/Timer.jsm", + NimbusFeatures: "resource://nimbus/ExperimentAPI.jsm", + SpecialMessageActions: + "resource://messaging-system/lib/SpecialMessageActions.jsm", + TargetingContext: "resource://messaging-system/targeting/Targeting.jsm", + Utils: "resource://services-settings/Utils.jsm", + MacAttribution: "resource:///modules/MacAttribution.jsm", +}); +XPCOMUtils.defineLazyServiceGetters(lazy, { + BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"], +}); +const { actionCreators: ac } = ChromeUtils.importESModule( + "resource://activity-stream/common/Actions.sys.mjs" +); + +const { CFRMessageProvider } = ChromeUtils.import( + "resource://activity-stream/lib/CFRMessageProvider.jsm" +); +const { OnboardingMessageProvider } = ChromeUtils.import( + "resource://activity-stream/lib/OnboardingMessageProvider.jsm" +); +const { RemoteSettings } = ChromeUtils.import( + "resource://services-settings/remote-settings.js" +); +const { CFRPageActions } = ChromeUtils.import( + "resource://activity-stream/lib/CFRPageActions.jsm" +); +const { AttributionCode } = ChromeUtils.import( + "resource:///modules/AttributionCode.jsm" +); + +// 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", + "snippets-admin.mozilla.org": "preview", +}; +const SNIPPETS_ENDPOINT_ALLOWLIST = + "browser.newtab.activity-stream.asrouter.allowHosts"; +// 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(["snippets"]); + +// To observe the app locale change notification. +const TOPIC_INTL_LOCALE_CHANGED = "intl:app-locales-changed"; +const TOPIC_EXPERIMENT_FORCE_ENROLLED = "nimbus:force-enroll"; +// 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"; + +const MESSAGING_EXPERIMENTS_DEFAULT_FEATURES = [ + "cfr", + "fxms-message-1", + "fxms-message-2", + "fxms-message-3", + "infobar", + "moments-page", + "pbNewtab", + "spotlight", +]; + +// 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"]; +const REACH_EVENT_CATEGORY = "messaging_experiments"; +const REACH_EVENT_METHOD = "reach"; + +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<object[]>} 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<object[]>} 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) { + let featureAPI = lazy.NimbusFeatures[featureId]; + let 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; + } + + let message = featureAPI.getAllVariables(); + + if (message?.id) { + // Cache the Nimbus feature ID on the message because there is not a 1-1 + // correspondance between templates and features. This is used when + // recording expose events (see |sendTriggerMessage|). + message._nimbusFeature = featureId; + experiments.push(message); + } + + if (!REACH_EVENT_GROUPS.includes(featureId)) { + continue; + } + + // If we are in a rollout, we do not have sibling branches. + if (experimentData) { + // 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 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 ( + branch.slug !== experimentData.branch.slug && + branchValue?.trigger + ) { + experiments.push({ + forReachEvent: { sent: false, group: featureId }, + experimentSlug: experimentData.slug, + branchSlug: branch.slug, + ...branchValue, + }); + } + } + } + } + + 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. + */ +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: {}, + 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._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._onExperimentForceEnrolled = this._onExperimentForceEnrolled.bind( + this + ); + this.forcePBWindow = this.forcePBWindow.bind(this); + Services.telemetry.setEventRecordingEnabled(REACH_EVENT_CATEGORY, true); + } + + 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); + } + } + + { + // If the feature IDs of the messaging-experiments provider has changed, + // then we need to update which features for which we are listening to + // changes. + const prevExpts = previousProviders.find( + p => p.id === "messaging-experiments" + ); + const expts = providers.find(p => p.id === "messaging-experiments"); + + this._onFeatureListChanged( + prevExpts?.enabled ? prevExpts.featureIds : [], + expts?.enabled ? expts.featureIds : [] + ); + } + + 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) { + // preview snippets are never excluded + if (message.provider === "preview") { + return false; + } + 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(); + } + + // We don't want to cache preview endpoints, remove them after messages are fetched + await this.setState(this._removePreviewEndpoint(newState)); + await this.cleanupImpressions(); + } + return this.state; + } + + 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._loadSnippetsAllowHosts(); + this.clearChildMessages = this.toWaitForInitFunc(clearChildMessages); + this.clearChildProviders = this.toWaitForInitFunc(clearChildProviders); + // NOTE: This is only necessary to sync devtools and snippets 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 previousSessionEnd = + (await this._storage.get("previousSessionEnd")) || 0; + + await this.setState({ + messageBlockList, + groupImpressions, + messageImpressions, + 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._onExperimentForceEnrolled, + TOPIC_EXPERIMENT_FORCE_ENROLLED + ); + 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._onExperimentForceEnrolled, + TOPIC_EXPERIMENT_FORCE_ENROLLED + ); + 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, + })); + } + + 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, + SnippetsTestMessageProvider: lazy.SnippetsTestMessageProvider, + PanelTestProvider: lazy.PanelTestProvider, + }; + } + } + + /** + * Used by ASRouter Admin returns all ASRouterTargeting.Environment + * and ASRouter._getMessagesContext parameters and values + */ + async getTargetingParameters(environment, localContext) { + const targetingParameters = {}; + for (const param of Object.keys(environment)) { + targetingParameters[param] = await environment[param]; + } + for (const param of Object.keys(localContext)) { + targetingParameters[param] = await localContext[param]; + } + + 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 } = this.state; + + return { + get messageImpressions() { + return messageImpressions; + }, + get previousSessionEnd() { + return previousSessionEnd; + }, + }; + } + + 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: {} }; + } + + 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 "toast_notification": + lazy.ToastNotification.showToastNotification( + message, + this.dispatchCFRAction + ); + break; + } + + return { message }; + } + + 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] + ? [...impressions[item.id]] + : []; + impressions[item.id].push(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 (or `campaign`s for snippets) 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, + })); + } + + _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; + } + } + + // Ensure we switch to the Onboarding message after RTAMO addon was installed + _updateOnboardingState() { + let addonInstallObs = (subject, topic) => { + Services.obs.removeObserver( + addonInstallObs, + "webextension-install-notify" + ); + }; + Services.obs.addObserver(addonInstallObs, "webextension-install-notify"); + } + + _loadSnippetsAllowHosts() { + let additionalHosts = []; + const allowPrefValue = Services.prefs.getStringPref( + SNIPPETS_ENDPOINT_ALLOWLIST, + "" + ); + try { + additionalHosts = JSON.parse(allowPrefValue); + } catch (e) { + if (allowPrefValue) { + console.error( + `Pref ${SNIPPETS_ENDPOINT_ALLOWLIST} value is not valid JSON` + ); + } + } + + if (!additionalHosts.length) { + return DEFAULT_ALLOWLIST_HOSTS; + } + + // If there are additional hosts we want to allow, add them as + // `preview` so that the updateCycle is 0 + return additionalHosts.reduce( + (allow_hosts, host) => { + allow_hosts[host] = "preview"; + Services.console.logStringMessage( + `Adding ${host} to list of allowed hosts.` + ); + return allow_hosts; + }, + { ...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 }); + } + + _removePreviewEndpoint(state) { + state.providers = state.providers.filter(p => p.id !== "preview"); + return state; + } + + addPreviewEndpoint(url, browser) { + const providers = [...this.state.providers]; + if ( + this._validPreviewEndpoint(url) && + !providers.find(p => p.url === url) + ) { + // When you view a preview snippet we want to hide all real content - + // sending EnterSnippetsPreviewMode puts this browser tab in that state. + browser.sendMessageToActor("EnterSnippetsPreviewMode", {}, "ASRouter"); + providers.push({ + id: "preview", + type: "remote", + enabled: true, + url, + updateCycleInMs: 0, + }); + return this.setState({ providers }); + } + return Promise.resolve(); + } + + /** + * 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 = AttributionCode.allowedCodeKeys + .map(key => `${key}=${encodeURIComponent(data[key] || "")}`) + .join("&"); + if (AppConstants.platform === "win") { + // The whole attribution data is encoded (again) for windows + await AttributionCode.writeAttributionFile( + encodeURIComponent(attributionData) + ); + } else if (AppConstants.platform === "macosx") { + let appPath = lazy.MacAttribution.applicationPath; + let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService( + Ci.nsIMacAttributionService + ); + + // The attribution data is treated as a url query for mac + let referrer = `https://www.mozilla.org/anything/?${attributionData}`; + + // This sets the Attribution to be the referrer + attributionSvc.setReferrerUrl(appPath, referrer, true); + + // Delete attribution data file + await AttributionCode.deleteFileAsync(); + } + + // 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 + AttributionCode._clearCache(); + await 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" }, + }; + 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 }; + } + + async sendNewTabMessage({ endpoint, tabId, browser }) { + let message; + + // Load preview endpoint for snippets if one is sent + if (endpoint) { + await this.addPreviewEndpoint(endpoint.url, browser); + } + + // Load all messages + await this.loadMessagesFromAllProviders(); + + if (endpoint) { + message = await this.handleMessageRequest({ provider: "preview" }); + + // We don't want to cache preview messages, remove them after we selected the message to show + if (message) { + await this.setState(state => ({ + messages: state.messages.filter(m => m.id !== message.id), + })); + } + } else { + const telemetryObject = { tabId }; + TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + message = await this.handleMessageRequest({ provider: "snippets" }); + TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject); + } + + return this.routeCFRMessage(message, browser, undefined, false); + } + + _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 + ); + } + + async sendTriggerMessage({ tabId, browser, ...trigger }) { + 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 _onExperimentForceEnrolled(subject, topic, slug) { + const experimentProvider = this.state.providers.find( + p => p.id === "messaging-experiments" + ); + if (!experimentProvider.enabled) { + return; + } + + const branch = lazy.ExperimentAPI.getActiveBranch({ slug }); + const features = branch.features ?? [branch.feature]; + const featureIds = features.map(feature => feature.featureId); + + this._onFeaturesUpdated(...featureIds); + + await this.loadMessagesFromAllProviders([experimentProvider]); + } + + /** + * Handle a change to the list of featureIds that the messaging-experiments + * provider is watching. + * + * This normally occurs when ASRouter update message providers, which happens + * every startup and when the messaging-experiment provider pref changes. + * + * On startup, |oldFeatures| will be an empty array and we will subscribe to + * everything in |newFeatures|. + * + * When the pref changes, we unsubscribe from |oldFeatures - newFeatures| and + * subscribe to |newFeatures - oldFeatures|. Features that are listed in both + * sets do not have their subscription status changed. Pref changes are mostly + * during unit tests. + * + * @param {string[]} oldFeatures The list of feature IDs we were previously + * listening to for new experiments. + * @param {string[]} newFeatures The list of feature IDs we are now listening + * to for new experiments. + */ + _onFeatureListChanged(oldFeatures, newFeatures) { + for (const featureId of oldFeatures) { + if (!newFeatures.includes(featureId)) { + const listener = this._experimentChangedListeners.get(featureId); + this._experimentChangedListeners.delete(featureId); + lazy.NimbusFeatures[featureId].off(listener); + } + } + + const newlySubscribed = []; + + for (const featureId of newFeatures) { + if (!oldFeatures.includes(featureId)) { + const listener = () => this._onFeaturesUpdated(featureId); + this._experimentChangedListeners.set(featureId, listener); + lazy.NimbusFeatures[featureId].onUpdate(listener); + + newlySubscribed.push(featureId); + } + } + + // Check for any messages present in the newly subscribed to Nimbus features + // so we can prefetch their remote images (if any). + this._onFeaturesUpdated(...newlySubscribed); + } + + /** + * Handle updated experiment features. + * + * If there are messages for the feature, RemoteImages will prefetch any + * images. + * + * @param {string[]} featureIds The feature IDs that have been updated. + */ + _onFeaturesUpdated(...featureIds) { + const messages = []; + + for (const featureId of featureIds) { + const featureAPI = lazy.NimbusFeatures[featureId]; + // If there is no active experiment for the feature, this will return + // null. + if (lazy.ExperimentAPI.getExperimentMetaData({ featureId })) { + // Otherwise, getAllVariables() will return the JSON blob for the + // message. + messages.push(featureAPI.getAllVariables()); + } + } + + // We are not awaiting this because we want these images to load in the + // background. + if (messages.length) { + lazy.RemoteImages.prefetchImagesFor(messages); + } + } + + 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. + */ +const ASRouter = new _ASRouter(); + +const EXPORTED_SYMBOLS = ["_ASRouter", "ASRouter", "MessageLoaderUtils"]; |