fune/browser/components/newtab/lib/ASRouter.jsm
Cristian Tuns b3bf09cc0d Backed out 6 changesets (bug 1816934, bug 1817182, bug 1817179, bug 1817183) for causing dt failures in browser_jsterm_autocomplete_null.js CLOSED TREE
Backed out changeset 17d4c013ed92 (bug 1817183)
Backed out changeset cfed8d9c23f3 (bug 1817183)
Backed out changeset 62fe2f589efe (bug 1817182)
Backed out changeset 557bd773fb85 (bug 1817179)
Backed out changeset 7f8a7865868b (bug 1816934)
Backed out changeset d6c1d4c0d2a0 (bug 1816934)
2023-02-17 10:51:33 -05:00

2100 lines
68 KiB
JavaScript

/* 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",
});
ChromeUtils.defineESModuleGetters(lazy, {
MacAttribution: "resource:///modules/MacAttribution.sys.mjs",
});
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.importESModule(
"resource:///modules/AttributionCode.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",
"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",
"fxms-message-4",
"fxms-message-5",
"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"];