Source code
Revision control
Copy as Markdown
Other Tools
/* 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
// 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(
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
MESSAGE_TYPE_HASH: "resource:///modules/asrouter/ActorConstants.mjs",
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",
BookmarksBarButton: "resource:///modules/asrouter/BookmarksBarButton.sys.mjs",
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
FeatureCalloutBroker:
"resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs",
InfoBar: "resource:///modules/asrouter/InfoBar.sys.mjs",
MacAttribution: "resource:///modules/MacAttribution.sys.mjs",
MenuMessage: "resource:///modules/asrouter/MenuMessage.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",
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",
});
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 { 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";
const NO_REACH_EVENT_GROUPS = ["pbNewtab"];
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<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) {
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 (
NO_REACH_EVENT_GROUPS.includes(featureId) ||
!MESSAGING_EXPERIMENTS_DEFAULT_FEATURES.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) {
dispatchCFRAction?.({
type: lazy.MESSAGE_TYPE_HASH.AS_ROUTER_TELEMETRY_USER_EVENT,
data: {
action: "asrouter_undesired_event",
message_id: "n/a",
event,
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,
};
// Render local messages with experiment l10n structure if devtools
// are enabled. This is not a production feature, since local messages
// do not use experiment localization, and experimental messages are
// translated in ExperimentAPI.sys.mjs. This is useful for development
// to allow quickly testing experimental messages without needing to
// manually convert all the $l10n objects to strings. We lock this
// behind the devtools because it requires recursively processing
// every message at least once, for a small performance hit.
if (
provider.type === "local" &&
lazy.ASRouterPreferences.devtoolsEnabled
) {
try {
return this._delocalizeValues(message);
} catch (e) {
lazy.log.error(
`Failed to delocalize message ${message.id}:`,
e.message,
e.cause
);
}
}
return message;
})
.filter(message => message.weight > 0),
lastUpdated,
errors: MessageLoaderUtils.errors,
};
},
/**
* For a given input (e.g. a message or a property), search for $l10n
* properties and flatten them to just their `text` property. This is done so
* that a message set up for experiment localization can be tested locally.
* Without this, the messaging surface would not be able to read the message
* because all the localized copy would be in $l10n objects. Normally, these
* objects are translated by ExperimentFeature.substituteLocalizations. Rather
* than returning $l10n.text, it would return localizations[$l10n.id] for the
* active language. Localizations are included in the recipe, not in the
* message, so we can't actually translate the message. But every $l10n object
* should have a `text` property with the original English copy. So you can
* copy a message straight from the recipe into a local message provider, and
* it should render the English version with no issues.
*
* @param {object} values An object to delocalize
* @returns {object} The object, stripped of any $l10n objects
*/
_delocalizeValues(values) {
if (typeof values !== "object" || values === null) {
return values;
}
if (Array.isArray(values)) {
return values.map(value => this._delocalizeValues(value));
}
const substituted = Object.assign({}, values);
for (const [key, value] of Object.entries(values)) {
if (key === "$l10n") {
if (typeof value === "object" && value !== null) {
if (value?.text) {
return value.text;
}
throw new Error(`Expected $l10n to have a text property, but got`, {
cause: value,
});
}
throw new Error(`Expected $l10n to be an object, but got`, {
cause: value,
});
}
substituted[key] = this._delocalizeValues(value);
}
return substituted;
},
/**
* 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._onExperimentEnrollmentsUpdated =
this._onExperimentEnrollmentsUpdated.bind(this);
this.forcePBWindow = this.forcePBWindow.bind(this);
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 (provider.id === "message-groups") {
// Message groups are handled separately by loadAllMessageGroups
continue;
}
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() {
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.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.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?.({
type: lazy.MESSAGE_TYPE_HASH.AS_ROUTER_TELEMETRY_USER_EVENT,
data: {
action: "asrouter_undesired_event",
message_id: message.id,
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;
}
_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 "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;
case "bookmarks_bar_button":
lazy.BookmarksBarButton.showBookmarksBarButton(browser, message);
break;
case "menu_message":
lazy.MenuMessage.showMenuMessage(browser, message, trigger, force);
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(() => ({
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<unknown>} 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
* @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;
// Keeping parity with legacy event telemetry values that only accepted
// underscores in featureID passed to event telemetry.
// Glean expects the metric name in camelCase.
const name = messageGroup
.replace(/-/g, "_")
.split("_")
.map(word => word[0].toUpperCase() + word.slice(1))
.join("");
const extra = {
value: message.experimentSlug,
branches: message.branchSlug,
};
Glean.messagingExperiments[`reach${name}`].record(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<object>}
* @resolves {message} an object with the routed message
*/
async sendTriggerMessage(
{ tabId, browser, ...trigger },
skipLoadingMessages = false
) {
if (!skipLoadingMessages) {
await this.loadMessagesFromAllProviders();
}
// Implement the global `browserIsSelected` context property.
if (trigger && browser?.constructor.name === "MozBrowser") {
if (!Object.prototype.hasOwnProperty.call(trigger, "context")) {
trigger.context = {};
}
if (typeof trigger.context === "object") {
trigger.context.browserIsSelected =
trigger.context.browserIsSelected ||
browser === browser.ownerGlobal.gBrowser?.selectedBrowser;
}
}
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 _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();