summaryrefslogtreecommitdiffstats
path: root/browser/components/asrouter/modules
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /browser/components/asrouter/modules
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/asrouter/modules')
-rw-r--r--browser/components/asrouter/modules/ASRouter.sys.mjs2079
-rw-r--r--browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs64
-rw-r--r--browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs117
-rw-r--r--browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs171
-rw-r--r--browser/components/asrouter/modules/ASRouterPreferences.sys.mjs241
-rw-r--r--browser/components/asrouter/modules/ASRouterTargeting.sys.mjs1308
-rw-r--r--browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs1439
-rw-r--r--browser/components/asrouter/modules/ActorConstants.sys.mjs49
-rw-r--r--browser/components/asrouter/modules/CFRMessageProvider.sys.mjs820
-rw-r--r--browser/components/asrouter/modules/CFRPageActions.sys.mjs1086
-rw-r--r--browser/components/asrouter/modules/FeatureCallout.sys.mjs2100
-rw-r--r--browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs215
-rw-r--r--browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs1299
-rw-r--r--browser/components/asrouter/modules/InfoBar.sys.mjs169
-rw-r--r--browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs37
-rw-r--r--browser/components/asrouter/modules/MomentsPageHub.sys.mjs171
-rw-r--r--browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs1414
-rw-r--r--browser/components/asrouter/modules/PageEventManager.sys.mjs135
-rw-r--r--browser/components/asrouter/modules/PanelTestProvider.sys.mjs771
-rw-r--r--browser/components/asrouter/modules/RemoteL10n.sys.mjs249
-rw-r--r--browser/components/asrouter/modules/Spotlight.sys.mjs78
-rw-r--r--browser/components/asrouter/modules/ToastNotification.sys.mjs138
-rw-r--r--browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs308
-rw-r--r--browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs544
24 files changed, 15002 insertions, 0 deletions
diff --git a/browser/components/asrouter/modules/ASRouter.sys.mjs b/browser/components/asrouter/modules/ASRouter.sys.mjs
new file mode 100644
index 0000000000..f6657a39b9
--- /dev/null
+++ b/browser/components/asrouter/modules/ASRouter.sys.mjs
@@ -0,0 +1,2079 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// We use importESModule here instead of static import so that
+// the Karma test environment won't choke on this module. This
+// is because the Karma test environment already stubs out
+// XPCOMUtils, AppConstants and RemoteSettings, and overrides
+// importESModule to be a no-op (which can't be done for a static import
+// statement).
+
+// eslint-disable-next-line mozilla/use-static-import
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+// eslint-disable-next-line mozilla/use-static-import
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+// eslint-disable-next-line mozilla/use-static-import
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ASRouterPreferences:
+ "resource:///modules/asrouter/ASRouterPreferences.sys.mjs",
+ ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
+ ASRouterTriggerListeners:
+ "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs",
+ AttributionCode: "resource:///modules/AttributionCode.sys.mjs",
+ Downloader: "resource://services-settings/Attachments.sys.mjs",
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ FeatureCalloutBroker:
+ "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs",
+ InfoBar: "resource:///modules/asrouter/InfoBar.sys.mjs",
+ KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs",
+ MacAttribution: "resource:///modules/MacAttribution.sys.mjs",
+ MomentsPageHub: "resource:///modules/asrouter/MomentsPageHub.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ PanelTestProvider: "resource:///modules/asrouter/PanelTestProvider.sys.mjs",
+ RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs",
+ SpecialMessageActions:
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
+ TARGETING_PREFERENCES:
+ "resource:///modules/asrouter/ASRouterPreferences.sys.mjs",
+ Utils: "resource://services-settings/Utils.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ Spotlight: "resource:///modules/asrouter/Spotlight.sys.mjs",
+ ToastNotification: "resource:///modules/asrouter/ToastNotification.sys.mjs",
+ ToolbarBadgeHub: "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs",
+ ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"],
+});
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ const { Logger } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/Logger.sys.mjs"
+ );
+ return new Logger("ASRouter");
+});
+import { actionCreators as ac } from "resource://activity-stream/common/Actions.sys.mjs";
+import { MESSAGING_EXPERIMENTS_DEFAULT_FEATURES } from "resource:///modules/asrouter/MessagingExperimentConstants.sys.mjs";
+import { CFRMessageProvider } from "resource:///modules/asrouter/CFRMessageProvider.sys.mjs";
+import { OnboardingMessageProvider } from "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs";
+import { CFRPageActions } from "resource:///modules/asrouter/CFRPageActions.sys.mjs";
+
+// List of hosts for endpoints that serve router messages.
+// Key is allowed host, value is a name for the endpoint host.
+const DEFAULT_ALLOWLIST_HOSTS = {
+ "activity-stream-icons.services.mozilla.com": "production",
+};
+// Max possible impressions cap for any message
+const MAX_MESSAGE_LIFETIME_CAP = 100;
+
+const LOCAL_MESSAGE_PROVIDERS = {
+ OnboardingMessageProvider,
+ CFRMessageProvider,
+};
+const STARTPAGE_VERSION = "6";
+
+// Remote Settings
+const RS_MAIN_BUCKET = "main";
+const RS_COLLECTION_L10N = "ms-language-packs"; // "ms" stands for Messaging System
+const RS_PROVIDERS_WITH_L10N = ["cfr"];
+const RS_FLUENT_VERSION = "v1";
+const RS_FLUENT_RECORD_PREFIX = `cfr-${RS_FLUENT_VERSION}`;
+const RS_DOWNLOAD_MAX_RETRIES = 2;
+// This is the list of providers for which we want to cache the targeting
+// expression result and reuse between calls. Cache duration is defined in
+// ASRouterTargeting where evaluation takes place.
+const JEXL_PROVIDER_CACHE = new Set();
+
+// To observe the app locale change notification.
+const TOPIC_INTL_LOCALE_CHANGED = "intl:app-locales-changed";
+const TOPIC_EXPERIMENT_ENROLLMENT_CHANGED = "nimbus:enrollments-updated";
+// To observe the pref that controls if ASRouter should use the remote Fluent files for l10n.
+const USE_REMOTE_L10N_PREF =
+ "browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
+
+// Experiment groups that need to report the reach event in Messaging-Experiments.
+// If you're adding new groups to it, make sure they're also added in the
+// `messaging_experiments.reach.objects` defined in "toolkit/components/telemetry/Events.yaml"
+const REACH_EVENT_GROUPS = [
+ "cfr",
+ "moments-page",
+ "infobar",
+ "spotlight",
+ "featureCallout",
+];
+const REACH_EVENT_CATEGORY = "messaging_experiments";
+const REACH_EVENT_METHOD = "reach";
+
+export const MessageLoaderUtils = {
+ STARTPAGE_VERSION,
+ REMOTE_LOADER_CACHE_KEY: "RemoteLoaderCache",
+ _errors: [],
+
+ reportError(e) {
+ console.error(e);
+ this._errors.push({
+ timestamp: new Date(),
+ error: { message: e.toString(), stack: e.stack },
+ });
+ },
+
+ get errors() {
+ const errors = this._errors;
+ this._errors = [];
+ return errors;
+ },
+
+ /**
+ * _localLoader - Loads messages for a local provider (i.e. one that lives in mozilla central)
+ *
+ * @param {obj} provider An AS router provider
+ * @param {Array} provider.messages An array of messages
+ * @returns {Array} the array of messages
+ */
+ _localLoader(provider) {
+ return provider.messages;
+ },
+
+ async _remoteLoaderCache(storage) {
+ let allCached;
+ try {
+ allCached =
+ (await storage.get(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY)) || {};
+ } catch (e) {
+ // istanbul ignore next
+ MessageLoaderUtils.reportError(e);
+ // istanbul ignore next
+ allCached = {};
+ }
+ return allCached;
+ },
+
+ /**
+ * _remoteLoader - Loads messages for a remote provider
+ *
+ * @param {obj} provider An AS router provider
+ * @param {string} provider.url An endpoint that returns an array of messages as JSON
+ * @param {obj} options.storage A storage object with get() and set() methods for caching.
+ * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
+ */
+ async _remoteLoader(provider, options) {
+ let remoteMessages = [];
+ if (provider.url) {
+ const allCached = await MessageLoaderUtils._remoteLoaderCache(
+ options.storage
+ );
+ const cached = allCached[provider.id];
+ let etag;
+
+ if (
+ cached &&
+ cached.url === provider.url &&
+ cached.version === STARTPAGE_VERSION
+ ) {
+ const { lastFetched, messages } = cached;
+ if (
+ !MessageLoaderUtils.shouldProviderUpdate({
+ ...provider,
+ lastUpdated: lastFetched,
+ })
+ ) {
+ // Cached messages haven't expired, return early.
+ return messages;
+ }
+ etag = cached.etag;
+ remoteMessages = messages;
+ }
+
+ let headers = new Headers();
+ if (etag) {
+ headers.set("If-None-Match", etag);
+ }
+
+ let response;
+ try {
+ response = await fetch(provider.url, {
+ headers,
+ credentials: "omit",
+ });
+ } catch (e) {
+ MessageLoaderUtils.reportError(e);
+ }
+ if (
+ response &&
+ response.ok &&
+ response.status >= 200 &&
+ response.status < 400
+ ) {
+ let jsonResponse;
+ try {
+ jsonResponse = await response.json();
+ } catch (e) {
+ MessageLoaderUtils.reportError(e);
+ return remoteMessages;
+ }
+ if (jsonResponse && jsonResponse.messages) {
+ remoteMessages = jsonResponse.messages.map(msg => ({
+ ...msg,
+ provider_url: provider.url,
+ }));
+
+ // Cache the results if this isn't a preview URL.
+ if (provider.updateCycleInMs > 0) {
+ etag = response.headers.get("ETag");
+ const cacheInfo = {
+ messages: remoteMessages,
+ etag,
+ lastFetched: Date.now(),
+ version: STARTPAGE_VERSION,
+ };
+
+ options.storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, {
+ ...allCached,
+ [provider.id]: cacheInfo,
+ });
+ }
+ } else {
+ MessageLoaderUtils.reportError(
+ `No messages returned from ${provider.url}.`
+ );
+ }
+ } else if (response) {
+ MessageLoaderUtils.reportError(
+ `Invalid response status ${response.status} from ${provider.url}.`
+ );
+ }
+ }
+ return remoteMessages;
+ },
+
+ /**
+ * _remoteSettingsLoader - Loads messages for a RemoteSettings provider
+ *
+ * Note:
+ * 1). The "cfr" provider requires the Fluent file for l10n, so there is
+ * another file downloading phase for those two providers after their messages
+ * are successfully fetched from Remote Settings. Currently, they share the same
+ * attachment of the record "${RS_FLUENT_RECORD_PREFIX}-${locale}" in the
+ * "ms-language-packs" collection. E.g. for "en-US" with version "v1",
+ * the Fluent file is attched to the record with ID "cfr-v1-en-US".
+ *
+ * 2). The Remote Settings downloader is able to detect the duplicate download
+ * requests for the same attachment and ignore the redundent requests automatically.
+ *
+ * @param {object} provider An AS router provider
+ * @param {string} provider.id The id of the provider
+ * @param {string} provider.collection Remote Settings collection name
+ * @param {object} options
+ * @param {function} options.dispatchCFRAction Action handler function
+ * @returns {Promise<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 (!REACH_EVENT_GROUPS.includes(featureId) || !experimentData) {
+ continue;
+ }
+
+ // Check other sibling branches for triggers, add them to the return array
+ // if found any. The `forReachEvent` label is used to identify those
+ // branches so that they would only be used to record the Reach event.
+ const branches =
+ (await lazy.ExperimentAPI.getAllBranches(experimentData.slug)) || [];
+ for (const branch of branches) {
+ let branchValue = branch[featureId].value;
+ if (!branchValue || branch.slug === experimentData.branch.slug) {
+ continue;
+ }
+ const branchMessages =
+ branchValue?.template === "multi" &&
+ Array.isArray(branchValue.messages)
+ ? branchValue.messages
+ : [branchValue];
+ for (const message of branchMessages) {
+ if (!message?.trigger) {
+ continue;
+ }
+ experiments.push({
+ forReachEvent: { sent: false, group: featureId },
+ experimentSlug: experimentData.slug,
+ branchSlug: branch.slug,
+ ...message,
+ });
+ }
+ }
+ }
+
+ return experiments;
+ },
+
+ _handleRemoteSettingsUndesiredEvent(event, providerId, dispatchCFRAction) {
+ if (dispatchCFRAction) {
+ dispatchCFRAction(
+ ac.ASRouterUserEvent({
+ action: "asrouter_undesired_event",
+ event,
+ message_id: "n/a",
+ event_context: providerId,
+ })
+ );
+ }
+ },
+
+ /**
+ * _getMessageLoader - return the right loading function given the provider's type
+ *
+ * @param {obj} provider An AS Router provider
+ * @returns {func} A loading function
+ */
+ _getMessageLoader(provider) {
+ switch (provider.type) {
+ case "remote":
+ return this._remoteLoader;
+ case "remote-settings":
+ return this._remoteSettingsLoader;
+ case "remote-experiments":
+ return this._experimentsAPILoader;
+ case "local":
+ default:
+ return this._localLoader;
+ }
+ },
+
+ /**
+ * shouldProviderUpdate - Given the current time, should a provider update its messages?
+ *
+ * @param {any} provider An AS Router provider
+ * @param {int} provider.updateCycleInMs The number of milliseconds we should wait between updates
+ * @param {Date} provider.lastUpdated If the provider has been updated, the time the last update occurred
+ * @param {Date} currentTime The time we should check against. (defaults to Date.now())
+ * @returns {bool} Should an update happen?
+ */
+ shouldProviderUpdate(provider, currentTime = Date.now()) {
+ return (
+ !(provider.lastUpdated >= 0) ||
+ currentTime - provider.lastUpdated > provider.updateCycleInMs
+ );
+ },
+
+ async _loadDataForProvider(provider, options) {
+ const loader = this._getMessageLoader(provider);
+ let messages = await loader(provider, options);
+ // istanbul ignore if
+ if (!messages) {
+ messages = [];
+ MessageLoaderUtils.reportError(
+ new Error(
+ `Tried to load messages for ${provider.id} but the result was not an Array.`
+ )
+ );
+ }
+
+ return { messages };
+ },
+
+ /**
+ * loadMessagesForProvider - Load messages for a provider, given the provider's type.
+ *
+ * @param {obj} provider An AS Router provider
+ * @param {string} provider.type An AS Router provider type (defaults to "local")
+ * @param {obj} options.storage A storage object with get() and set() methods for caching.
+ * @param {func} options.dispatchCFRAction dispatch an action the main AS Store
+ * @returns {obj} Returns an object with .messages (an array of messages) and .lastUpdated (the time the messages were updated)
+ */
+ async loadMessagesForProvider(provider, options) {
+ let { messages } = await this._loadDataForProvider(provider, options);
+ // Filter out messages we temporarily want to exclude
+ if (provider.exclude && provider.exclude.length) {
+ messages = messages.filter(
+ message => !provider.exclude.includes(message.id)
+ );
+ }
+ const lastUpdated = Date.now();
+ return {
+ messages: messages
+ .map(messageData => {
+ const message = {
+ weight: 100,
+ ...messageData,
+ groups: messageData.groups || [],
+ provider: provider.id,
+ };
+
+ return message;
+ })
+ .filter(message => message.weight > 0),
+ lastUpdated,
+ errors: MessageLoaderUtils.errors,
+ };
+ },
+
+ /**
+ * cleanupCache - Removes cached data of removed providers.
+ *
+ * @param {Array} providers A list of activer AS Router providers
+ */
+ async cleanupCache(providers, storage) {
+ const ids = providers.filter(p => p.type === "remote").map(p => p.id);
+ const cache = await MessageLoaderUtils._remoteLoaderCache(storage);
+ let dirty = false;
+ for (let id in cache) {
+ if (!ids.includes(id)) {
+ delete cache[id];
+ dirty = true;
+ }
+ }
+ if (dirty) {
+ await storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, cache);
+ }
+ },
+
+ /**
+ * The locale to use for RemoteL10n.
+ *
+ * This may map the app's actual locale into something that RemoteL10n
+ * supports.
+ */
+ get locale() {
+ const localeMap = {
+ "ja-JP-macos": "ja-JP-mac",
+
+ // While it's not a valid locale, "und" is commonly observed on
+ // Linux platforms. Per l10n team, it's reasonable to fallback to
+ // "en-US", therefore, we should allow the fetch for it.
+ und: "en-US",
+ };
+
+ const locale = Services.locale.appLocaleAsBCP47;
+ return localeMap[locale] ?? locale;
+ },
+};
+
+/**
+ * @class _ASRouter - Keeps track of all messages, UI surfaces, and
+ * handles blocking, rotation, etc. Inspecting ASRouter.state will
+ * tell you what the current displayed message is in all UI surfaces.
+ *
+ * Note: This is written as a constructor rather than just a plain object
+ * so that it can be more easily unit tested.
+ */
+export class _ASRouter {
+ constructor(localProviders = LOCAL_MESSAGE_PROVIDERS) {
+ this.initialized = false;
+ this.clearChildMessages = null;
+ this.clearChildProviders = null;
+ this.updateAdminState = null;
+ this.sendTelemetry = null;
+ this.dispatchCFRAction = null;
+ this._storage = null;
+ this._resetInitialization();
+ this._state = {
+ providers: [],
+ messageBlockList: [],
+ messageImpressions: {},
+ screenImpressions: {},
+ messages: [],
+ groups: [],
+ errors: [],
+ localeInUse: Services.locale.appLocaleAsBCP47,
+ };
+ this._experimentChangedListeners = new Map();
+ this._triggerHandler = this._triggerHandler.bind(this);
+ this._localProviders = localProviders;
+ this.blockMessageById = this.blockMessageById.bind(this);
+ this.unblockMessageById = this.unblockMessageById.bind(this);
+ this.handleMessageRequest = this.handleMessageRequest.bind(this);
+ this.addImpression = this.addImpression.bind(this);
+ this.addScreenImpression = this.addScreenImpression.bind(this);
+ this._handleTargetingError = this._handleTargetingError.bind(this);
+ this.onPrefChange = this.onPrefChange.bind(this);
+ this._onLocaleChanged = this._onLocaleChanged.bind(this);
+ this.isUnblockedMessage = this.isUnblockedMessage.bind(this);
+ this.unblockAll = this.unblockAll.bind(this);
+ this.forceWNPanel = this.forceWNPanel.bind(this);
+ this._onExperimentEnrollmentsUpdated =
+ this._onExperimentEnrollmentsUpdated.bind(this);
+ this.forcePBWindow = this.forcePBWindow.bind(this);
+ Services.telemetry.setEventRecordingEnabled(REACH_EVENT_CATEGORY, true);
+ this.messagesEnabledInAutomation = [];
+ }
+
+ async onPrefChange(prefName) {
+ if (lazy.TARGETING_PREFERENCES.includes(prefName)) {
+ let invalidMessages = [];
+ // Notify all tabs of messages that have become invalid after pref change
+ const context = this._getMessagesContext();
+ const targetingContext = new lazy.TargetingContext(context);
+
+ for (const msg of this.state.messages.filter(this.isUnblockedMessage)) {
+ if (!msg.targeting) {
+ continue;
+ }
+ const isMatch = await targetingContext.evalWithDefault(msg.targeting);
+ if (!isMatch) {
+ invalidMessages.push(msg.id);
+ }
+ }
+ this.clearChildMessages(invalidMessages);
+ } else {
+ // Update message providers and fetch new messages on pref change
+ this._loadLocalProviders();
+ let invalidProviders = await this._updateMessageProviders();
+ if (invalidProviders.length) {
+ this.clearChildProviders(invalidProviders);
+ }
+ await this.loadMessagesFromAllProviders();
+ // Any change in user prefs can disable or enable groups
+ await this.setState(state => ({
+ groups: state.groups.map(this._checkGroupEnabled),
+ }));
+ }
+ }
+
+ // Fetch and decode the message provider pref JSON, and update the message providers
+ async _updateMessageProviders() {
+ lazy.ASRouterPreferences.console.debug("entering updateMessageProviders");
+
+ const previousProviders = this.state.providers;
+ const providers = await Promise.all(
+ [
+ // If we have added a `preview` provider, hold onto it
+ ...previousProviders.filter(p => p.id === "preview"),
+ // The provider should be enabled and not have a user preference set to false
+ ...lazy.ASRouterPreferences.providers.filter(
+ p =>
+ p.enabled &&
+ lazy.ASRouterPreferences.getUserPreference(p.id) !== false
+ ),
+ ].map(async _provider => {
+ // make a copy so we don't modify the source of the pref
+ const provider = { ..._provider };
+
+ if (provider.type === "local" && !provider.messages) {
+ // Get the messages from the local message provider
+ const localProvider = this._localProviders[provider.localProvider];
+ provider.messages = [];
+ if (localProvider) {
+ provider.messages = await localProvider.getMessages();
+ }
+ }
+ if (provider.type === "remote" && provider.url) {
+ provider.url = provider.url.replace(
+ /%STARTPAGE_VERSION%/g,
+ STARTPAGE_VERSION
+ );
+ provider.url = Services.urlFormatter.formatURL(provider.url);
+ }
+ if (provider.id === "messaging-experiments") {
+ // By default, the messaging-experiments provider lacks a featureIds
+ // property, so fall back to the list of default features.
+ if (!provider.featureIds) {
+ provider.featureIds = MESSAGING_EXPERIMENTS_DEFAULT_FEATURES;
+ }
+ }
+ // Reset provider update timestamp to force message refresh
+ provider.lastUpdated = undefined;
+ return provider;
+ })
+ );
+
+ const providerIDs = providers.map(p => p.id);
+ let invalidProviders = [];
+
+ // Clear old messages for providers that are no longer enabled
+ for (const prevProvider of previousProviders) {
+ if (!providerIDs.includes(prevProvider.id)) {
+ invalidProviders.push(prevProvider.id);
+ }
+ }
+
+ return this.setState(prevState => ({
+ providers,
+ // Clear any messages from removed providers
+ messages: [
+ ...prevState.messages.filter(message =>
+ providerIDs.includes(message.provider)
+ ),
+ ],
+ })).then(() => invalidProviders);
+ }
+
+ get state() {
+ return this._state;
+ }
+
+ set state(value) {
+ throw new Error(
+ "Do not modify this.state directy. Instead, call this.setState(newState)"
+ );
+ }
+
+ /**
+ * _resetInitialization - adds the following to the instance:
+ * .initialized {bool} Has AS Router been initialized?
+ * .waitForInitialized {Promise} A promise that resolves when initializion is complete
+ * ._finishInitializing {func} A function that, when called, resolves the .waitForInitialized
+ * promise and sets .initialized to true.
+ * @memberof _ASRouter
+ */
+ _resetInitialization() {
+ this.initialized = false;
+ this.initializing = false;
+ this.waitForInitialized = new Promise(resolve => {
+ this._finishInitializing = () => {
+ this.initialized = true;
+ this.initializing = false;
+ resolve();
+ };
+ });
+ }
+
+ /**
+ * Check all provided groups are enabled.
+ * @param groups Set of groups to verify
+ * @returns bool
+ */
+ hasGroupsEnabled(groups = []) {
+ return this.state.groups
+ .filter(({ id }) => groups.includes(id))
+ .every(({ enabled }) => enabled);
+ }
+
+ /**
+ * Verify that the provider block the message through the `exclude` field
+ * @param message Message to verify
+ * @returns bool
+ */
+ isExcludedByProvider(message) {
+ const provider = this.state.providers.find(p => p.id === message.provider);
+ if (!provider) {
+ return true;
+ }
+ if (provider.exclude) {
+ return provider.exclude.includes(message.id);
+ }
+ return false;
+ }
+
+ /**
+ * Takes a group and sets the correct `enabled` state based on message config
+ * and user preferences
+ *
+ * @param {GroupConfig} group
+ * @returns {GroupConfig}
+ */
+ _checkGroupEnabled(group) {
+ return {
+ ...group,
+ enabled:
+ group.enabled &&
+ // And if defined user preferences are true. If multiple prefs are
+ // defined then at least one has to be enabled.
+ (Array.isArray(group.userPreferences)
+ ? group.userPreferences.some(pref =>
+ lazy.ASRouterPreferences.getUserPreference(pref)
+ )
+ : true),
+ };
+ }
+
+ /**
+ * Fetch all message groups and update Router.state.groups.
+ * There are two cases to consider:
+ * 1. The provider needs to update as determined by the update cycle
+ * 2. Some pref change occured which could invalidate one of the existing
+ * groups.
+ */
+ async loadAllMessageGroups() {
+ const provider = this.state.providers.find(
+ p =>
+ p.id === "message-groups" && MessageLoaderUtils.shouldProviderUpdate(p)
+ );
+ let remoteMessages = null;
+ if (provider) {
+ const { messages } = await MessageLoaderUtils._loadDataForProvider(
+ provider,
+ {
+ storage: this._storage,
+ dispatchCFRAction: this.dispatchCFRAction,
+ }
+ );
+ remoteMessages = messages;
+ }
+ await this.setState(state => ({
+ // If fetching remote messages fails we default to existing state.groups.
+ groups: (remoteMessages || state.groups).map(this._checkGroupEnabled),
+ }));
+ }
+
+ /**
+ * loadMessagesFromAllProviders - Loads messages from all providers if they require updates.
+ * Checks the .lastUpdated field on each provider to see if updates are needed
+ * @param toUpdate An optional list of providers to update. This overrides
+ * the checks to determine which providers to update.
+ * @memberof _ASRouter
+ */
+ async loadMessagesFromAllProviders(toUpdate = undefined) {
+ const needsUpdate = Array.isArray(toUpdate)
+ ? toUpdate
+ : this.state.providers.filter(provider =>
+ MessageLoaderUtils.shouldProviderUpdate(provider)
+ );
+ lazy.ASRouterPreferences.console.debug(
+ "entering loadMessagesFromAllProviders"
+ );
+
+ await this.loadAllMessageGroups();
+ // Don't do extra work if we don't need any updates
+ if (needsUpdate.length) {
+ let newState = { messages: [], providers: [] };
+ for (const provider of this.state.providers) {
+ if (needsUpdate.includes(provider)) {
+ const { messages, lastUpdated, errors } =
+ await MessageLoaderUtils.loadMessagesForProvider(provider, {
+ storage: this._storage,
+ dispatchCFRAction: this.dispatchCFRAction,
+ });
+ newState.providers.push({ ...provider, lastUpdated, errors });
+ newState.messages = [...newState.messages, ...messages];
+ } else {
+ // Skip updating this provider's messages if no update is required
+ let messages = this.state.messages.filter(
+ msg => msg.provider === provider.id
+ );
+ newState.providers.push(provider);
+ newState.messages = [...newState.messages, ...messages];
+ }
+ }
+
+ // Some messages have triggers that require us to initalise trigger listeners
+ const unseenListeners = new Set(lazy.ASRouterTriggerListeners.keys());
+ for (const { trigger } of newState.messages) {
+ if (trigger && lazy.ASRouterTriggerListeners.has(trigger.id)) {
+ lazy.ASRouterTriggerListeners.get(trigger.id).init(
+ this._triggerHandler,
+ trigger.params,
+ trigger.patterns
+ );
+ unseenListeners.delete(trigger.id);
+ }
+ }
+ // We don't need these listeners, but they may have previously been
+ // initialised, so uninitialise them
+ for (const triggerID of unseenListeners) {
+ lazy.ASRouterTriggerListeners.get(triggerID).uninit();
+ }
+
+ await this.setState(newState);
+ await this.cleanupImpressions();
+ }
+
+ await this._fireMessagesLoadedTrigger();
+
+ return this.state;
+ }
+
+ async _fireMessagesLoadedTrigger() {
+ const win = Services.wm.getMostRecentBrowserWindow() ?? null;
+ const browser = win?.gBrowser?.selectedBrowser ?? null;
+ // pass skipLoadingMessages to avoid infinite recursion. pass browser and
+ // window into context so messages that may need a window or browser can
+ // target accordingly.
+ await this.sendTriggerMessage(
+ {
+ id: "messagesLoaded",
+ browser,
+ context: { browser, browserWindow: win },
+ },
+ true
+ );
+ }
+
+ async _maybeUpdateL10nAttachment() {
+ const { localeInUse } = this.state.localeInUse;
+ const newLocale = Services.locale.appLocaleAsBCP47;
+ if (newLocale !== localeInUse) {
+ const providers = [...this.state.providers];
+ let needsUpdate = false;
+ providers.forEach(provider => {
+ if (RS_PROVIDERS_WITH_L10N.includes(provider.id)) {
+ // Force to refresh the messages as well as the attachment.
+ provider.lastUpdated = undefined;
+ needsUpdate = true;
+ }
+ });
+ if (needsUpdate) {
+ await this.setState({
+ localeInUse: newLocale,
+ providers,
+ });
+ await this.loadMessagesFromAllProviders();
+ }
+ }
+ return this.state;
+ }
+
+ async _onLocaleChanged(subject, topic, data) {
+ await this._maybeUpdateL10nAttachment();
+ }
+
+ observe(aSubject, aTopic, aPrefName) {
+ switch (aPrefName) {
+ case USE_REMOTE_L10N_PREF:
+ CFRPageActions.reloadL10n();
+ break;
+ }
+ }
+
+ toWaitForInitFunc(func) {
+ return (...args) => this.waitForInitialized.then(() => func(...args));
+ }
+
+ /**
+ * init - Initializes the MessageRouter.
+ *
+ * @param {obj} parameters parameters to initialize ASRouter
+ * @memberof _ASRouter
+ */
+ async init({
+ storage,
+ sendTelemetry,
+ clearChildMessages,
+ clearChildProviders,
+ updateAdminState,
+ dispatchCFRAction,
+ }) {
+ if (this.initializing || this.initialized) {
+ return null;
+ }
+ this.initializing = true;
+ this._storage = storage;
+ this.ALLOWLIST_HOSTS = this._loadAllowHosts();
+ this.clearChildMessages = this.toWaitForInitFunc(clearChildMessages);
+ this.clearChildProviders = this.toWaitForInitFunc(clearChildProviders);
+ // NOTE: This is only necessary to sync devtools when devtools is active.
+ this.updateAdminState = this.toWaitForInitFunc(updateAdminState);
+ this.sendTelemetry = sendTelemetry;
+ this.dispatchCFRAction = this.toWaitForInitFunc(dispatchCFRAction);
+
+ lazy.ASRouterPreferences.init();
+ lazy.ASRouterPreferences.addListener(this.onPrefChange);
+ lazy.ToolbarBadgeHub.init(this.waitForInitialized, {
+ handleMessageRequest: this.handleMessageRequest,
+ addImpression: this.addImpression,
+ blockMessageById: this.blockMessageById,
+ unblockMessageById: this.unblockMessageById,
+ sendTelemetry: this.sendTelemetry,
+ });
+ lazy.ToolbarPanelHub.init(this.waitForInitialized, {
+ getMessages: this.handleMessageRequest,
+ sendTelemetry: this.sendTelemetry,
+ });
+ lazy.MomentsPageHub.init(this.waitForInitialized, {
+ handleMessageRequest: this.handleMessageRequest,
+ addImpression: this.addImpression,
+ blockMessageById: this.blockMessageById,
+ sendTelemetry: this.sendTelemetry,
+ });
+
+ this._loadLocalProviders();
+
+ const messageBlockList =
+ (await this._storage.get("messageBlockList")) || [];
+ const messageImpressions =
+ (await this._storage.get("messageImpressions")) || {};
+ const groupImpressions =
+ (await this._storage.get("groupImpressions")) || {};
+ const screenImpressions =
+ (await this._storage.get("screenImpressions")) || {};
+ const previousSessionEnd =
+ (await this._storage.get("previousSessionEnd")) || 0;
+
+ await this.setState({
+ messageBlockList,
+ groupImpressions,
+ messageImpressions,
+ screenImpressions,
+ previousSessionEnd,
+ ...(lazy.ASRouterPreferences.specialConditions || {}),
+ initialized: false,
+ });
+ await this._updateMessageProviders();
+ await this.loadMessagesFromAllProviders();
+ await MessageLoaderUtils.cleanupCache(this.state.providers, storage);
+
+ lazy.SpecialMessageActions.blockMessageById = this.blockMessageById;
+ Services.obs.addObserver(this._onLocaleChanged, TOPIC_INTL_LOCALE_CHANGED);
+ Services.obs.addObserver(
+ this._onExperimentEnrollmentsUpdated,
+ TOPIC_EXPERIMENT_ENROLLMENT_CHANGED
+ );
+ Services.prefs.addObserver(USE_REMOTE_L10N_PREF, this);
+ // sets .initialized to true and resolves .waitForInitialized promise
+ this._finishInitializing();
+ return this.state;
+ }
+
+ uninit() {
+ this._storage.set("previousSessionEnd", Date.now());
+
+ this.clearChildMessages = null;
+ this.clearChildProviders = null;
+ this.updateAdminState = null;
+ this.sendTelemetry = null;
+ this.dispatchCFRAction = null;
+
+ lazy.ASRouterPreferences.removeListener(this.onPrefChange);
+ lazy.ASRouterPreferences.uninit();
+ lazy.ToolbarPanelHub.uninit();
+ lazy.ToolbarBadgeHub.uninit();
+ lazy.MomentsPageHub.uninit();
+
+ // Uninitialise all trigger listeners
+ for (const listener of lazy.ASRouterTriggerListeners.values()) {
+ listener.uninit();
+ }
+ Services.obs.removeObserver(
+ this._onLocaleChanged,
+ TOPIC_INTL_LOCALE_CHANGED
+ );
+ Services.obs.removeObserver(
+ this._onExperimentEnrollmentsUpdated,
+ TOPIC_EXPERIMENT_ENROLLMENT_CHANGED
+ );
+ Services.prefs.removeObserver(USE_REMOTE_L10N_PREF, this);
+ // If we added any CFR recommendations, they need to be removed
+ CFRPageActions.clearRecommendations();
+ this._resetInitialization();
+ }
+
+ setState(callbackOrObj) {
+ lazy.ASRouterPreferences.console.debug(
+ "in setState, callbackOrObj = ",
+ callbackOrObj
+ );
+ lazy.ASRouterPreferences.console.trace();
+ const newState =
+ typeof callbackOrObj === "function"
+ ? callbackOrObj(this.state)
+ : callbackOrObj;
+ this._state = {
+ ...this.state,
+ ...newState,
+ };
+ if (lazy.ASRouterPreferences.devtoolsEnabled) {
+ return this.updateTargetingParameters().then(state => {
+ this.updateAdminState(state);
+ return state;
+ });
+ }
+ return Promise.resolve(this.state);
+ }
+
+ updateTargetingParameters() {
+ return this.getTargetingParameters(
+ lazy.ASRouterTargeting.Environment,
+ this._getMessagesContext()
+ ).then(targetingParameters => ({
+ ...this.state,
+ providerPrefs: lazy.ASRouterPreferences.providers,
+ userPrefs: lazy.ASRouterPreferences.getAllUserPreferences(),
+ targetingParameters,
+ errors: this.errors,
+ devtoolsEnabled: lazy.ASRouterPreferences.devtoolsEnabled,
+ }));
+ }
+
+ getMessageById(id) {
+ return this.state.messages.find(message => message.id === id);
+ }
+
+ _loadLocalProviders() {
+ // If we're in ASR debug mode add the local test providers
+ if (lazy.ASRouterPreferences.devtoolsEnabled) {
+ this._localProviders = {
+ ...this._localProviders,
+ PanelTestProvider: lazy.PanelTestProvider,
+ };
+ }
+ }
+
+ /**
+ * Used by ASRouter Admin returns all ASRouterTargeting.Environment
+ * and ASRouter._getMessagesContext parameters and values
+ */
+ async getTargetingParameters(environment, localContext) {
+ // Resolve objects that may contain promises.
+ async function resolve(object) {
+ if (typeof object === "object" && object !== null) {
+ if (Array.isArray(object)) {
+ return Promise.all(object.map(async item => resolve(await item)));
+ }
+
+ if (object instanceof Date) {
+ return object;
+ }
+
+ const target = {};
+ const promises = Object.entries(object).map(async ([key, value]) => {
+ try {
+ let resolvedValue = await resolve(await value);
+ return [key, resolvedValue];
+ } catch (error) {
+ lazy.ASRouterPreferences.console.debug(
+ `getTargetingParameters: Error resolving ${key}: `,
+ error
+ );
+ throw error;
+ }
+ });
+ for (const { status, value } of await Promise.allSettled(promises)) {
+ if (status === "fulfilled") {
+ const [key, resolvedValue] = value;
+ target[key] = resolvedValue;
+ }
+ }
+ return target;
+ }
+
+ return object;
+ }
+
+ const targetingParameters = {
+ ...(await resolve(environment)),
+ ...(await resolve(localContext)),
+ };
+
+ return targetingParameters;
+ }
+
+ _handleTargetingError(error, message) {
+ console.error(error);
+ this.dispatchCFRAction(
+ ac.ASRouterUserEvent({
+ message_id: message.id,
+ action: "asrouter_undesired_event",
+ event: "TARGETING_EXPRESSION_ERROR",
+ event_context: {},
+ })
+ );
+ }
+
+ // Return an object containing targeting parameters used to select messages
+ _getMessagesContext() {
+ const { messageImpressions, previousSessionEnd, screenImpressions } =
+ this.state;
+
+ return {
+ get messageImpressions() {
+ return messageImpressions;
+ },
+ get previousSessionEnd() {
+ return previousSessionEnd;
+ },
+ get screenImpressions() {
+ return screenImpressions;
+ },
+ };
+ }
+
+ async evaluateExpression({ expression, context }) {
+ const targetingContext = new lazy.TargetingContext(context);
+ let evaluationStatus;
+ try {
+ evaluationStatus = {
+ result: await targetingContext.evalWithDefault(expression),
+ success: true,
+ };
+ } catch (e) {
+ evaluationStatus = { result: e.message, success: false };
+ }
+ return Promise.resolve({ evaluationStatus });
+ }
+
+ unblockAll() {
+ return this.setState({ messageBlockList: [] });
+ }
+
+ isUnblockedMessage(message) {
+ const { state } = this;
+ return (
+ !state.messageBlockList.includes(message.id) &&
+ (!message.campaign ||
+ !state.messageBlockList.includes(message.campaign)) &&
+ this.hasGroupsEnabled(message.groups) &&
+ !this.isExcludedByProvider(message)
+ );
+ }
+
+ // Work out if a message can be shown based on its and its provider's frequency caps.
+ isBelowFrequencyCaps(message) {
+ const { messageImpressions, groupImpressions } = this.state;
+ const impressionsForMessage = messageImpressions[message.id];
+
+ const _belowItemFrequencyCap = this._isBelowItemFrequencyCap(
+ message,
+ impressionsForMessage,
+ MAX_MESSAGE_LIFETIME_CAP
+ );
+ if (!_belowItemFrequencyCap) {
+ lazy.ASRouterPreferences.console.debug(
+ `isBelowFrequencyCaps: capped by item: `,
+ message,
+ "impressions =",
+ impressionsForMessage
+ );
+ }
+
+ const _belowGroupFrequencyCaps = message.groups.every(messageGroup => {
+ const belowThisGroupCap = this._isBelowItemFrequencyCap(
+ this.state.groups.find(({ id }) => id === messageGroup),
+ groupImpressions[messageGroup]
+ );
+
+ if (!belowThisGroupCap) {
+ lazy.ASRouterPreferences.console.debug(
+ `isBelowFrequencyCaps: ${message.id} capped by group ${messageGroup}`
+ );
+ } else {
+ lazy.ASRouterPreferences.console.debug(
+ `isBelowFrequencyCaps: ${message.id} allowed by group ${messageGroup}, groupImpressions = `,
+ groupImpressions
+ );
+ }
+
+ return belowThisGroupCap;
+ });
+
+ return _belowItemFrequencyCap && _belowGroupFrequencyCaps;
+ }
+
+ // Helper for isBelowFrecencyCaps - work out if the frequency cap for the given
+ // item has been exceeded or not
+ _isBelowItemFrequencyCap(item, impressions, maxLifetimeCap = Infinity) {
+ if (item && item.frequency && impressions && impressions.length) {
+ if (
+ item.frequency.lifetime &&
+ impressions.length >= Math.min(item.frequency.lifetime, maxLifetimeCap)
+ ) {
+ lazy.ASRouterPreferences.console.debug(
+ `${item.id} capped by lifetime (${item.frequency.lifetime})`
+ );
+
+ return false;
+ }
+ if (item.frequency.custom) {
+ const now = Date.now();
+ for (const setting of item.frequency.custom) {
+ let { period } = setting;
+ const impressionsInPeriod = impressions.filter(t => now - t < period);
+ if (impressionsInPeriod.length >= setting.cap) {
+ lazy.ASRouterPreferences.console.debug(
+ `${item.id} capped by impressions (${impressionsInPeriod.length}) in period (${period}) >= ${setting.cap}`
+ );
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ async _extraTemplateStrings(originalMessage) {
+ let extraTemplateStrings;
+ let localProvider = this._findProvider(originalMessage.provider);
+ if (localProvider && localProvider.getExtraAttributes) {
+ extraTemplateStrings = await localProvider.getExtraAttributes();
+ }
+
+ return extraTemplateStrings;
+ }
+
+ _findProvider(providerID) {
+ return this._localProviders[
+ this.state.providers.find(i => i.id === providerID).localProvider
+ ];
+ }
+
+ routeCFRMessage(message, browser, trigger, force = false) {
+ if (!message) {
+ return { message: {} };
+ }
+
+ // filter out messages we want to exclude from tests
+ if (
+ message.skip_in_tests &&
+ // `this.messagesEnabledInAutomation` should be stubbed in tests
+ !this.messagesEnabledInAutomation?.includes(message.id) &&
+ (Cu.isInAutomation ||
+ Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") ||
+ Services.env.get("MOZ_AUTOMATION"))
+ ) {
+ lazy.log.debug(
+ `Skipping message ${message.id} because ${message.skip_in_tests}`
+ );
+ return { message: {} };
+ }
+
+ switch (message.template) {
+ case "whatsnew_panel_message":
+ if (force) {
+ lazy.ToolbarPanelHub.forceShowMessage(browser, message);
+ }
+ break;
+ case "cfr_doorhanger":
+ case "milestone_message":
+ if (force) {
+ CFRPageActions.forceRecommendation(
+ browser,
+ message,
+ this.dispatchCFRAction
+ );
+ } else {
+ CFRPageActions.addRecommendation(
+ browser,
+ trigger.param && trigger.param.host,
+ message,
+ this.dispatchCFRAction
+ );
+ }
+ break;
+ case "cfr_urlbar_chiclet":
+ if (force) {
+ CFRPageActions.forceRecommendation(
+ browser,
+ message,
+ this.dispatchCFRAction
+ );
+ } else {
+ CFRPageActions.addRecommendation(
+ browser,
+ null,
+ message,
+ this.dispatchCFRAction
+ );
+ }
+ break;
+ case "toolbar_badge":
+ lazy.ToolbarBadgeHub.registerBadgeNotificationListener(message, {
+ force,
+ });
+ break;
+ case "update_action":
+ lazy.MomentsPageHub.executeAction(message);
+ break;
+ case "infobar":
+ lazy.InfoBar.showInfoBarMessage(
+ browser,
+ message,
+ this.dispatchCFRAction
+ );
+ break;
+ case "spotlight":
+ lazy.Spotlight.showSpotlightDialog(
+ browser,
+ message,
+ this.dispatchCFRAction
+ );
+ break;
+ case "feature_callout":
+ // featureCalloutCheck only comes from within FeatureCallout, where it
+ // is used to request a matching message. It is not a real trigger.
+ // pdfJsFeatureCalloutCheck is used for PDF.js feature callouts, which
+ // are managed by the trigger listener itself.
+ switch (trigger.id) {
+ case "featureCalloutCheck":
+ case "pdfJsFeatureCalloutCheck":
+ case "newtabFeatureCalloutCheck":
+ break;
+ default:
+ lazy.FeatureCalloutBroker.showFeatureCallout(browser, message);
+ }
+ break;
+ case "toast_notification":
+ lazy.ToastNotification.showToastNotification(
+ message,
+ this.dispatchCFRAction
+ );
+ break;
+ }
+
+ return { message };
+ }
+
+ addScreenImpression(screen) {
+ lazy.ASRouterPreferences.console.debug(
+ `entering addScreenImpression for ${screen.id}`
+ );
+
+ const time = Date.now();
+
+ let screenImpressions = { ...this.state.screenImpressions };
+ screenImpressions[screen.id] = time;
+
+ this.setState({ screenImpressions });
+ lazy.ASRouterPreferences.console.debug(
+ screen.id,
+ `screen impression added, screenImpressions[screen.id]: `,
+ screenImpressions[screen.id]
+ );
+ this._storage.set("screenImpressions", screenImpressions);
+ }
+
+ addImpression(message) {
+ lazy.ASRouterPreferences.console.debug(
+ `entering addImpression for ${message.id}`
+ );
+
+ const groupsWithFrequency = this.state.groups?.filter(
+ ({ frequency, id }) => frequency && message.groups?.includes(id)
+ );
+ // We only need to store impressions for messages that have frequency, or
+ // that have providers that have frequency
+ if (message.frequency || groupsWithFrequency.length) {
+ const time = Date.now();
+ return this.setState(state => {
+ const messageImpressions = this._addImpressionForItem(
+ state.messageImpressions,
+ message,
+ "messageImpressions",
+ time
+ );
+ // Initialize this with state.groupImpressions, and then assign the
+ // newly-updated copy to it during each iteration so that
+ // all the changes get captured and either returned or passed into the
+ // _addImpressionsForItem call on the next iteration.
+ let { groupImpressions } = state;
+ for (const group of groupsWithFrequency) {
+ groupImpressions = this._addImpressionForItem(
+ groupImpressions,
+ group,
+ "groupImpressions",
+ time
+ );
+ }
+
+ return { messageImpressions, groupImpressions };
+ });
+ }
+ return Promise.resolve();
+ }
+
+ // Helper for addImpression - calculate the updated impressions object for the given
+ // item, then store it and return it
+ _addImpressionForItem(currentImpressions, item, impressionsString, time) {
+ // The destructuring here is to avoid mutating passed parameters
+ // (see https://redux.js.org/recipes/structuring-reducers/prerequisite-concepts#immutable-data-management)
+ const impressions = { ...currentImpressions };
+ if (item.frequency) {
+ impressions[item.id] = [...(impressions[item.id] ?? []), time];
+
+ lazy.ASRouterPreferences.console.debug(
+ item.id,
+ "impression added, impressions[item.id]: ",
+ impressions[item.id]
+ );
+
+ this._storage.set(impressionsString, impressions);
+ }
+ return impressions;
+ }
+
+ /**
+ * getLongestPeriod
+ *
+ * @param {obj} item Either an ASRouter message or an ASRouter provider
+ * @returns {int|null} if the item has custom frequency caps, the longest period found in the list of caps.
+ if the item has no custom frequency caps, null
+ * @memberof _ASRouter
+ */
+ getLongestPeriod(item) {
+ if (!item.frequency || !item.frequency.custom) {
+ return null;
+ }
+ return item.frequency.custom.sort((a, b) => b.period - a.period)[0].period;
+ }
+
+ /**
+ * cleanupImpressions - this function cleans up obsolete impressions whenever
+ * messages are refreshed or fetched. It will likely need to be more sophisticated in the future,
+ * but the current behaviour for when both message impressions and provider impressions are
+ * cleared is as follows (where `item` is either `message` or `provider`):
+ *
+ * 1. If the item id for a list of item impressions no longer exists in the ASRouter state, it
+ * will be cleared.
+ * 2. If the item has time-bound frequency caps but no lifetime cap, any item impressions older
+ * than the longest time period will be cleared.
+ */
+ cleanupImpressions() {
+ return this.setState(state => {
+ const messageImpressions = this._cleanupImpressionsForItems(
+ state,
+ state.messages,
+ "messageImpressions"
+ );
+ const groupImpressions = this._cleanupImpressionsForItems(
+ state,
+ state.groups,
+ "groupImpressions"
+ );
+ return { messageImpressions, groupImpressions };
+ });
+ }
+
+ /** _cleanupImpressionsForItems - Helper for cleanupImpressions - calculate the updated
+ /* impressions object for the given items, then store it and return it
+ *
+ * @param {obj} state Reference to ASRouter internal state
+ * @param {array} items Can be messages, providers or groups that we count impressions for
+ * @param {string} impressionsString Key name for entry in state where impressions are stored
+ */
+ _cleanupImpressionsForItems(state, items, impressionsString) {
+ const impressions = { ...state[impressionsString] };
+ let needsUpdate = false;
+ Object.keys(impressions).forEach(id => {
+ const [item] = items.filter(x => x.id === id);
+ // Don't keep impressions for items that no longer exist
+ if (!item || !item.frequency || !Array.isArray(impressions[id])) {
+ lazy.ASRouterPreferences.console.debug(
+ "_cleanupImpressionsForItem: removing impressions for deleted or changed item: ",
+ item
+ );
+ lazy.ASRouterPreferences.console.trace();
+ delete impressions[id];
+ needsUpdate = true;
+ return;
+ }
+ if (!impressions[id].length) {
+ return;
+ }
+ // If we don't want to store impressions older than the longest period
+ if (item.frequency.custom && !item.frequency.lifetime) {
+ lazy.ASRouterPreferences.console.debug(
+ "_cleanupImpressionsForItem: removing impressions older than longest period for item: ",
+ item
+ );
+ const now = Date.now();
+ impressions[id] = impressions[id].filter(
+ t => now - t < this.getLongestPeriod(item)
+ );
+ needsUpdate = true;
+ }
+ });
+ if (needsUpdate) {
+ this._storage.set(impressionsString, impressions);
+ }
+ return impressions;
+ }
+
+ handleMessageRequest({
+ messages: candidates,
+ triggerId,
+ triggerParam,
+ triggerContext,
+ template,
+ provider,
+ ordered = false,
+ returnAll = false,
+ }) {
+ let shouldCache;
+ lazy.ASRouterPreferences.console.debug(
+ "in handleMessageRequest, arguments = ",
+ Array.from(arguments) // eslint-disable-line prefer-rest-params
+ );
+ lazy.ASRouterPreferences.console.trace();
+ const messages =
+ candidates ||
+ this.state.messages.filter(m => {
+ if (provider && m.provider !== provider) {
+ lazy.ASRouterPreferences.console.debug(m.id, " filtered by provider");
+ return false;
+ }
+ if (template && m.template !== template) {
+ lazy.ASRouterPreferences.console.debug(m.id, " filtered by template");
+ return false;
+ }
+ if (triggerId && !m.trigger) {
+ lazy.ASRouterPreferences.console.debug(m.id, " filtered by trigger");
+ return false;
+ }
+ if (triggerId && m.trigger.id !== triggerId) {
+ lazy.ASRouterPreferences.console.debug(
+ m.id,
+ " filtered by triggerId"
+ );
+ return false;
+ }
+ if (!this.isUnblockedMessage(m)) {
+ lazy.ASRouterPreferences.console.debug(
+ m.id,
+ " filtered because blocked"
+ );
+ return false;
+ }
+ if (!this.isBelowFrequencyCaps(m)) {
+ lazy.ASRouterPreferences.console.debug(
+ m.id,
+ " filtered because capped"
+ );
+ return false;
+ }
+
+ if (shouldCache !== false) {
+ shouldCache = JEXL_PROVIDER_CACHE.has(m.provider);
+ }
+
+ return true;
+ });
+
+ if (!messages.length) {
+ return returnAll ? messages : null;
+ }
+
+ const context = this._getMessagesContext();
+
+ // Find a message that matches the targeting context as well as the trigger context (if one is provided)
+ // If no trigger is provided, we should find a message WITHOUT a trigger property defined.
+ return lazy.ASRouterTargeting.findMatchingMessage({
+ messages,
+ trigger: triggerId && {
+ id: triggerId,
+ param: triggerParam,
+ context: triggerContext,
+ },
+ context,
+ onError: this._handleTargetingError,
+ ordered,
+ shouldCache,
+ returnAll,
+ });
+ }
+
+ setMessageById({ id, ...data }, force, browser) {
+ return this.routeCFRMessage(this.getMessageById(id), browser, data, force);
+ }
+
+ blockMessageById(idOrIds) {
+ lazy.ASRouterPreferences.console.debug(
+ "blockMessageById called, idOrIds = ",
+ idOrIds
+ );
+ lazy.ASRouterPreferences.console.trace();
+
+ const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
+
+ return this.setState(state => {
+ const messageBlockList = [...state.messageBlockList];
+ const messageImpressions = { ...state.messageImpressions };
+
+ idsToBlock.forEach(id => {
+ const message = state.messages.find(m => m.id === id);
+ const idToBlock = message && message.campaign ? message.campaign : id;
+ if (!messageBlockList.includes(idToBlock)) {
+ messageBlockList.push(idToBlock);
+ }
+
+ // When a message is blocked, its impressions should be cleared as well
+ delete messageImpressions[id];
+ });
+
+ this._storage.set("messageBlockList", messageBlockList);
+ this._storage.set("messageImpressions", messageImpressions);
+ return { messageBlockList, messageImpressions };
+ });
+ }
+
+ unblockMessageById(idOrIds) {
+ const idsToUnblock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
+
+ return this.setState(state => {
+ const messageBlockList = [...state.messageBlockList];
+ idsToUnblock
+ .map(id => state.messages.find(m => m.id === id))
+ // Remove all `id`s from the message block list
+ .forEach(message => {
+ const idToUnblock =
+ message && message.campaign ? message.campaign : message.id;
+ messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1);
+ });
+
+ this._storage.set("messageBlockList", messageBlockList);
+ return { messageBlockList };
+ });
+ }
+
+ resetGroupsState() {
+ const newGroupImpressions = {};
+ for (let { id } of this.state.groups) {
+ newGroupImpressions[id] = [];
+ }
+ // Update storage
+ this._storage.set("groupImpressions", newGroupImpressions);
+ return this.setState(({ groups }) => ({
+ groupImpressions: newGroupImpressions,
+ }));
+ }
+
+ resetMessageState() {
+ const newMessageImpressions = {};
+ for (let { id } of this.state.messages) {
+ newMessageImpressions[id] = [];
+ }
+ // Update storage
+ this._storage.set("messageImpressions", newMessageImpressions);
+ return this.setState(() => ({
+ messageImpressions: newMessageImpressions,
+ }));
+ }
+
+ resetScreenImpressions() {
+ const newScreenImpressions = {};
+ this._storage.set("screenImpressions", newScreenImpressions);
+ return this.setState(() => ({ screenImpressions: newScreenImpressions }));
+ }
+
+ /**
+ * Edit the ASRouter state directly. For use by the ASRouter devtools.
+ * Requires browser.newtabpage.activity-stream.asrouter.devtoolsEnabled
+ * @param {string} key Key of the property to edit, one of:
+ * | "groupImpressions"
+ * | "messageImpressions"
+ * | "screenImpressions"
+ * | "messageBlockList"
+ * @param {object|string[]} value New value to set for state[key]
+ * @returns {Promise<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
+ * attribution data (see Return to AMO flow in bug 1475354 for example). Note - OSX and Windows only
+ * @param {data} Object an object containing the attribtion data that came from asrouter admin page
+ */
+ async forceAttribution(data) {
+ // Extract the parameters from data that will make up the referrer url
+ const attributionData = lazy.AttributionCode.allowedCodeKeys
+ .map(key => `${key}=${encodeURIComponent(data[key] || "")}`)
+ .join("&");
+ if (AppConstants.platform === "win") {
+ // The whole attribution data is encoded (again) for windows
+ await lazy.AttributionCode.writeAttributionFile(
+ encodeURIComponent(attributionData)
+ );
+ } else if (AppConstants.platform === "macosx") {
+ await this.setAttributionString(encodeURIComponent(attributionData));
+ }
+
+ // Clear cache call is only possible in a testing environment
+ Services.env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
+
+ // Clear and refresh Attribution, and then fetch the messages again to update
+ lazy.AttributionCode._clearCache();
+ await lazy.AttributionCode.getAttrDataAsync();
+ await this._updateMessageProviders();
+ return this.loadMessagesFromAllProviders();
+ }
+
+ async sendPBNewTabMessage({ tabId, hideDefault }) {
+ let message = null;
+ const PromoInfo = {
+ FOCUS: { enabledPref: "browser.promo.focus.enabled" },
+ VPN: { enabledPref: "browser.vpn_promo.enabled" },
+ PIN: { enabledPref: "browser.promo.pin.enabled" },
+ COOKIE_BANNERS: { enabledPref: "browser.promo.cookiebanners.enabled" },
+ };
+ await this.loadMessagesFromAllProviders();
+
+ // If message has hideDefault property set to true
+ // remove from state all pb_newtab messages with type default
+ if (hideDefault) {
+ await this.setState(state => ({
+ messages: state.messages.filter(
+ m => !(m.template === "pb_newtab" && m.type === "default")
+ ),
+ }));
+ }
+
+ // Remove from state pb_newtab messages with PromoType disabled
+ await this.setState(state => ({
+ messages: state.messages.filter(
+ m =>
+ !(
+ m.template === "pb_newtab" &&
+ !Services.prefs.getBoolPref(
+ PromoInfo[m.content?.promoType]?.enabledPref,
+ true
+ )
+ )
+ ),
+ }));
+
+ const telemetryObject = { tabId };
+ TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
+ message = await this.handleMessageRequest({
+ template: "pb_newtab",
+ });
+ TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
+
+ // Format urls if any are defined
+ ["infoLinkUrl"].forEach(key => {
+ if (message?.content?.[key]) {
+ message.content[key] = Services.urlFormatter.formatURL(
+ message.content[key]
+ );
+ }
+ });
+
+ return { message };
+ }
+
+ _recordReachEvent(message) {
+ const messageGroup = message.forReachEvent.group;
+ // Events telemetry only accepts understores for the event `object`
+ const underscored = messageGroup.split("-").join("_");
+ const extra = { branches: message.branchSlug };
+ Services.telemetry.recordEvent(
+ REACH_EVENT_CATEGORY,
+ REACH_EVENT_METHOD,
+ underscored,
+ message.experimentSlug,
+ extra
+ );
+ }
+
+ /**
+ * Fire a trigger, look for a matching message, and route it to the
+ * appropriate message handler/messaging surface.
+ * @param {object} trigger
+ * @param {string} trigger.id the name of the trigger, e.g. "openURL"
+ * @param {object} [trigger.param] an object with host, url, type, etc. keys
+ * whose values are used to match against the message's trigger params
+ * @param {object} [trigger.context] an object with data about the source of
+ * the trigger, matched against the message's targeting expression
+ * @param {MozBrowser} trigger.browser the browser to route messages to
+ * @param {number} [trigger.tabId] identifier used only for exposure testing
+ * @param {boolean} [skipLoadingMessages=false] pass true to skip looking for
+ * new messages. use when calling from loadMessagesFromAllProviders to avoid
+ * recursion. we call this from loadMessagesFromAllProviders in order to
+ * fire the messagesLoaded trigger.
+ * @returns {Promise<object>}
+ * @resolves {message} an object with the routed message
+ */
+ async sendTriggerMessage(
+ { tabId, browser, ...trigger },
+ skipLoadingMessages = false
+ ) {
+ if (!skipLoadingMessages) {
+ await this.loadMessagesFromAllProviders();
+ }
+ const telemetryObject = { tabId };
+ TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
+ // Return all the messages so that it can record the Reach event
+ const messages =
+ (await this.handleMessageRequest({
+ triggerId: trigger.id,
+ triggerParam: trigger.param,
+ triggerContext: trigger.context,
+ returnAll: true,
+ })) || [];
+ TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
+
+ // Record the Reach event for all the messages with `forReachEvent`,
+ // only send the first message without forReachEvent to the target
+ const nonReachMessages = [];
+ for (const message of messages) {
+ if (message.forReachEvent) {
+ if (!message.forReachEvent.sent) {
+ this._recordReachEvent(message);
+ message.forReachEvent.sent = true;
+ }
+ } else {
+ nonReachMessages.push(message);
+ }
+ }
+
+ if (nonReachMessages.length) {
+ let featureId = nonReachMessages[0]._nimbusFeature;
+ if (featureId) {
+ lazy.NimbusFeatures[featureId].recordExposureEvent({ once: true });
+ }
+ }
+
+ return this.routeCFRMessage(
+ nonReachMessages[0] || null,
+ browser,
+ trigger,
+ false
+ );
+ }
+
+ async forceWNPanel(browser) {
+ let win = browser.ownerGlobal;
+ await lazy.ToolbarPanelHub.enableToolbarButton();
+
+ win.PanelUI.showSubView(
+ "PanelUI-whatsNew",
+ win.document.getElementById("whats-new-menu-button")
+ );
+
+ let panel = win.document.getElementById("customizationui-widget-panel");
+ // Set the attribute to keep the panel open
+ panel.setAttribute("noautohide", true);
+ }
+
+ async closeWNPanel(browser) {
+ let win = browser.ownerGlobal;
+ let panel = win.document.getElementById("customizationui-widget-panel");
+ // Set the attribute to allow the panel to close
+ panel.setAttribute("noautohide", false);
+ // Removing the button is enough to close the panel.
+ await lazy.ToolbarPanelHub._hideToolbarButton(win);
+ }
+
+ async _onExperimentEnrollmentsUpdated() {
+ const experimentProvider = this.state.providers.find(
+ p => p.id === "messaging-experiments"
+ );
+ if (!experimentProvider?.enabled) {
+ return;
+ }
+ await this.loadMessagesFromAllProviders([experimentProvider]);
+ }
+
+ async forcePBWindow(browser, msg) {
+ const privateBrowserOpener = await new Promise(
+ (
+ resolveOnContentBrowserCreated // wrap this in a promise to give back the right browser
+ ) =>
+ browser.ownerGlobal.openTrustedLinkIn(
+ "about:privatebrowsing?debug",
+ "window",
+ {
+ private: true,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal({}),
+ csp: null,
+ resolveOnContentBrowserCreated,
+ opener: "devtools",
+ }
+ )
+ );
+
+ lazy.setTimeout(() => {
+ // setTimeout is necessary to make sure the private browsing window has a chance to open before the message is sent
+ privateBrowserOpener.browsingContext.currentWindowGlobal
+ .getActor("AboutPrivateBrowsing")
+ .sendAsyncMessage("ShowDevToolsMessage", msg);
+ }, 100);
+
+ return privateBrowserOpener;
+ }
+}
+
+/**
+ * ASRouter - singleton instance of _ASRouter that controls all messages
+ * in the new tab page.
+ */
+export const ASRouter = new _ASRouter();
diff --git a/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs b/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs
new file mode 100644
index 0000000000..e81380b1e2
--- /dev/null
+++ b/browser/components/asrouter/modules/ASRouterDefaultConfig.sys.mjs
@@ -0,0 +1,64 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ASRouter } from "resource:///modules/asrouter/ASRouter.sys.mjs";
+import { TelemetryFeed } from "resource://activity-stream/lib/TelemetryFeed.sys.mjs";
+import { ASRouterParentProcessMessageHandler } from "resource:///modules/asrouter/ASRouterParentProcessMessageHandler.sys.mjs";
+
+// We use importESModule here instead of static import so that
+// the Karma test environment won't choke on this module. This
+// is because the Karma test environment does not actually rely
+// on SpecialMessageActions, and overrides importESModule to be
+// a no-op (which can't be done for a static import statement).
+
+// eslint-disable-next-line mozilla/use-static-import
+const { SpecialMessageActions } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs"
+);
+
+import { ASRouterPreferences } from "resource:///modules/asrouter/ASRouterPreferences.sys.mjs";
+import { QueryCache } from "resource:///modules/asrouter/ASRouterTargeting.sys.mjs";
+import { ActivityStreamStorage } from "resource://activity-stream/lib/ActivityStreamStorage.sys.mjs";
+
+const createStorage = async telemetryFeed => {
+ // "snippets" is the name of one storage space, but these days it is used
+ // not for snippet-related data (snippets were removed in bug 1715158),
+ // but storage for impression or session data for all ASRouter messages.
+ //
+ // We keep the name "snippets" to avoid having to do an IndexedDB database
+ // migration.
+ const dbStore = new ActivityStreamStorage({
+ storeNames: ["sectionPrefs", "snippets"],
+ telemetry: {
+ handleUndesiredEvent: e => telemetryFeed.SendASRouterUndesiredEvent(e),
+ },
+ });
+ // Accessing the db causes the object stores to be created / migrated.
+ // This needs to happen before other instances try to access the db, which
+ // would update only a subset of the stores to the latest version.
+ try {
+ await dbStore.db; // eslint-disable-line no-unused-expressions
+ } catch (e) {
+ return Promise.reject(e);
+ }
+ return dbStore.getDbTable("snippets");
+};
+
+export const ASRouterDefaultConfig = () => {
+ const router = ASRouter;
+ const telemetry = new TelemetryFeed();
+ const messageHandler = new ASRouterParentProcessMessageHandler({
+ router,
+ preferences: ASRouterPreferences,
+ specialMessageActions: SpecialMessageActions,
+ queryCache: QueryCache,
+ sendTelemetry: telemetry.onAction.bind(telemetry),
+ });
+ return {
+ router,
+ messageHandler,
+ createStorage: createStorage.bind(null, telemetry),
+ };
+};
diff --git a/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs b/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs
new file mode 100644
index 0000000000..d0fdbfdae4
--- /dev/null
+++ b/browser/components/asrouter/modules/ASRouterNewTabHook.sys.mjs
@@ -0,0 +1,117 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+class ASRouterNewTabHookInstance {
+ constructor() {
+ this._newTabMessageHandler = null;
+ this._parentProcessMessageHandler = null;
+ this._router = null;
+ this._clearChildMessages = (...params) =>
+ this._newTabMessageHandler === null
+ ? Promise.resolve()
+ : this._newTabMessageHandler.clearChildMessages(...params);
+ this._clearChildProviders = (...params) =>
+ this._newTabMessageHandler === null
+ ? Promise.resolve()
+ : this._newTabMessageHandler.clearChildProviders(...params);
+ this._updateAdminState = (...params) =>
+ this._newTabMessageHandler === null
+ ? Promise.resolve()
+ : this._newTabMessageHandler.updateAdminState(...params);
+ }
+
+ /**
+ * Params:
+ * object - {
+ * messageHandler: message handler for parent process messages
+ * {
+ * handleCFRAction: Responds to CFR action and returns a Promise
+ * handleTelemetry: Logs telemetry events and returns nothing
+ * },
+ * router: ASRouter instance
+ * createStorage: function to create DB storage for ASRouter
+ * }
+ */
+ async initialize({ messageHandler, router, createStorage }) {
+ this._parentProcessMessageHandler = messageHandler;
+ this._router = router;
+ if (!this._router.initialized) {
+ const storage = await createStorage();
+ await this._router.init({
+ storage,
+ sendTelemetry: this._parentProcessMessageHandler.handleTelemetry,
+ dispatchCFRAction: this._parentProcessMessageHandler.handleCFRAction,
+ clearChildMessages: this._clearChildMessages,
+ clearChildProviders: this._clearChildProviders,
+ updateAdminState: this._updateAdminState,
+ });
+ }
+ }
+
+ destroy() {
+ if (this._router?.initialized) {
+ this.disconnect();
+ this._router.uninit();
+ }
+ }
+
+ /**
+ * Connects new tab message handler to hook.
+ * Note: Should only ever be called on an initialized instance
+ * Params:
+ * newTabMessageHandler - {
+ * clearChildMessages: clears child messages and returns Promise
+ * clearChildProviders: clears child providers and returns Promise.
+ * updateAdminState: updates admin state and returns Promise
+ * }
+ * Returns: parentProcessMessageHandler
+ */
+ connect(newTabMessageHandler) {
+ this._newTabMessageHandler = newTabMessageHandler;
+ return this._parentProcessMessageHandler;
+ }
+
+ /**
+ * Disconnects new tab message handler from hook.
+ */
+ disconnect() {
+ this._newTabMessageHandler = null;
+ }
+}
+
+class AwaitSingleton {
+ constructor() {
+ this.instance = null;
+ const initialized = new Promise(resolve => {
+ this.setInstance = instance => {
+ this.setInstance = () => {};
+ this.instance = instance;
+ resolve(instance);
+ };
+ });
+ this.getInstance = () => initialized;
+ }
+}
+
+export const ASRouterNewTabHook = (() => {
+ const singleton = new AwaitSingleton();
+ const instance = new ASRouterNewTabHookInstance();
+ return {
+ getInstance: singleton.getInstance,
+
+ /**
+ * Param:
+ * params - see ASRouterNewTabHookInstance.init
+ */
+ createInstance: async params => {
+ await instance.initialize(params);
+ singleton.setInstance(instance);
+ },
+
+ destroy: () => {
+ instance.destroy();
+ },
+ };
+})();
diff --git a/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs b/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs
new file mode 100644
index 0000000000..c2f5fcd884
--- /dev/null
+++ b/browser/components/asrouter/modules/ASRouterParentProcessMessageHandler.sys.mjs
@@ -0,0 +1,171 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { ASRouterPreferences } from "resource:///modules/asrouter/ASRouterPreferences.sys.mjs";
+import { MESSAGE_TYPE_HASH as msg } from "resource:///modules/asrouter/ActorConstants.sys.mjs";
+
+export class ASRouterParentProcessMessageHandler {
+ constructor({
+ router,
+ preferences,
+ specialMessageActions,
+ queryCache,
+ sendTelemetry,
+ }) {
+ this._router = router;
+ this._preferences = preferences;
+ this._specialMessageActions = specialMessageActions;
+ this._queryCache = queryCache;
+ this.handleTelemetry = sendTelemetry;
+ this.handleMessage = this.handleMessage.bind(this);
+ this.handleCFRAction = this.handleCFRAction.bind(this);
+ }
+
+ handleCFRAction({ type, data }, browser) {
+ switch (type) {
+ case msg.INFOBAR_TELEMETRY:
+ case msg.TOOLBAR_BADGE_TELEMETRY:
+ case msg.TOOLBAR_PANEL_TELEMETRY:
+ case msg.MOMENTS_PAGE_TELEMETRY:
+ case msg.DOORHANGER_TELEMETRY:
+ case msg.SPOTLIGHT_TELEMETRY:
+ case msg.TOAST_NOTIFICATION_TELEMETRY: {
+ return this.handleTelemetry({ type, data });
+ }
+ default: {
+ return this.handleMessage(type, data, { browser });
+ }
+ }
+ }
+
+ handleMessage(name, data, { id: tabId, browser } = { browser: null }) {
+ switch (name) {
+ case msg.AS_ROUTER_TELEMETRY_USER_EVENT:
+ return this.handleTelemetry({
+ type: msg.AS_ROUTER_TELEMETRY_USER_EVENT,
+ data,
+ });
+ case msg.BLOCK_MESSAGE_BY_ID: {
+ ASRouterPreferences.console.debug(
+ "handleMesssage(): about to block, data = ",
+ data
+ );
+ ASRouterPreferences.console.trace();
+
+ // Block the message but don't dismiss it in case the action taken has
+ // another state that needs to be visible
+ return this._router
+ .blockMessageById(data.id)
+ .then(() => !data.preventDismiss);
+ }
+ case msg.USER_ACTION: {
+ return this._specialMessageActions.handleAction(data, browser);
+ }
+ case msg.IMPRESSION: {
+ return this._router.addImpression(data);
+ }
+ case msg.TRIGGER: {
+ return this._router.sendTriggerMessage({
+ ...(data && data.trigger),
+ tabId,
+ browser,
+ });
+ }
+ case msg.PBNEWTAB_MESSAGE_REQUEST: {
+ return this._router.sendPBNewTabMessage({
+ ...data,
+ tabId,
+ browser,
+ });
+ }
+
+ // ADMIN Messages
+ case msg.ADMIN_CONNECT_STATE: {
+ if (data && data.endpoint) {
+ return this._router.loadMessagesFromAllProviders();
+ }
+ return this._router.updateTargetingParameters();
+ }
+ case msg.UNBLOCK_MESSAGE_BY_ID: {
+ return this._router.unblockMessageById(data.id);
+ }
+ case msg.UNBLOCK_ALL: {
+ return this._router.unblockAll();
+ }
+ case msg.BLOCK_BUNDLE: {
+ return this._router.blockMessageById(data.bundle.map(b => b.id));
+ }
+ case msg.UNBLOCK_BUNDLE: {
+ return this._router.setState(state => {
+ const messageBlockList = [...state.messageBlockList];
+ for (let message of data.bundle) {
+ messageBlockList.splice(messageBlockList.indexOf(message.id), 1);
+ }
+ this._router._storage.set("messageBlockList", messageBlockList);
+ return { messageBlockList };
+ });
+ }
+ case msg.DISABLE_PROVIDER: {
+ this._preferences.enableOrDisableProvider(data, false);
+ return Promise.resolve();
+ }
+ case msg.ENABLE_PROVIDER: {
+ this._preferences.enableOrDisableProvider(data, true);
+ return Promise.resolve();
+ }
+ case msg.EVALUATE_JEXL_EXPRESSION: {
+ return this._router.evaluateExpression(data);
+ }
+ case msg.EXPIRE_QUERY_CACHE: {
+ this._queryCache.expireAll();
+ return Promise.resolve();
+ }
+ case msg.FORCE_ATTRIBUTION: {
+ return this._router.forceAttribution(data);
+ }
+ case msg.FORCE_PRIVATE_BROWSING_WINDOW: {
+ return this._router.forcePBWindow(browser, data.message);
+ }
+ case msg.FORCE_WHATSNEW_PANEL: {
+ return this._router.forceWNPanel(browser);
+ }
+ case msg.CLOSE_WHATSNEW_PANEL: {
+ return this._router.closeWNPanel(browser);
+ }
+ case msg.MODIFY_MESSAGE_JSON: {
+ return this._router.routeCFRMessage(data.content, browser, data, true);
+ }
+ case msg.OVERRIDE_MESSAGE: {
+ return this._router.setMessageById(data, true, browser);
+ }
+ case msg.RESET_PROVIDER_PREF: {
+ this._preferences.resetProviderPref();
+ return Promise.resolve();
+ }
+ case msg.SET_PROVIDER_USER_PREF: {
+ this._preferences.setUserPreference(data.id, data.value);
+ return Promise.resolve();
+ }
+ case msg.RESET_GROUPS_STATE: {
+ return this._router
+ .resetGroupsState(data)
+ .then(() => this._router.loadMessagesFromAllProviders());
+ }
+ case msg.RESET_MESSAGE_STATE: {
+ return this._router.resetMessageState();
+ }
+ case msg.RESET_SCREEN_IMPRESSIONS: {
+ return this._router.resetScreenImpressions();
+ }
+ case msg.EDIT_STATE: {
+ const [[key, value]] = Object.entries(data);
+ return this._router.editState(key, value);
+ }
+ default: {
+ return Promise.reject(new Error(`Unknown message received: ${name}`));
+ }
+ }
+ }
+}
diff --git a/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs b/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs
new file mode 100644
index 0000000000..c7617d80c0
--- /dev/null
+++ b/browser/components/asrouter/modules/ASRouterPreferences.sys.mjs
@@ -0,0 +1,241 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const PROVIDER_PREF_BRANCH =
+ "browser.newtabpage.activity-stream.asrouter.providers.";
+const DEVTOOLS_PREF =
+ "browser.newtabpage.activity-stream.asrouter.devtoolsEnabled";
+
+/**
+ * Use `ASRouterPreferences.console.debug()` and friends from ASRouter files to
+ * log messages during development. See LOG_LEVELS in ConsoleAPI.jsm for the
+ * available methods as well as the available values for this pref.
+ */
+const DEBUG_PREF = "browser.newtabpage.activity-stream.asrouter.debugLogLevel";
+
+const FXA_USERNAME_PREF = "services.sync.username";
+
+const DEFAULT_STATE = {
+ _initialized: false,
+ _providers: null,
+ _providerPrefBranch: PROVIDER_PREF_BRANCH,
+ _devtoolsEnabled: null,
+ _devtoolsPref: DEVTOOLS_PREF,
+};
+
+const USER_PREFERENCES = {
+ cfrAddons: "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
+ cfrFeatures:
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+};
+
+// Preferences that influence targeting attributes. When these change we need
+// to re-evaluate if the message targeting still matches
+export const TARGETING_PREFERENCES = [FXA_USERNAME_PREF];
+
+export const TEST_PROVIDERS = [
+ {
+ id: "panel_local_testing",
+ type: "local",
+ localProvider: "PanelTestProvider",
+ enabled: true,
+ },
+];
+
+export class _ASRouterPreferences {
+ constructor() {
+ Object.assign(this, DEFAULT_STATE);
+ this._callbacks = new Set();
+
+ ChromeUtils.defineLazyGetter(this, "console", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ /* eslint-disable mozilla/use-console-createInstance */
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ let consoleOptions = {
+ maxLogLevel: "error",
+ maxLogLevelPref: DEBUG_PREF,
+ prefix: "ASRouter",
+ };
+ return new ConsoleAPI(consoleOptions);
+ });
+ }
+
+ _transformPersonalizedCfrScores(value) {
+ let result = {};
+ try {
+ result = JSON.parse(value);
+ } catch (e) {
+ console.error(e);
+ }
+ return result;
+ }
+
+ _getProviderConfig() {
+ const prefList = Services.prefs.getChildList(this._providerPrefBranch);
+ return prefList.reduce((filtered, pref) => {
+ let value;
+ try {
+ value = JSON.parse(Services.prefs.getStringPref(pref, ""));
+ } catch (e) {
+ console.error(
+ `Could not parse ASRouter preference. Try resetting ${pref} in about:config.`
+ );
+ }
+ if (value) {
+ filtered.push(value);
+ }
+ return filtered;
+ }, []);
+ }
+
+ get providers() {
+ if (!this._initialized || this._providers === null) {
+ const config = this._getProviderConfig();
+ const providers = config.map(provider => Object.freeze(provider));
+ if (this.devtoolsEnabled) {
+ providers.unshift(...TEST_PROVIDERS);
+ }
+ this._providers = Object.freeze(providers);
+ }
+
+ return this._providers;
+ }
+
+ enableOrDisableProvider(id, value) {
+ const providers = this._getProviderConfig();
+ const config = providers.find(p => p.id === id);
+ if (!config) {
+ console.error(
+ `Cannot set enabled state for '${id}' because the pref ${this._providerPrefBranch}${id} does not exist or is not correctly formatted.`
+ );
+ return;
+ }
+
+ Services.prefs.setStringPref(
+ this._providerPrefBranch + id,
+ JSON.stringify({ ...config, enabled: value })
+ );
+ }
+
+ resetProviderPref() {
+ for (const pref of Services.prefs.getChildList(this._providerPrefBranch)) {
+ Services.prefs.clearUserPref(pref);
+ }
+ for (const id of Object.keys(USER_PREFERENCES)) {
+ Services.prefs.clearUserPref(USER_PREFERENCES[id]);
+ }
+ }
+
+ /**
+ * Bug 1800087 - Migrate the ASRouter message provider prefs' values to the
+ * current format (provider.bucket -> provider.collection).
+ *
+ * TODO (Bug 1800937): Remove migration code after the next watershed release.
+ */
+ _migrateProviderPrefs() {
+ const prefList = Services.prefs.getChildList(this._providerPrefBranch);
+ for (const pref of prefList) {
+ if (!Services.prefs.prefHasUserValue(pref)) {
+ continue;
+ }
+ try {
+ let value = JSON.parse(Services.prefs.getStringPref(pref, ""));
+ if (value && "bucket" in value && !("collection" in value)) {
+ const { bucket, ...rest } = value;
+ Services.prefs.setStringPref(
+ pref,
+ JSON.stringify({
+ ...rest,
+ collection: bucket,
+ })
+ );
+ }
+ } catch (e) {
+ Services.prefs.clearUserPref(pref);
+ }
+ }
+ }
+
+ get devtoolsEnabled() {
+ if (!this._initialized || this._devtoolsEnabled === null) {
+ this._devtoolsEnabled = Services.prefs.getBoolPref(
+ this._devtoolsPref,
+ false
+ );
+ }
+ return this._devtoolsEnabled;
+ }
+
+ observe(aSubject, aTopic, aPrefName) {
+ if (aPrefName && aPrefName.startsWith(this._providerPrefBranch)) {
+ this._providers = null;
+ } else if (aPrefName === this._devtoolsPref) {
+ this._providers = null;
+ this._devtoolsEnabled = null;
+ }
+ this._callbacks.forEach(cb => cb(aPrefName));
+ }
+
+ getUserPreference(name) {
+ const prefName = USER_PREFERENCES[name] || name;
+ return Services.prefs.getBoolPref(prefName, true);
+ }
+
+ getAllUserPreferences() {
+ const values = {};
+ for (const id of Object.keys(USER_PREFERENCES)) {
+ values[id] = this.getUserPreference(id);
+ }
+ return values;
+ }
+
+ setUserPreference(providerId, value) {
+ if (!USER_PREFERENCES[providerId]) {
+ return;
+ }
+ Services.prefs.setBoolPref(USER_PREFERENCES[providerId], value);
+ }
+
+ addListener(callback) {
+ this._callbacks.add(callback);
+ }
+
+ removeListener(callback) {
+ this._callbacks.delete(callback);
+ }
+
+ init() {
+ if (this._initialized) {
+ return;
+ }
+ this._migrateProviderPrefs();
+ Services.prefs.addObserver(this._providerPrefBranch, this);
+ Services.prefs.addObserver(this._devtoolsPref, this);
+ for (const id of Object.keys(USER_PREFERENCES)) {
+ Services.prefs.addObserver(USER_PREFERENCES[id], this);
+ }
+ for (const targetingPref of TARGETING_PREFERENCES) {
+ Services.prefs.addObserver(targetingPref, this);
+ }
+ this._initialized = true;
+ }
+
+ uninit() {
+ if (this._initialized) {
+ Services.prefs.removeObserver(this._providerPrefBranch, this);
+ Services.prefs.removeObserver(this._devtoolsPref, this);
+ for (const id of Object.keys(USER_PREFERENCES)) {
+ Services.prefs.removeObserver(USER_PREFERENCES[id], this);
+ }
+ for (const targetingPref of TARGETING_PREFERENCES) {
+ Services.prefs.removeObserver(targetingPref, this);
+ }
+ }
+ Object.assign(this, DEFAULT_STATE);
+ this._callbacks.clear();
+ }
+}
+
+export const ASRouterPreferences = new _ASRouterPreferences();
diff --git a/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs
new file mode 100644
index 0000000000..a262f8911e
--- /dev/null
+++ b/browser/components/asrouter/modules/ASRouterTargeting.sys.mjs
@@ -0,0 +1,1308 @@
+/* This Source Code Form is subject to the terms of the Mozilla PublicddonMa
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const FXA_ENABLED_PREF = "identity.fxaccounts.enabled";
+const DISTRIBUTION_ID_PREF = "distribution.id";
+const DISTRIBUTION_ID_CHINA_REPACK = "MozillaOnline";
+
+// We use importESModule here instead of static import so that
+// the Karma test environment won't choke on this module. This
+// is because the Karma test environment already stubs out
+// XPCOMUtils, AppConstants, NewTabUtils and ShellService, and
+// overrides importESModule to be a no-op (which can't be done
+// for a static import statement).
+
+// eslint-disable-next-line mozilla/use-static-import
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+// eslint-disable-next-line mozilla/use-static-import
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+// eslint-disable-next-line mozilla/use-static-import
+const { NewTabUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/NewTabUtils.sys.mjs"
+);
+
+// eslint-disable-next-line mozilla/use-static-import
+const { ShellService } = ChromeUtils.importESModule(
+ "resource:///modules/ShellService.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs",
+ ASRouterPreferences:
+ "resource:///modules/asrouter/ASRouterPreferences.sys.mjs",
+ AttributionCode: "resource:///modules/AttributionCode.sys.mjs",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ HomePage: "resource:///modules/HomePage.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs",
+ WindowsLaunchOnLogin: "resource://gre/modules/WindowsLaunchOnLogin.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "cfrFeaturesUserPref",
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "cfrAddonsUserPref",
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isWhatsNewPanelEnabled",
+ "browser.messaging-system.whatsNewPanel.enabled",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "hasAccessedFxAPanel",
+ "identity.fxaccounts.toolbar.accessed",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "clientsDevicesDesktop",
+ "services.sync.clients.devices.desktop",
+ 0
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "clientsDevicesMobile",
+ "services.sync.clients.devices.mobile",
+ 0
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "syncNumClients",
+ "services.sync.numClients",
+ 0
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "devtoolsSelfXSSCount",
+ "devtools.selfxss.count",
+ 0
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isFxAEnabled",
+ FXA_ENABLED_PREF,
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "isXPIInstallEnabled",
+ "xpinstall.enabled",
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "hasMigratedBookmarks",
+ "browser.migrate.interactions.bookmarks",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "hasMigratedCSVPasswords",
+ "browser.migrate.interactions.csvpasswords",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "hasMigratedHistory",
+ "browser.migrate.interactions.history",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "hasMigratedPasswords",
+ "browser.migrate.interactions.passwords",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "useEmbeddedMigrationWizard",
+ "browser.migrate.content-modal.about-welcome-behavior",
+ "default",
+ null,
+ behaviorString => {
+ return behaviorString === "embedded";
+ }
+);
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ AUS: ["@mozilla.org/updates/update-service;1", "nsIApplicationUpdateService"],
+ BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"],
+ TrackingDBService: [
+ "@mozilla.org/tracking-db-service;1",
+ "nsITrackingDBService",
+ ],
+ UpdateCheckSvc: ["@mozilla.org/updates/update-checker;1", "nsIUpdateChecker"],
+});
+
+const FXA_USERNAME_PREF = "services.sync.username";
+
+const { activityStreamProvider: asProvider } = NewTabUtils;
+
+const FXA_ATTACHED_CLIENTS_UPDATE_INTERVAL = 4 * 60 * 60 * 1000; // Four hours
+const FRECENT_SITES_UPDATE_INTERVAL = 6 * 60 * 60 * 1000; // Six hours
+const FRECENT_SITES_IGNORE_BLOCKED = false;
+const FRECENT_SITES_NUM_ITEMS = 25;
+const FRECENT_SITES_MIN_FRECENCY = 100;
+
+const CACHE_EXPIRATION = 5 * 60 * 1000;
+const jexlEvaluationCache = new Map();
+
+/**
+ * CachedTargetingGetter
+ * @param property {string} Name of the method
+ * @param options {any=} Options passed to the method
+ * @param updateInterval {number?} Update interval for query. Defaults to FRECENT_SITES_UPDATE_INTERVAL
+ */
+export function CachedTargetingGetter(
+ property,
+ options = null,
+ updateInterval = FRECENT_SITES_UPDATE_INTERVAL,
+ getter = asProvider
+) {
+ return {
+ _lastUpdated: 0,
+ _value: null,
+ // For testing
+ expire() {
+ this._lastUpdated = 0;
+ this._value = null;
+ },
+ async get() {
+ const now = Date.now();
+ if (now - this._lastUpdated >= updateInterval) {
+ this._value = await getter[property](options);
+ this._lastUpdated = now;
+ }
+ return this._value;
+ },
+ };
+}
+
+function CacheListAttachedOAuthClients() {
+ return {
+ _lastUpdated: 0,
+ _value: null,
+ expire() {
+ this._lastUpdated = 0;
+ this._value = null;
+ },
+ get() {
+ const now = Date.now();
+ if (now - this._lastUpdated >= FXA_ATTACHED_CLIENTS_UPDATE_INTERVAL) {
+ this._value = new Promise(resolve => {
+ lazy.fxAccounts
+ .listAttachedOAuthClients()
+ .then(clients => {
+ resolve(clients);
+ })
+ .catch(() => resolve([]));
+ });
+ this._lastUpdated = now;
+ }
+ return this._value;
+ },
+ };
+}
+
+function CheckBrowserNeedsUpdate(
+ updateInterval = FRECENT_SITES_UPDATE_INTERVAL
+) {
+ const checker = {
+ _lastUpdated: 0,
+ _value: null,
+ // For testing. Avoid update check network call.
+ setUp(value) {
+ this._lastUpdated = Date.now();
+ this._value = value;
+ },
+ expire() {
+ this._lastUpdated = 0;
+ this._value = null;
+ },
+ async get() {
+ const now = Date.now();
+ if (
+ !AppConstants.MOZ_UPDATER ||
+ now - this._lastUpdated < updateInterval
+ ) {
+ return this._value;
+ }
+ if (!lazy.AUS.canCheckForUpdates) {
+ return false;
+ }
+ this._lastUpdated = now;
+ let check = lazy.UpdateCheckSvc.checkForUpdates(
+ lazy.UpdateCheckSvc.FOREGROUND_CHECK
+ );
+ let result = await check.result;
+ if (!result.succeeded) {
+ lazy.ASRouterPreferences.console.error(
+ "CheckBrowserNeedsUpdate failed :>> ",
+ result.request
+ );
+ return false;
+ }
+ checker._value = !!result.updates.length;
+ return checker._value;
+ },
+ };
+
+ return checker;
+}
+
+export const QueryCache = {
+ expireAll() {
+ Object.keys(this.queries).forEach(query => {
+ this.queries[query].expire();
+ });
+ Object.keys(this.getters).forEach(key => {
+ this.getters[key].expire();
+ });
+ },
+ queries: {
+ TopFrecentSites: new CachedTargetingGetter("getTopFrecentSites", {
+ ignoreBlocked: FRECENT_SITES_IGNORE_BLOCKED,
+ numItems: FRECENT_SITES_NUM_ITEMS,
+ topsiteFrecency: FRECENT_SITES_MIN_FRECENCY,
+ onePerDomain: true,
+ includeFavicon: false,
+ }),
+ TotalBookmarksCount: new CachedTargetingGetter("getTotalBookmarksCount"),
+ CheckBrowserNeedsUpdate: new CheckBrowserNeedsUpdate(),
+ RecentBookmarks: new CachedTargetingGetter("getRecentBookmarks"),
+ ListAttachedOAuthClients: new CacheListAttachedOAuthClients(),
+ UserMonthlyActivity: new CachedTargetingGetter("getUserMonthlyActivity"),
+ },
+ getters: {
+ doesAppNeedPin: new CachedTargetingGetter(
+ "doesAppNeedPin",
+ null,
+ FRECENT_SITES_UPDATE_INTERVAL,
+ ShellService
+ ),
+ doesAppNeedPrivatePin: new CachedTargetingGetter(
+ "doesAppNeedPin",
+ true,
+ FRECENT_SITES_UPDATE_INTERVAL,
+ ShellService
+ ),
+ isDefaultBrowser: new CachedTargetingGetter(
+ "isDefaultBrowser",
+ null,
+ FRECENT_SITES_UPDATE_INTERVAL,
+ ShellService
+ ),
+ currentThemes: new CachedTargetingGetter(
+ "getAddonsByTypes",
+ ["theme"],
+ FRECENT_SITES_UPDATE_INTERVAL,
+ lazy.AddonManager // eslint-disable-line mozilla/valid-lazy
+ ),
+ isDefaultHTMLHandler: new CachedTargetingGetter(
+ "isDefaultHandlerFor",
+ [".html"],
+ FRECENT_SITES_UPDATE_INTERVAL,
+ ShellService
+ ),
+ isDefaultPDFHandler: new CachedTargetingGetter(
+ "isDefaultHandlerFor",
+ [".pdf"],
+ FRECENT_SITES_UPDATE_INTERVAL,
+ ShellService
+ ),
+ defaultPDFHandler: new CachedTargetingGetter(
+ "getDefaultPDFHandler",
+ null,
+ FRECENT_SITES_UPDATE_INTERVAL,
+ ShellService
+ ),
+ },
+};
+
+/**
+ * sortMessagesByWeightedRank
+ *
+ * Each message has an associated weight, which is guaranteed to be strictly
+ * positive. Sort the messages so that higher weighted messages are more likely
+ * to come first.
+ *
+ * Specifically, sort them so that the probability of message x_1 with weight
+ * w_1 appearing before message x_2 with weight w_2 is (w_1 / (w_1 + w_2)).
+ *
+ * This is equivalent to requiring that x_1 appearing before x_2 is (w_1 / w_2)
+ * "times" as likely as x_2 appearing before x_1.
+ *
+ * See Bug 1484996, Comment 2 for a justification of the method.
+ *
+ * @param {Array} messages - A non-empty array of messages to sort, all with
+ * strictly positive weights
+ * @returns the sorted array
+ */
+function sortMessagesByWeightedRank(messages) {
+ return messages
+ .map(message => ({
+ message,
+ rank: Math.pow(Math.random(), 1 / message.weight),
+ }))
+ .sort((a, b) => b.rank - a.rank)
+ .map(({ message }) => message);
+}
+
+/**
+ * getSortedMessages - Given an array of Messages, applies sorting and filtering rules
+ * in expected order.
+ *
+ * @param {Array<Message>} messages
+ * @param {{}} options
+ * @param {boolean} options.ordered - Should .order be used instead of random weighted sorting?
+ * @returns {Array<Message>}
+ */
+export function getSortedMessages(messages, options = {}) {
+ let { ordered } = { ordered: false, ...options };
+ let result = messages;
+
+ if (!ordered) {
+ result = sortMessagesByWeightedRank(result);
+ }
+
+ result.sort((a, b) => {
+ // Next, sort by priority
+ if (a.priority > b.priority || (!isNaN(a.priority) && isNaN(b.priority))) {
+ return -1;
+ }
+ if (a.priority < b.priority || (isNaN(a.priority) && !isNaN(b.priority))) {
+ return 1;
+ }
+
+ // Sort messages with targeting expressions higher than those with none
+ if (a.targeting && !b.targeting) {
+ return -1;
+ }
+ if (!a.targeting && b.targeting) {
+ return 1;
+ }
+
+ // Next, sort by order *ascending* if ordered = true
+ if (ordered) {
+ if (a.order > b.order || (!isNaN(a.order) && isNaN(b.order))) {
+ return 1;
+ }
+ if (a.order < b.order || (isNaN(a.order) && !isNaN(b.order))) {
+ return -1;
+ }
+ }
+
+ return 0;
+ });
+
+ return result;
+}
+
+/**
+ * parseAboutPageURL - Parse a URL string retrieved from about:home and about:new, returns
+ * its type (web extenstion or custom url) and the parsed url(s)
+ *
+ * @param {string} url - A URL string for home page or newtab page
+ * @returns {Object} {
+ * isWebExt: boolean,
+ * isCustomUrl: boolean,
+ * urls: Array<{url: string, host: string}>
+ * }
+ */
+function parseAboutPageURL(url) {
+ let ret = {
+ isWebExt: false,
+ isCustomUrl: false,
+ urls: [],
+ };
+ if (url.startsWith("moz-extension://")) {
+ ret.isWebExt = true;
+ ret.urls.push({ url, host: "" });
+ } else {
+ // The home page URL could be either a single URL or a list of "|" separated URLs.
+ // Note that it should work with "about:home" and "about:blank", in which case the
+ // "host" is set as an empty string.
+ for (const _url of url.split("|")) {
+ if (!["about:home", "about:newtab", "about:blank"].includes(_url)) {
+ ret.isCustomUrl = true;
+ }
+ try {
+ const parsedURL = new URL(_url);
+ const host = parsedURL.hostname.replace(/^www\./i, "");
+ ret.urls.push({ url: _url, host });
+ } catch (e) {}
+ }
+ // If URL parsing failed, just return the given url with an empty host
+ if (!ret.urls.length) {
+ ret.urls.push({ url, host: "" });
+ }
+ }
+
+ return ret;
+}
+
+/**
+ * Get the number of records in autofill storage, e.g. credit cards/addresses.
+ *
+ * @param {Object} [data]
+ * @param {string} [data.collectionName]
+ * The name used to specify which collection to retrieve records.
+ * @param {string} [data.searchString]
+ * The typed string for filtering out the matched records.
+ * @param {string} [data.info]
+ * The input autocomplete property's information.
+ * @returns {Promise<number>} The number of matched records.
+ * @see FormAutofillParent._getRecords
+ */
+async function getAutofillRecords(data) {
+ let actor;
+ try {
+ const win = Services.wm.getMostRecentBrowserWindow();
+ actor =
+ win.gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "FormAutofill"
+ );
+ } catch (error) {
+ // If the actor is not available, we can't get the records. We could import
+ // the records directly from FormAutofillStorage to avoid the messiness of
+ // JSActors, but that would import a lot of code for a targeting attribute.
+ return 0;
+ }
+ let records = await actor?.receiveMessage({
+ name: "FormAutofill:GetRecords",
+ data,
+ });
+ return records?.records?.length ?? 0;
+}
+
+// Attribution data can be encoded multiple times so we need this function to
+// get a cleartext value.
+function decodeAttributionValue(value) {
+ if (!value) {
+ return null;
+ }
+
+ let decodedValue = value;
+
+ while (decodedValue.includes("%")) {
+ try {
+ const result = decodeURIComponent(decodedValue);
+ if (result === decodedValue) {
+ break;
+ }
+ decodedValue = result;
+ } catch (e) {
+ break;
+ }
+ }
+
+ return decodedValue;
+}
+
+const TargetingGetters = {
+ get locale() {
+ return Services.locale.appLocaleAsBCP47;
+ },
+ get localeLanguageCode() {
+ return (
+ Services.locale.appLocaleAsBCP47 &&
+ Services.locale.appLocaleAsBCP47.substr(0, 2)
+ );
+ },
+ get browserSettings() {
+ const { settings } = lazy.TelemetryEnvironment.currentEnvironment;
+ return {
+ update: settings.update,
+ };
+ },
+ get attributionData() {
+ // Attribution is determined at startup - so we can use the cached attribution at this point
+ return lazy.AttributionCode.getCachedAttributionData();
+ },
+ get currentDate() {
+ return new Date();
+ },
+ get profileAgeCreated() {
+ return lazy.ProfileAge().then(times => times.created);
+ },
+ get profileAgeReset() {
+ return lazy.ProfileAge().then(times => times.reset);
+ },
+ get usesFirefoxSync() {
+ return Services.prefs.prefHasUserValue(FXA_USERNAME_PREF);
+ },
+ get isFxAEnabled() {
+ return lazy.isFxAEnabled;
+ },
+ get isFxASignedIn() {
+ return new Promise(resolve => {
+ if (!lazy.isFxAEnabled) {
+ resolve(false);
+ }
+ if (Services.prefs.getStringPref(FXA_USERNAME_PREF, "")) {
+ resolve(true);
+ }
+ lazy.fxAccounts
+ .getSignedInUser()
+ .then(data => resolve(!!data))
+ .catch(e => resolve(false));
+ });
+ },
+ get sync() {
+ return {
+ desktopDevices: lazy.clientsDevicesDesktop,
+ mobileDevices: lazy.clientsDevicesMobile,
+ totalDevices: lazy.syncNumClients,
+ };
+ },
+ get xpinstallEnabled() {
+ // This is needed for all add-on recommendations, to know if we allow xpi installs in the first place
+ return lazy.isXPIInstallEnabled;
+ },
+ get addonsInfo() {
+ let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
+ Ci.nsIBackgroundTasks
+ );
+ if (bts?.isBackgroundTaskMode) {
+ return { addons: {}, isFullData: true };
+ }
+
+ return lazy.AddonManager.getActiveAddons(["extension", "service"]).then(
+ ({ addons, fullData }) => {
+ const info = {};
+ for (const addon of addons) {
+ info[addon.id] = {
+ version: addon.version,
+ type: addon.type,
+ isSystem: addon.isSystem,
+ isWebExtension: addon.isWebExtension,
+ };
+ if (fullData) {
+ Object.assign(info[addon.id], {
+ name: addon.name,
+ userDisabled: addon.userDisabled,
+ installDate: addon.installDate,
+ });
+ }
+ }
+ return { addons: info, isFullData: fullData };
+ }
+ );
+ },
+ get searchEngines() {
+ const NONE = { installed: [], current: "" };
+ let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
+ Ci.nsIBackgroundTasks
+ );
+ if (bts?.isBackgroundTaskMode) {
+ return Promise.resolve(NONE);
+ }
+ return new Promise(resolve => {
+ // Note: calling init ensures this code is only executed after Search has been initialized
+ Services.search
+ .getAppProvidedEngines()
+ .then(engines => {
+ resolve({
+ current: Services.search.defaultEngine.identifier,
+ installed: engines.map(engine => engine.identifier),
+ });
+ })
+ .catch(() => resolve(NONE));
+ });
+ },
+ get isDefaultBrowser() {
+ return QueryCache.getters.isDefaultBrowser.get().catch(() => null);
+ },
+ get devToolsOpenedCount() {
+ return lazy.devtoolsSelfXSSCount;
+ },
+ get topFrecentSites() {
+ return QueryCache.queries.TopFrecentSites.get().then(sites =>
+ sites.map(site => ({
+ url: site.url,
+ host: new URL(site.url).hostname,
+ frecency: site.frecency,
+ lastVisitDate: site.lastVisitDate,
+ }))
+ );
+ },
+ get recentBookmarks() {
+ return QueryCache.queries.RecentBookmarks.get();
+ },
+ get pinnedSites() {
+ return NewTabUtils.pinnedLinks.links.map(site =>
+ site
+ ? {
+ url: site.url,
+ host: new URL(site.url).hostname,
+ searchTopSite: site.searchTopSite,
+ }
+ : {}
+ );
+ },
+ get providerCohorts() {
+ return lazy.ASRouterPreferences.providers.reduce((prev, current) => {
+ prev[current.id] = current.cohort || "";
+ return prev;
+ }, {});
+ },
+ get totalBookmarksCount() {
+ return QueryCache.queries.TotalBookmarksCount.get();
+ },
+ get firefoxVersion() {
+ return parseInt(AppConstants.MOZ_APP_VERSION.match(/\d+/), 10);
+ },
+ get region() {
+ return lazy.Region.home || "";
+ },
+ get needsUpdate() {
+ return QueryCache.queries.CheckBrowserNeedsUpdate.get();
+ },
+ get hasPinnedTabs() {
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ if (win.closed || !win.ownerGlobal.gBrowser) {
+ continue;
+ }
+ if (win.ownerGlobal.gBrowser.visibleTabs.filter(t => t.pinned).length) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+ get hasAccessedFxAPanel() {
+ return lazy.hasAccessedFxAPanel;
+ },
+ get isWhatsNewPanelEnabled() {
+ return lazy.isWhatsNewPanelEnabled;
+ },
+ get userPrefs() {
+ return {
+ cfrFeatures: lazy.cfrFeaturesUserPref,
+ cfrAddons: lazy.cfrAddonsUserPref,
+ };
+ },
+ get totalBlockedCount() {
+ return lazy.TrackingDBService.sumAllEvents();
+ },
+ get blockedCountByType() {
+ const idToTextMap = new Map([
+ [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"],
+ [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"],
+ [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"],
+ [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"],
+ [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"],
+ ]);
+
+ const dateTo = new Date();
+ const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000);
+ return lazy.TrackingDBService.getEventsByDateRange(dateFrom, dateTo).then(
+ eventsByDate => {
+ let totalEvents = {};
+ for (let blockedType of idToTextMap.values()) {
+ totalEvents[blockedType] = 0;
+ }
+
+ return eventsByDate.reduce((acc, day) => {
+ const type = day.getResultByName("type");
+ const count = day.getResultByName("count");
+ acc[idToTextMap.get(type)] = acc[idToTextMap.get(type)] + count;
+ return acc;
+ }, totalEvents);
+ }
+ );
+ },
+ get attachedFxAOAuthClients() {
+ return this.usesFirefoxSync
+ ? QueryCache.queries.ListAttachedOAuthClients.get()
+ : [];
+ },
+ get platformName() {
+ return AppConstants.platform;
+ },
+ get isChinaRepack() {
+ return (
+ Services.prefs
+ .getDefaultBranch(null)
+ .getCharPref(DISTRIBUTION_ID_PREF, "default") ===
+ DISTRIBUTION_ID_CHINA_REPACK
+ );
+ },
+ get userId() {
+ return lazy.ClientEnvironment.userId;
+ },
+ get profileRestartCount() {
+ let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
+ Ci.nsIBackgroundTasks
+ );
+ if (bts?.isBackgroundTaskMode) {
+ return 0;
+ }
+ // Counter starts at 1 when a profile is created, substract 1 so the value
+ // returned matches expectations
+ return (
+ lazy.TelemetrySession.getMetadata("targeting").profileSubsessionCounter -
+ 1
+ );
+ },
+ get homePageSettings() {
+ const url = lazy.HomePage.get();
+ const { isWebExt, isCustomUrl, urls } = parseAboutPageURL(url);
+
+ return {
+ isWebExt,
+ isCustomUrl,
+ urls,
+ isDefault: lazy.HomePage.isDefault,
+ isLocked: lazy.HomePage.locked,
+ };
+ },
+ get newtabSettings() {
+ const url = lazy.AboutNewTab.newTabURL;
+ const { isWebExt, isCustomUrl, urls } = parseAboutPageURL(url);
+
+ return {
+ isWebExt,
+ isCustomUrl,
+ isDefault: lazy.AboutNewTab.activityStreamEnabled,
+ url: urls[0].url,
+ host: urls[0].host,
+ };
+ },
+ get activeNotifications() {
+ let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
+ Ci.nsIBackgroundTasks
+ );
+ if (bts?.isBackgroundTaskMode) {
+ // This might need to hook into the alert service to enumerate relevant
+ // persistent native notifications.
+ return false;
+ }
+
+ let window = lazy.BrowserWindowTracker.getTopWindow();
+
+ // Technically this doesn't mean we have active notifications,
+ // but because we use !activeNotifications to check for conflicts, this should return true
+ if (!window) {
+ return true;
+ }
+
+ if (
+ window.gURLBar?.view.isOpen ||
+ window.gNotificationBox?.currentNotification ||
+ window.gBrowser.getNotificationBox()?.currentNotification
+ ) {
+ return true;
+ }
+
+ return false;
+ },
+
+ get isMajorUpgrade() {
+ return lazy.BrowserHandler.majorUpgrade;
+ },
+
+ get hasActiveEnterprisePolicies() {
+ return Services.policies.status === Services.policies.ACTIVE;
+ },
+
+ get userMonthlyActivity() {
+ return QueryCache.queries.UserMonthlyActivity.get();
+ },
+
+ get doesAppNeedPin() {
+ return QueryCache.getters.doesAppNeedPin.get();
+ },
+
+ get doesAppNeedPrivatePin() {
+ return QueryCache.getters.doesAppNeedPrivatePin.get();
+ },
+
+ get launchOnLoginEnabled() {
+ if (AppConstants.platform !== "win") {
+ return false;
+ }
+ return lazy.WindowsLaunchOnLogin.getLaunchOnLoginEnabled();
+ },
+
+ /**
+ * Is this invocation running in background task mode?
+ *
+ * @return {boolean} `true` if running in background task mode.
+ */
+ get isBackgroundTaskMode() {
+ let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
+ Ci.nsIBackgroundTasks
+ );
+ return !!bts?.isBackgroundTaskMode;
+ },
+
+ /**
+ * A non-empty task name if this invocation is running in background
+ * task mode, or `null` if this invocation is not running in
+ * background task mode.
+ *
+ * @return {string|null} background task name or `null`.
+ */
+ get backgroundTaskName() {
+ let bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
+ Ci.nsIBackgroundTasks
+ );
+ return bts?.backgroundTaskName();
+ },
+
+ get userPrefersReducedMotion() {
+ let window = Services.appShell.hiddenDOMWindow;
+ return window?.matchMedia("(prefers-reduced-motion: reduce)")?.matches;
+ },
+
+ /**
+ * Whether or not the user is in the Major Release 2022 holdback study.
+ */
+ get inMr2022Holdback() {
+ return (
+ lazy.NimbusFeatures.majorRelease2022.getVariable("onboarding") === false
+ );
+ },
+
+ /**
+ * The distribution id, if any.
+ * @return {string}
+ */
+ get distributionId() {
+ return Services.prefs
+ .getDefaultBranch(null)
+ .getCharPref("distribution.id", "");
+ },
+
+ /** Where the Firefox View button is shown, if at all.
+ * @return {string} container of the button if it is shown in the toolbar/overflow menu
+ * @return {string} `null` if the button has been removed
+ */
+ get fxViewButtonAreaType() {
+ let button = lazy.CustomizableUI.getWidget("firefox-view-button");
+ return button.areaType;
+ },
+
+ isDefaultHandler: {
+ get html() {
+ return QueryCache.getters.isDefaultHTMLHandler.get();
+ },
+ get pdf() {
+ return QueryCache.getters.isDefaultPDFHandler.get();
+ },
+ },
+
+ get defaultPDFHandler() {
+ return QueryCache.getters.defaultPDFHandler.get();
+ },
+
+ get creditCardsSaved() {
+ return getAutofillRecords({ collectionName: "creditCards" });
+ },
+
+ get addressesSaved() {
+ return getAutofillRecords({ collectionName: "addresses" });
+ },
+
+ /**
+ * Has the user ever used the Migration Wizard to migrate bookmarks?
+ * @return {boolean} `true` if bookmark migration has occurred.
+ */
+ get hasMigratedBookmarks() {
+ return lazy.hasMigratedBookmarks;
+ },
+
+ /**
+ * Has the user ever used the Migration Wizard to migrate passwords from
+ * a CSV file?
+ * @return {boolean} `true` if CSV passwords have been imported via the
+ * migration wizard.
+ */
+ get hasMigratedCSVPasswords() {
+ return lazy.hasMigratedCSVPasswords;
+ },
+
+ /**
+ * Has the user ever used the Migration Wizard to migrate history?
+ * @return {boolean} `true` if history migration has occurred.
+ */
+ get hasMigratedHistory() {
+ return lazy.hasMigratedHistory;
+ },
+
+ /**
+ * Has the user ever used the Migration Wizard to migrate passwords?
+ * @return {boolean} `true` if password migration has occurred.
+ */
+ get hasMigratedPasswords() {
+ return lazy.hasMigratedPasswords;
+ },
+
+ /**
+ * Returns true if the user is configured to use the embedded migration
+ * wizard in about:welcome by having
+ * "browser.migrate.content-modal.about-welcome-behavior" be equal to
+ * "embedded".
+ * @return {boolean} `true` if the embedded migration wizard is enabled.
+ */
+ get useEmbeddedMigrationWizard() {
+ return lazy.useEmbeddedMigrationWizard;
+ },
+
+ /**
+ * Whether the user installed Firefox via the RTAMO flow.
+ * @return {boolean} `true` when RTAMO has been used to download Firefox,
+ * `false` otherwise.
+ */
+ get isRTAMO() {
+ const { attributionData } = this;
+
+ return (
+ attributionData?.source === "addons.mozilla.org" &&
+ !!decodeAttributionValue(attributionData?.content)?.startsWith("rta:")
+ );
+ },
+
+ /**
+ * Whether the user installed via the device migration flow.
+ * @return {boolean} `true` when the link to download the browser was part
+ * of guidance for device migration. `false` otherwise.
+ */
+ get isDeviceMigration() {
+ const { attributionData } = this;
+
+ return attributionData?.campaign === "migration";
+ },
+
+ /**
+ * The values of the height and width available to the browser to display
+ * web content. The available height and width are each calculated taking
+ * into account the presence of menu bars, docks, and other similar OS elements
+ * @returns {Object} resolution The resolution object containing width and height
+ * @returns {string} resolution.width The available width of the primary monitor
+ * @returns {string} resolution.height The available height of the primary monitor
+ */
+ get primaryResolution() {
+ // Using hidden dom window ensures that we have a window object
+ // to grab a screen from in certain edge cases such as targeting evaluation
+ // during first startup before the browser is available, and in MacOS
+ let window = Services.appShell.hiddenDOMWindow;
+ return {
+ width: window?.screen.availWidth,
+ height: window?.screen.availHeight,
+ };
+ },
+
+ get archBits() {
+ let bits = null;
+ try {
+ bits = Services.sysinfo.getProperty("archbits", null);
+ } catch (_e) {
+ // getProperty can throw if the memsize does not exist
+ }
+ if (bits) {
+ bits = Number(bits);
+ }
+ return bits;
+ },
+
+ get memoryMB() {
+ let memory = null;
+ try {
+ memory = Services.sysinfo.getProperty("memsize", null);
+ } catch (_e) {
+ // getProperty can throw if the memsize does not exist
+ }
+ if (memory) {
+ memory = Number(memory) / 1024 / 1024;
+ }
+ return memory;
+ },
+};
+
+export const ASRouterTargeting = {
+ Environment: TargetingGetters,
+
+ /**
+ * Snapshot the current targeting environment.
+ *
+ * Asynchronous getters are handled. Getters that throw or reject
+ * are ignored.
+ *
+ * Leftward (earlier) targets supercede rightward (later) targets, just like
+ * `TargetingContext.combineContexts`.
+ *
+ * @param {object} options - object containing:
+ * @param {Array<object>|null} options.targets -
+ * targeting environments to snapshot; (default: `[ASRouterTargeting.Environment]`)
+ * @return {object} snapshot of target with `environment` object and `version` integer.
+ */
+ async getEnvironmentSnapshot({
+ targets = [ASRouterTargeting.Environment],
+ } = {}) {
+ async function resolve(object) {
+ if (typeof object === "object" && object !== null) {
+ if (Array.isArray(object)) {
+ return Promise.all(object.map(async item => resolve(await item)));
+ }
+
+ if (object instanceof Date) {
+ return object;
+ }
+
+ // One promise for each named property. Label promises with property name.
+ const promises = Object.keys(object).map(async key => {
+ // Each promise needs to check if we're shutting down when it is evaluated.
+ if (Services.startup.shuttingDown) {
+ throw new Error(
+ "shutting down, so not querying targeting environment"
+ );
+ }
+
+ const value = await resolve(await object[key]);
+
+ return [key, value];
+ });
+
+ const resolved = {};
+ for (const result of await Promise.allSettled(promises)) {
+ // Ignore properties that are rejected.
+ if (result.status === "fulfilled") {
+ const [key, value] = result.value;
+ resolved[key] = value;
+ }
+ }
+
+ return resolved;
+ }
+
+ return object;
+ }
+
+ // We would like to use `TargetingContext.combineContexts`, but `Proxy`
+ // instances complicate iterating with `Object.keys`. Instead, merge by
+ // hand after resolving.
+ const environment = {};
+ for (let target of targets.toReversed()) {
+ Object.assign(environment, await resolve(target));
+ }
+
+ // Should we need to migrate in the future.
+ const snapshot = { environment, version: 1 };
+
+ return snapshot;
+ },
+
+ isTriggerMatch(trigger = {}, candidateMessageTrigger = {}) {
+ if (trigger.id !== candidateMessageTrigger.id) {
+ return false;
+ } else if (
+ !candidateMessageTrigger.params &&
+ !candidateMessageTrigger.patterns
+ ) {
+ return true;
+ }
+
+ if (!trigger.param) {
+ return false;
+ }
+
+ return (
+ (candidateMessageTrigger.params &&
+ trigger.param.host &&
+ candidateMessageTrigger.params.includes(trigger.param.host)) ||
+ (candidateMessageTrigger.params &&
+ trigger.param.type &&
+ candidateMessageTrigger.params.filter(t => t === trigger.param.type)
+ .length) ||
+ (candidateMessageTrigger.params &&
+ trigger.param.type &&
+ candidateMessageTrigger.params.filter(
+ t => (t & trigger.param.type) === t
+ ).length) ||
+ (candidateMessageTrigger.patterns &&
+ trigger.param.url &&
+ new MatchPatternSet(candidateMessageTrigger.patterns).matches(
+ trigger.param.url
+ ))
+ );
+ },
+
+ /**
+ * getCachedEvaluation - Return a cached jexl evaluation if available
+ *
+ * @param {string} targeting JEXL expression to lookup
+ * @returns {obj|null} Object with value result or null if not available
+ */
+ getCachedEvaluation(targeting) {
+ if (jexlEvaluationCache.has(targeting)) {
+ const { timestamp, value } = jexlEvaluationCache.get(targeting);
+ if (Date.now() - timestamp <= CACHE_EXPIRATION) {
+ return { value };
+ }
+ jexlEvaluationCache.delete(targeting);
+ }
+
+ return null;
+ },
+
+ /**
+ * checkMessageTargeting - Checks is a message's targeting parameters are satisfied
+ *
+ * @param {*} message An AS router message
+ * @param {obj} targetingContext a TargetingContext instance complete with eval environment
+ * @param {func} onError A function to handle errors (takes two params; error, message)
+ * @param {boolean} shouldCache Should the JEXL evaluations be cached and reused.
+ * @returns
+ */
+ async checkMessageTargeting(message, targetingContext, onError, shouldCache) {
+ lazy.ASRouterPreferences.console.debug(
+ "in checkMessageTargeting, arguments = ",
+ Array.from(arguments) // eslint-disable-line prefer-rest-params
+ );
+
+ // If no targeting is specified,
+ if (!message.targeting) {
+ return true;
+ }
+ let result;
+ try {
+ if (shouldCache) {
+ result = this.getCachedEvaluation(message.targeting);
+ if (result) {
+ return result.value;
+ }
+ }
+ // Used to report the source of the targeting error in the case of
+ // undesired events
+ targetingContext.setTelemetrySource(message.id);
+ result = await targetingContext.evalWithDefault(message.targeting);
+ if (shouldCache) {
+ jexlEvaluationCache.set(message.targeting, {
+ timestamp: Date.now(),
+ value: result,
+ });
+ }
+ } catch (error) {
+ if (onError) {
+ onError(error, message);
+ }
+ console.error(error);
+ result = false;
+ }
+ return result;
+ },
+
+ _isMessageMatch(
+ message,
+ trigger,
+ targetingContext,
+ onError,
+ shouldCache = false
+ ) {
+ return (
+ message &&
+ (trigger
+ ? this.isTriggerMatch(trigger, message.trigger)
+ : !message.trigger) &&
+ // If a trigger expression was passed to this function, the message should match it.
+ // Otherwise, we should choose a message with no trigger property (i.e. a message that can show up at any time)
+ this.checkMessageTargeting(
+ message,
+ targetingContext,
+ onError,
+ shouldCache
+ )
+ );
+ },
+
+ /**
+ * findMatchingMessage - Given an array of messages, returns one message
+ * whos targeting expression evaluates to true
+ *
+ * @param {Array<Message>} messages An array of AS router messages
+ * @param {trigger} string A trigger expression if a message for that trigger is desired
+ * @param {obj|null} context A FilterExpression context. Defaults to TargetingGetters above.
+ * @param {func} onError A function to handle errors (takes two params; error, message)
+ * @param {func} ordered An optional param when true sort message by order specified in message
+ * @param {boolean} shouldCache Should the JEXL evaluations be cached and reused.
+ * @param {boolean} returnAll Should we return all matching messages, not just the first one found.
+ * @returns {obj|Array<Message>} If returnAll is false, a single message. If returnAll is true, an array of messages.
+ */
+ async findMatchingMessage({
+ messages,
+ trigger = {},
+ context = {},
+ onError,
+ ordered = false,
+ shouldCache = false,
+ returnAll = false,
+ }) {
+ const sortedMessages = getSortedMessages(messages, { ordered });
+ lazy.ASRouterPreferences.console.debug(
+ "in findMatchingMessage, sortedMessages = ",
+ sortedMessages
+ );
+ const matching = returnAll ? [] : null;
+ const targetingContext = new lazy.TargetingContext(
+ lazy.TargetingContext.combineContexts(
+ context,
+ this.Environment,
+ trigger.context || {}
+ )
+ );
+
+ const isMatch = candidate =>
+ this._isMessageMatch(
+ candidate,
+ trigger,
+ targetingContext,
+ onError,
+ shouldCache
+ );
+
+ for (const candidate of sortedMessages) {
+ if (await isMatch(candidate)) {
+ // If not returnAll, we should return the first message we find that matches.
+ if (!returnAll) {
+ return candidate;
+ }
+
+ matching.push(candidate);
+ }
+ }
+ return matching;
+ },
+};
diff --git a/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs b/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs
new file mode 100644
index 0000000000..d8eaa3994d
--- /dev/null
+++ b/browser/components/asrouter/modules/ASRouterTriggerListeners.sys.mjs
@@ -0,0 +1,1439 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs",
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
+ FeatureCalloutBroker:
+ "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ const { Logger } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/Logger.sys.mjs"
+ );
+ return new Logger("ASRouterTriggerListeners");
+});
+
+const FEW_MINUTES = 15 * 60 * 1000; // 15 mins
+
+function isPrivateWindow(win) {
+ return (
+ !(win instanceof Ci.nsIDOMWindow) ||
+ win.closed ||
+ lazy.PrivateBrowsingUtils.isWindowPrivate(win)
+ );
+}
+
+/**
+ * Check current location against the list of allowed hosts
+ * Additionally verify for redirects and check original request URL against
+ * the list.
+ *
+ * @returns {object} - {host, url} pair that matched the list of allowed hosts
+ */
+function checkURLMatch(aLocationURI, { hosts, matchPatternSet }, aRequest) {
+ // If checks pass we return a match
+ let match;
+ try {
+ match = { host: aLocationURI.host, url: aLocationURI.spec };
+ } catch (e) {
+ // nsIURI.host can throw for non-nsStandardURL nsIURIs
+ return false;
+ }
+
+ // Check current location against allowed hosts
+ if (hosts.has(match.host)) {
+ return match;
+ }
+
+ if (matchPatternSet) {
+ if (matchPatternSet.matches(match.url)) {
+ return match;
+ }
+ }
+
+ // Nothing else to check, return early
+ if (!aRequest) {
+ return false;
+ }
+
+ // The original URL at the start of the request
+ const originalLocation = aRequest.QueryInterface(Ci.nsIChannel).originalURI;
+ // We have been redirected
+ if (originalLocation.spec !== aLocationURI.spec) {
+ return (
+ hosts.has(originalLocation.host) && {
+ host: originalLocation.host,
+ url: originalLocation.spec,
+ }
+ );
+ }
+
+ return false;
+}
+
+function createMatchPatternSet(patterns, flags) {
+ try {
+ return new MatchPatternSet(new Set(patterns), flags);
+ } catch (e) {
+ console.error(e);
+ }
+ return new MatchPatternSet([]);
+}
+
+/**
+ * A Map from trigger IDs to singleton trigger listeners. Each listener must
+ * have idempotent `init` and `uninit` methods.
+ */
+export const ASRouterTriggerListeners = new Map([
+ [
+ "openArticleURL",
+ {
+ id: "openArticleURL",
+ _initialized: false,
+ _triggerHandler: null,
+ _hosts: new Set(),
+ _matchPatternSet: null,
+ readerModeEvent: "Reader:UpdateReaderButton",
+
+ init(triggerHandler, hosts, patterns) {
+ if (!this._initialized) {
+ this.receiveMessage = this.receiveMessage.bind(this);
+ lazy.AboutReaderParent.addMessageListener(this.readerModeEvent, this);
+ this._triggerHandler = triggerHandler;
+ this._initialized = true;
+ }
+ if (patterns) {
+ this._matchPatternSet = createMatchPatternSet([
+ ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
+ ...patterns,
+ ]);
+ }
+ if (hosts) {
+ hosts.forEach(h => this._hosts.add(h));
+ }
+ },
+
+ receiveMessage({ data, target }) {
+ if (data && data.isArticle) {
+ const match = checkURLMatch(target.currentURI, {
+ hosts: this._hosts,
+ matchPatternSet: this._matchPatternSet,
+ });
+ if (match) {
+ this._triggerHandler(target, { id: this.id, param: match });
+ }
+ }
+ },
+
+ uninit() {
+ if (this._initialized) {
+ lazy.AboutReaderParent.removeMessageListener(
+ this.readerModeEvent,
+ this
+ );
+ this._initialized = false;
+ this._triggerHandler = null;
+ this._hosts = new Set();
+ this._matchPatternSet = null;
+ }
+ },
+ },
+ ],
+ [
+ "openBookmarkedURL",
+ {
+ id: "openBookmarkedURL",
+ _initialized: false,
+ _triggerHandler: null,
+ _hosts: new Set(),
+ bookmarkEvent: "bookmark-icon-updated",
+
+ init(triggerHandler) {
+ if (!this._initialized) {
+ Services.obs.addObserver(this, this.bookmarkEvent);
+ this._triggerHandler = triggerHandler;
+ this._initialized = true;
+ }
+ },
+
+ observe(subject, topic, data) {
+ if (topic === this.bookmarkEvent && data === "starred") {
+ const browser = Services.wm.getMostRecentBrowserWindow();
+ if (browser) {
+ this._triggerHandler(browser.gBrowser.selectedBrowser, {
+ id: this.id,
+ });
+ }
+ }
+ },
+
+ uninit() {
+ if (this._initialized) {
+ Services.obs.removeObserver(this, this.bookmarkEvent);
+ this._initialized = false;
+ this._triggerHandler = null;
+ this._hosts = new Set();
+ }
+ },
+ },
+ ],
+ [
+ "frequentVisits",
+ {
+ id: "frequentVisits",
+ _initialized: false,
+ _triggerHandler: null,
+ _hosts: null,
+ _matchPatternSet: null,
+ _visits: null,
+
+ init(triggerHandler, hosts = [], patterns) {
+ if (!this._initialized) {
+ this.onTabSwitch = this.onTabSwitch.bind(this);
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ if (!isPrivateWindow(win)) {
+ win.addEventListener("TabSelect", this.onTabSwitch);
+ win.gBrowser.addTabsProgressListener(this);
+ }
+ },
+ win => {
+ if (!isPrivateWindow(win)) {
+ win.removeEventListener("TabSelect", this.onTabSwitch);
+ win.gBrowser.removeTabsProgressListener(this);
+ }
+ }
+ );
+ this._visits = new Map();
+ this._initialized = true;
+ }
+ this._triggerHandler = triggerHandler;
+ if (patterns) {
+ this._matchPatternSet = createMatchPatternSet([
+ ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
+ ...patterns,
+ ]);
+ }
+ if (this._hosts) {
+ hosts.forEach(h => this._hosts.add(h));
+ } else {
+ this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
+ }
+ },
+
+ /* _updateVisits - Record visit timestamps for websites that match `this._hosts` and only
+ * if it's been more than FEW_MINUTES since the last visit.
+ * @param {string} host - Location host of current selected tab
+ * @returns {boolean} - If the new visit has been recorded
+ */
+ _updateVisits(host) {
+ const visits = this._visits.get(host);
+
+ if (visits && Date.now() - visits[0] > FEW_MINUTES) {
+ this._visits.set(host, [Date.now(), ...visits]);
+ return true;
+ }
+ if (!visits) {
+ this._visits.set(host, [Date.now()]);
+ return true;
+ }
+
+ return false;
+ },
+
+ onTabSwitch(event) {
+ if (!event.target.ownerGlobal.gBrowser) {
+ return;
+ }
+
+ const { gBrowser } = event.target.ownerGlobal;
+ const match = checkURLMatch(gBrowser.currentURI, {
+ hosts: this._hosts,
+ matchPatternSet: this._matchPatternSet,
+ });
+ if (match) {
+ this.triggerHandler(gBrowser.selectedBrowser, match);
+ }
+ },
+
+ triggerHandler(aBrowser, match) {
+ const updated = this._updateVisits(match.host);
+
+ // If the previous visit happend less than FEW_MINUTES ago
+ // no updates were made, no need to trigger the handler
+ if (!updated) {
+ return;
+ }
+
+ this._triggerHandler(aBrowser, {
+ id: this.id,
+ param: match,
+ context: {
+ // Remapped to {host, timestamp} because JEXL operators can only
+ // filter over collections (arrays of objects)
+ recentVisits: this._visits
+ .get(match.host)
+ .map(timestamp => ({ host: match.host, timestamp })),
+ },
+ });
+ },
+
+ onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
+ // Some websites trigger redirect events after they finish loading even
+ // though the location remains the same. This results in onLocationChange
+ // events to be fired twice.
+ const isSameDocument = !!(
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
+ );
+ if (aWebProgress.isTopLevel && !isSameDocument) {
+ const match = checkURLMatch(
+ aLocationURI,
+ { hosts: this._hosts, matchPatternSet: this._matchPatternSet },
+ aRequest
+ );
+ if (match) {
+ this.triggerHandler(aBrowser, match);
+ }
+ }
+ },
+
+ uninit() {
+ if (this._initialized) {
+ lazy.EveryWindow.unregisterCallback(this.id);
+
+ this._initialized = false;
+ this._triggerHandler = null;
+ this._hosts = null;
+ this._matchPatternSet = null;
+ this._visits = null;
+ }
+ },
+ },
+ ],
+
+ /**
+ * Attach listeners to every browser window to detect location changes, and
+ * notify the trigger handler whenever we navigate to a URL with a hostname
+ * we're looking for.
+ */
+ [
+ "openURL",
+ {
+ id: "openURL",
+ _initialized: false,
+ _triggerHandler: null,
+ _hosts: null,
+ _matchPatternSet: null,
+ _visits: null,
+
+ /*
+ * If the listener is already initialised, `init` will replace the trigger
+ * handler and add any new hosts to `this._hosts`.
+ */
+ init(triggerHandler, hosts = [], patterns) {
+ if (!this._initialized) {
+ this.onLocationChange = this.onLocationChange.bind(this);
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ if (!isPrivateWindow(win)) {
+ win.gBrowser.addTabsProgressListener(this);
+ }
+ },
+ win => {
+ if (!isPrivateWindow(win)) {
+ win.gBrowser.removeTabsProgressListener(this);
+ }
+ }
+ );
+
+ this._visits = new Map();
+ this._initialized = true;
+ }
+ this._triggerHandler = triggerHandler;
+ if (patterns) {
+ this._matchPatternSet = createMatchPatternSet([
+ ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
+ ...patterns,
+ ]);
+ }
+ if (this._hosts) {
+ hosts.forEach(h => this._hosts.add(h));
+ } else {
+ this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
+ }
+ },
+
+ uninit() {
+ if (this._initialized) {
+ lazy.EveryWindow.unregisterCallback(this.id);
+
+ this._initialized = false;
+ this._triggerHandler = null;
+ this._hosts = null;
+ this._matchPatternSet = null;
+ this._visits = null;
+ }
+ },
+
+ onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
+ // Some websites trigger redirect events after they finish loading even
+ // though the location remains the same. This results in onLocationChange
+ // events to be fired twice.
+ const isSameDocument = !!(
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
+ );
+ if (aWebProgress.isTopLevel && !isSameDocument) {
+ const match = checkURLMatch(
+ aLocationURI,
+ { hosts: this._hosts, matchPatternSet: this._matchPatternSet },
+ aRequest
+ );
+ if (match) {
+ let visitsCount = (this._visits.get(match.url) || 0) + 1;
+ this._visits.set(match.url, visitsCount);
+ this._triggerHandler(aBrowser, {
+ id: this.id,
+ param: match,
+ context: { visitsCount },
+ });
+ }
+ }
+ },
+ },
+ ],
+
+ /**
+ * Add an observer notification to notify the trigger handler whenever the user
+ * saves or updates a login via the login capture doorhanger.
+ */
+ [
+ "newSavedLogin",
+ {
+ _initialized: false,
+ _triggerHandler: null,
+
+ /**
+ * If the listener is already initialised, `init` will replace the trigger
+ * handler.
+ */
+ init(triggerHandler) {
+ if (!this._initialized) {
+ Services.obs.addObserver(this, "LoginStats:NewSavedPassword");
+ Services.obs.addObserver(this, "LoginStats:LoginUpdateSaved");
+ this._initialized = true;
+ }
+ this._triggerHandler = triggerHandler;
+ },
+
+ uninit() {
+ if (this._initialized) {
+ Services.obs.removeObserver(this, "LoginStats:NewSavedPassword");
+ Services.obs.removeObserver(this, "LoginStats:LoginUpdateSaved");
+
+ this._initialized = false;
+ this._triggerHandler = null;
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aSubject.currentURI.asciiHost === "accounts.firefox.com") {
+ // Don't notify about saved logins on the FxA login origin since this
+ // trigger is used to promote login Sync and getting a recommendation
+ // to enable Sync during the sign up process is a bad UX.
+ return;
+ }
+
+ switch (aTopic) {
+ case "LoginStats:NewSavedPassword": {
+ this._triggerHandler(aSubject, {
+ id: "newSavedLogin",
+ context: { type: "save" },
+ });
+ break;
+ }
+ case "LoginStats:LoginUpdateSaved": {
+ this._triggerHandler(aSubject, {
+ id: "newSavedLogin",
+ context: { type: "update" },
+ });
+ break;
+ }
+ default: {
+ throw new Error(`Unexpected observer notification: ${aTopic}`);
+ }
+ }
+ },
+ },
+ ],
+ [
+ "formAutofill",
+ {
+ id: "formAutofill",
+ _initialized: false,
+ _triggerHandler: null,
+ _triggerDelay: 10000, // 10 second delay before triggering
+ _topic: "formautofill-storage-changed",
+ _events: ["add", "update", "notifyUsed"] /** @see AutofillRecords */,
+ _collections: ["addresses", "creditCards"] /** @see AutofillRecords */,
+
+ init(triggerHandler) {
+ if (!this._initialized) {
+ Services.obs.addObserver(this, this._topic);
+ this._initialized = true;
+ }
+ this._triggerHandler = triggerHandler;
+ },
+
+ uninit() {
+ if (this._initialized) {
+ Services.obs.removeObserver(this, this._topic);
+ this._initialized = false;
+ this._triggerHandler = null;
+ }
+ },
+
+ observe(subject, topic, data) {
+ const browser =
+ Services.wm.getMostRecentBrowserWindow()?.gBrowser.selectedBrowser;
+ if (
+ !browser ||
+ topic !== this._topic ||
+ !subject.wrappedJSObject ||
+ // Ignore changes caused by manual edits in the credit card/address
+ // managers in about:preferences.
+ browser.contentWindow?.gSubDialog?.dialogs.length
+ ) {
+ return;
+ }
+ let { sourceSync, collectionName } = subject.wrappedJSObject;
+ // Ignore changes from sync and changes to untracked collections.
+ if (sourceSync || !this._collections.includes(collectionName)) {
+ return;
+ }
+ if (this._events.includes(data)) {
+ let event = data;
+ let type = collectionName;
+ if (event === "notifyUsed") {
+ event = "use";
+ }
+ if (type === "creditCards") {
+ type = "card";
+ }
+ if (type === "addresses") {
+ type = "address";
+ }
+ lazy.setTimeout(() => {
+ if (
+ this._initialized &&
+ // Make sure the browser still exists and is still selected.
+ browser.isConnectedAndReady &&
+ browser ===
+ Services.wm.getMostRecentBrowserWindow()?.gBrowser
+ .selectedBrowser
+ ) {
+ this._triggerHandler(browser, {
+ id: this.id,
+ context: { event, type },
+ });
+ }
+ }, this._triggerDelay);
+ }
+ },
+ },
+ ],
+
+ [
+ "contentBlocking",
+ {
+ _initialized: false,
+ _triggerHandler: null,
+ _events: [],
+ _sessionPageLoad: 0,
+ onLocationChange: null,
+
+ init(triggerHandler, params, patterns) {
+ params.forEach(p => this._events.push(p));
+
+ if (!this._initialized) {
+ Services.obs.addObserver(this, "SiteProtection:ContentBlockingEvent");
+ Services.obs.addObserver(
+ this,
+ "SiteProtection:ContentBlockingMilestone"
+ );
+ this.onLocationChange = this._onLocationChange.bind(this);
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ if (!isPrivateWindow(win)) {
+ win.gBrowser.addTabsProgressListener(this);
+ }
+ },
+ win => {
+ if (!isPrivateWindow(win)) {
+ win.gBrowser.removeTabsProgressListener(this);
+ }
+ }
+ );
+
+ this._initialized = true;
+ }
+ this._triggerHandler = triggerHandler;
+ },
+
+ uninit() {
+ if (this._initialized) {
+ Services.obs.removeObserver(
+ this,
+ "SiteProtection:ContentBlockingEvent"
+ );
+ Services.obs.removeObserver(
+ this,
+ "SiteProtection:ContentBlockingMilestone"
+ );
+ lazy.EveryWindow.unregisterCallback(this.id);
+ this.onLocationChange = null;
+ this._initialized = false;
+ }
+ this._triggerHandler = null;
+ this._events = [];
+ this._sessionPageLoad = 0;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "SiteProtection:ContentBlockingEvent":
+ const { browser, host, event } = aSubject.wrappedJSObject;
+ if (this._events.filter(e => (e & event) === e).length) {
+ this._triggerHandler(browser, {
+ id: "contentBlocking",
+ param: {
+ host,
+ type: event,
+ },
+ context: {
+ pageLoad: this._sessionPageLoad,
+ },
+ });
+ }
+ break;
+ case "SiteProtection:ContentBlockingMilestone":
+ if (this._events.includes(aSubject.wrappedJSObject.event)) {
+ this._triggerHandler(
+ Services.wm.getMostRecentBrowserWindow().gBrowser
+ .selectedBrowser,
+ {
+ id: "contentBlocking",
+ context: {
+ pageLoad: this._sessionPageLoad,
+ },
+ param: {
+ type: aSubject.wrappedJSObject.event,
+ },
+ }
+ );
+ }
+ break;
+ }
+ },
+
+ _onLocationChange(
+ aBrowser,
+ aWebProgress,
+ aRequest,
+ aLocationURI,
+ aFlags
+ ) {
+ // Some websites trigger redirect events after they finish loading even
+ // though the location remains the same. This results in onLocationChange
+ // events to be fired twice.
+ const isSameDocument = !!(
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
+ );
+ if (
+ ["http", "https"].includes(aLocationURI.scheme) &&
+ aWebProgress.isTopLevel &&
+ !isSameDocument
+ ) {
+ this._sessionPageLoad += 1;
+ }
+ },
+ },
+ ],
+
+ [
+ "captivePortalLogin",
+ {
+ id: "captivePortalLogin",
+ _initialized: false,
+ _triggerHandler: null,
+
+ _shouldShowCaptivePortalVPNPromo() {
+ return lazy.BrowserUtils.shouldShowVPNPromo();
+ },
+
+ init(triggerHandler) {
+ if (!this._initialized) {
+ Services.obs.addObserver(this, "captive-portal-login-success");
+ this._initialized = true;
+ }
+ this._triggerHandler = triggerHandler;
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "captive-portal-login-success":
+ const browser = Services.wm.getMostRecentBrowserWindow();
+ // The check is here rather than in init because some
+ // folks leave their browsers running for a long time,
+ // eg from before leaving on a plane trip to after landing
+ // in the new destination, and the current region may have
+ // changed since init time.
+ if (browser && this._shouldShowCaptivePortalVPNPromo()) {
+ this._triggerHandler(browser.gBrowser.selectedBrowser, {
+ id: this.id,
+ });
+ }
+ break;
+ }
+ },
+
+ uninit() {
+ if (this._initialized) {
+ this._triggerHandler = null;
+ this._initialized = false;
+ Services.obs.removeObserver(this, "captive-portal-login-success");
+ }
+ },
+ },
+ ],
+
+ [
+ "preferenceObserver",
+ {
+ id: "preferenceObserver",
+ _initialized: false,
+ _triggerHandler: null,
+ _observedPrefs: [],
+
+ init(triggerHandler, prefs) {
+ if (!this._initialized) {
+ this._triggerHandler = triggerHandler;
+ this._initialized = true;
+ }
+ prefs.forEach(pref => {
+ this._observedPrefs.push(pref);
+ Services.prefs.addObserver(pref, this);
+ });
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "nsPref:changed":
+ const browser = Services.wm.getMostRecentBrowserWindow();
+ if (browser && this._observedPrefs.includes(aData)) {
+ this._triggerHandler(browser.gBrowser.selectedBrowser, {
+ id: this.id,
+ param: {
+ type: aData,
+ },
+ });
+ }
+ break;
+ }
+ },
+
+ uninit() {
+ if (this._initialized) {
+ this._observedPrefs.forEach(pref =>
+ Services.prefs.removeObserver(pref, this)
+ );
+ this._initialized = false;
+ this._triggerHandler = null;
+ this._observedPrefs = [];
+ }
+ },
+ },
+ ],
+ [
+ "nthTabClosed",
+ {
+ id: "nthTabClosed",
+ _initialized: false,
+ _triggerHandler: null,
+ // Number of tabs the user closed this session
+ _closedTabs: 0,
+
+ init(triggerHandler) {
+ this._triggerHandler = triggerHandler;
+ if (!this._initialized) {
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ win.addEventListener("TabClose", this);
+ },
+ win => {
+ win.removeEventListener("TabClose", this);
+ }
+ );
+ this._initialized = true;
+ }
+ },
+ handleEvent(event) {
+ if (this._initialized) {
+ if (!event.target.ownerGlobal.gBrowser) {
+ return;
+ }
+ const { gBrowser } = event.target.ownerGlobal;
+ this._closedTabs++;
+ this._triggerHandler(gBrowser.selectedBrowser, {
+ id: this.id,
+ context: { tabsClosedCount: this._closedTabs },
+ });
+ }
+ },
+ uninit() {
+ if (this._initialized) {
+ lazy.EveryWindow.unregisterCallback(this.id);
+ this._initialized = false;
+ this._triggerHandler = null;
+ this._closedTabs = 0;
+ }
+ },
+ },
+ ],
+ [
+ "activityAfterIdle",
+ {
+ id: "activityAfterIdle",
+ _initialized: false,
+ _triggerHandler: null,
+ _idleService: null,
+ // Optimization - only report idle state after one minute of idle time.
+ // This represents a minimum idleForMilliseconds of 60000.
+ _idleThreshold: 60,
+ _idleSince: null,
+ _quietSince: null,
+ _awaitingVisibilityChange: false,
+ // Fire the trigger 2 seconds after activity resumes to ensure user is
+ // actively using the browser when it fires.
+ _triggerDelay: 2000,
+ _triggerTimeout: null,
+ // We may get an idle notification immediately after waking from sleep.
+ // The idle time in such a case will be the amount of time since the last
+ // user interaction, which was before the computer went to sleep. We want
+ // to ignore them in that case, so we ignore idle notifications that
+ // happen within 1 second of the last wake notification.
+ _wakeDelay: 1000,
+ _lastWakeTime: null,
+ _listenedEvents: ["visibilitychange", "TabClose", "TabAttrModified"],
+ // When the OS goes to sleep or the process is suspended, we want to drop
+ // the idle time, since the time between sleep and wake is expected to be
+ // very long (e.g. overnight). Otherwise, this would trigger on the first
+ // activity after waking/resuming, counting sleep as idle time. This
+ // basically means each session starts with a fresh idle time.
+ _observedTopics: [
+ "sleep_notification",
+ "suspend_process_notification",
+ "wake_notification",
+ "resume_process_notification",
+ "mac_app_activate",
+ ],
+
+ get _isVisible() {
+ return [...Services.wm.getEnumerator("navigator:browser")].some(
+ win => !win.closed && !win.document?.hidden
+ );
+ },
+ get _soundPlaying() {
+ return [...Services.wm.getEnumerator("navigator:browser")].some(win =>
+ win.gBrowser?.tabs.some(tab => !tab.closing && tab.soundPlaying)
+ );
+ },
+ init(triggerHandler) {
+ this._triggerHandler = triggerHandler;
+ // Instantiate this here instead of with a lazy service getter so we can
+ // stub it in tests (otherwise we'd have to wait up to 6 minutes for an
+ // idle notification in certain test environments).
+ if (!this._idleService) {
+ this._idleService = Cc[
+ "@mozilla.org/widget/useridleservice;1"
+ ].getService(Ci.nsIUserIdleService);
+ }
+ if (
+ !this._initialized &&
+ !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing
+ ) {
+ this._idleService.addIdleObserver(this, this._idleThreshold);
+ for (let topic of this._observedTopics) {
+ Services.obs.addObserver(this, topic);
+ }
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ for (let ev of this._listenedEvents) {
+ win.addEventListener(ev, this);
+ }
+ },
+ win => {
+ for (let ev of this._listenedEvents) {
+ win.removeEventListener(ev, this);
+ }
+ }
+ );
+ if (!this._soundPlaying) {
+ this._quietSince = Date.now();
+ }
+ this._initialized = true;
+ this.log("Initialized: ", {
+ idleTime: this._idleService.idleTime,
+ quietSince: this._quietSince,
+ });
+ }
+ },
+ observe(subject, topic, data) {
+ if (this._initialized) {
+ this.log("Heard observer notification: ", {
+ subject,
+ topic,
+ data,
+ idleTime: this._idleService.idleTime,
+ idleSince: this._idleSince,
+ quietSince: this._quietSince,
+ lastWakeTime: this._lastWakeTime,
+ });
+ switch (topic) {
+ case "idle":
+ const now = Date.now();
+ // If the idle notification is within 1 second of the last wake
+ // notification, ignore it. We do this to avoid counting time the
+ // computer spent asleep as "idle time"
+ const isImmediatelyAfterWake =
+ this._lastWakeTime &&
+ now - this._lastWakeTime < this._wakeDelay;
+ if (!isImmediatelyAfterWake) {
+ this._idleSince = now - subject.idleTime;
+ }
+ break;
+ case "active":
+ // Trigger when user returns from being idle.
+ if (this._isVisible) {
+ this._onActive();
+ this._idleSince = null;
+ this._lastWakeTime = null;
+ } else if (this._idleSince) {
+ // If the window is not visible, we want to wait until it is
+ // visible before triggering.
+ this._awaitingVisibilityChange = true;
+ }
+ break;
+ // OS/process notifications
+ case "wake_notification":
+ case "resume_process_notification":
+ case "mac_app_activate":
+ this._lastWakeTime = Date.now();
+ // Fall through to reset idle time.
+ default:
+ this._idleSince = null;
+ }
+ }
+ },
+ handleEvent(event) {
+ if (this._initialized) {
+ switch (event.type) {
+ case "visibilitychange":
+ if (this._awaitingVisibilityChange && this._isVisible) {
+ this._onActive();
+ this._idleSince = null;
+ this._lastWakeTime = null;
+ this._awaitingVisibilityChange = false;
+ }
+ break;
+ case "TabAttrModified":
+ // Listen for DOMAudioPlayback* events.
+ if (!event.detail?.changed?.includes("soundplaying")) {
+ break;
+ }
+ // fall through
+ case "TabClose":
+ this.log("Tab sound changed: ", {
+ event,
+ idleTime: this._idleService.idleTime,
+ idleSince: this._idleSince,
+ quietSince: this._quietSince,
+ });
+ // Maybe update time if a tab closes with sound playing.
+ if (this._soundPlaying) {
+ this._quietSince = null;
+ } else if (!this._quietSince) {
+ this._quietSince = Date.now();
+ }
+ }
+ }
+ },
+ _onActive() {
+ this.log("User is active: ", {
+ idleTime: this._idleService.idleTime,
+ idleSince: this._idleSince,
+ quietSince: this._quietSince,
+ lastWakeTime: this._lastWakeTime,
+ });
+ if (this._idleSince && this._quietSince) {
+ const win = Services.wm.getMostRecentBrowserWindow();
+ if (win && !isPrivateWindow(win) && !this._triggerTimeout) {
+ // Time since the most recent user interaction/audio playback,
+ // reported as the number of milliseconds the user has been idle.
+ const idleForMilliseconds =
+ Date.now() - Math.max(this._idleSince, this._quietSince);
+ this._triggerTimeout = lazy.setTimeout(() => {
+ this._triggerHandler(win.gBrowser.selectedBrowser, {
+ id: this.id,
+ context: { idleForMilliseconds },
+ });
+ this._triggerTimeout = null;
+ }, this._triggerDelay);
+ }
+ }
+ },
+ uninit() {
+ if (this._initialized) {
+ this._idleService.removeIdleObserver(this, this._idleThreshold);
+ for (let topic of this._observedTopics) {
+ Services.obs.removeObserver(this, topic);
+ }
+ lazy.EveryWindow.unregisterCallback(this.id);
+ lazy.clearTimeout(this._triggerTimeout);
+ this._triggerTimeout = null;
+ this._initialized = false;
+ this._triggerHandler = null;
+ this._idleSince = null;
+ this._quietSince = null;
+ this._lastWakeTime = null;
+ this._awaitingVisibilityChange = false;
+ this.log("Uninitialized");
+ }
+ },
+ log(...args) {
+ lazy.log.debug("Idle trigger :>>", ...args);
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+ },
+ ],
+ [
+ "cookieBannerDetected",
+ {
+ id: "cookieBannerDetected",
+ _initialized: false,
+ _triggerHandler: null,
+
+ init(triggerHandler) {
+ this._triggerHandler = triggerHandler;
+ if (!this._initialized) {
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ win.addEventListener("cookiebannerdetected", this);
+ },
+ win => {
+ win.removeEventListener("cookiebannerdetected", this);
+ }
+ );
+ this._initialized = true;
+ }
+ },
+ handleEvent(event) {
+ if (this._initialized) {
+ const win = event.target || Services.wm.getMostRecentBrowserWindow();
+ if (!win) {
+ return;
+ }
+ this._triggerHandler(win.gBrowser.selectedBrowser, {
+ id: this.id,
+ });
+ }
+ },
+ uninit() {
+ if (this._initialized) {
+ lazy.EveryWindow.unregisterCallback(this.id);
+ this._initialized = false;
+ this._triggerHandler = null;
+ }
+ },
+ },
+ ],
+ [
+ "cookieBannerHandled",
+ {
+ id: "cookieBannerHandled",
+ _initialized: false,
+ _triggerHandler: null,
+
+ init(triggerHandler) {
+ this._triggerHandler = triggerHandler;
+ if (!this._initialized) {
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ win.addEventListener("cookiebannerhandled", this);
+ },
+ win => {
+ win.removeEventListener("cookiebannerhandled", this);
+ }
+ );
+ this._initialized = true;
+ }
+ },
+ handleEvent(event) {
+ if (this._initialized) {
+ const browser =
+ event.detail.windowContext.rootFrameLoader?.ownerElement;
+ const win = browser?.ownerGlobal;
+ // We only want to show messages in the active browser window.
+ if (
+ win === Services.wm.getMostRecentBrowserWindow() &&
+ browser === win.gBrowser.selectedBrowser
+ ) {
+ this._triggerHandler(browser, { id: this.id });
+ }
+ }
+ },
+ uninit() {
+ if (this._initialized) {
+ lazy.EveryWindow.unregisterCallback(this.id);
+ this._initialized = false;
+ this._triggerHandler = null;
+ }
+ },
+ },
+ ],
+ [
+ "pdfJsFeatureCalloutCheck",
+ {
+ id: "pdfJsFeatureCalloutCheck",
+ _initialized: false,
+ _triggerHandler: null,
+ _callouts: new WeakMap(),
+
+ init(triggerHandler) {
+ if (!this._initialized) {
+ this.onLocationChange = this.onLocationChange.bind(this);
+ this.onStateChange = this.onLocationChange;
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ this.onBrowserWindow(win);
+ win.addEventListener("TabSelect", this);
+ win.addEventListener("TabClose", this);
+ win.addEventListener("SSTabRestored", this);
+ win.gBrowser.addTabsProgressListener(this);
+ },
+ win => {
+ win.removeEventListener("TabSelect", this);
+ win.removeEventListener("TabClose", this);
+ win.removeEventListener("SSTabRestored", this);
+ win.gBrowser.removeTabsProgressListener(this);
+ }
+ );
+ this._initialized = true;
+ }
+ this._triggerHandler = triggerHandler;
+ },
+
+ uninit() {
+ if (this._initialized) {
+ lazy.EveryWindow.unregisterCallback(this.id);
+ this._initialized = false;
+ this._triggerHandler = null;
+ for (let key of ChromeUtils.nondeterministicGetWeakMapKeys(
+ this._callouts
+ )) {
+ const item = this._callouts.get(key);
+ if (item) {
+ item.callout.endTour(true);
+ item.cleanup();
+ this._callouts.delete(key);
+ }
+ }
+ }
+ },
+
+ async showFeatureCalloutTour(win, browser, panelId, context) {
+ const result = await this._triggerHandler(browser, {
+ id: "pdfJsFeatureCalloutCheck",
+ context,
+ });
+ if (result.message.trigger) {
+ const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout(
+ {
+ win,
+ browser,
+ pref: {
+ name:
+ result.message.content?.tour_pref_name ??
+ "browser.pdfjs.feature-tour",
+ defaultValue: result.message.content?.tour_pref_default_value,
+ },
+ location: "pdfjs",
+ theme: { preset: "pdfjs", simulateContent: true },
+ cleanup: () => {
+ this._callouts.delete(win);
+ },
+ },
+ result.message
+ );
+ if (callout) {
+ callout.panelId = panelId;
+ this._callouts.set(win, callout);
+ }
+ }
+ },
+
+ onLocationChange(browser) {
+ const tabbrowser = browser.getTabBrowser();
+ if (browser !== tabbrowser.selectedBrowser) {
+ return;
+ }
+ const win = tabbrowser.ownerGlobal;
+ const tab = tabbrowser.selectedTab;
+ const existingCallout = this._callouts.get(win);
+ const isPDFJS =
+ browser.contentPrincipal.originNoSuffix === "resource://pdf.js";
+ if (
+ existingCallout &&
+ (existingCallout.panelId !== tab.linkedPanel || !isPDFJS)
+ ) {
+ existingCallout.callout.endTour(true);
+ existingCallout.cleanup();
+ }
+ if (!this._callouts.has(win) && isPDFJS) {
+ this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
+ source: "open",
+ });
+ }
+ },
+
+ handleEvent(event) {
+ const tab = event.target;
+ const win = tab.ownerGlobal;
+ const { gBrowser } = win;
+ if (!gBrowser) {
+ return;
+ }
+ switch (event.type) {
+ case "SSTabRestored":
+ if (tab !== gBrowser.selectedTab) {
+ return;
+ }
+ // fall through
+ case "TabSelect": {
+ const browser = gBrowser.getBrowserForTab(tab);
+ const existingCallout = this._callouts.get(win);
+ const isPDFJS =
+ browser.contentPrincipal.originNoSuffix === "resource://pdf.js";
+ if (
+ existingCallout &&
+ (existingCallout.panelId !== tab.linkedPanel || !isPDFJS)
+ ) {
+ existingCallout.callout.endTour(true);
+ existingCallout.cleanup();
+ }
+ if (!this._callouts.has(win) && isPDFJS) {
+ this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
+ source: "open",
+ });
+ }
+ break;
+ }
+ case "TabClose": {
+ const existingCallout = this._callouts.get(win);
+ if (
+ existingCallout &&
+ existingCallout.panelId === tab.linkedPanel
+ ) {
+ existingCallout.callout.endTour(true);
+ existingCallout.cleanup();
+ }
+ break;
+ }
+ }
+ },
+
+ onBrowserWindow(win) {
+ this.onLocationChange(win.gBrowser.selectedBrowser);
+ },
+ },
+ ],
+ [
+ "newtabFeatureCalloutCheck",
+ {
+ id: "newtabFeatureCalloutCheck",
+ _initialized: false,
+ _triggerHandler: null,
+ _callouts: new WeakMap(),
+
+ init(triggerHandler) {
+ if (!this._initialized) {
+ this.onLocationChange = this.onLocationChange.bind(this);
+ this.onStateChange = this.onLocationChange;
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ this.onBrowserWindow(win);
+ win.addEventListener("TabSelect", this);
+ win.addEventListener("TabClose", this);
+ win.addEventListener("SSTabRestored", this);
+ win.gBrowser.addTabsProgressListener(this);
+ },
+ win => {
+ win.removeEventListener("TabSelect", this);
+ win.removeEventListener("TabClose", this);
+ win.removeEventListener("SSTabRestored", this);
+ win.gBrowser.removeTabsProgressListener(this);
+ }
+ );
+ this._initialized = true;
+ }
+ this._triggerHandler = triggerHandler;
+ },
+
+ uninit() {
+ if (this._initialized) {
+ lazy.EveryWindow.unregisterCallback(this.id);
+ this._initialized = false;
+ this._triggerHandler = null;
+ for (let key of ChromeUtils.nondeterministicGetWeakMapKeys(
+ this._callouts
+ )) {
+ const item = this._callouts.get(key);
+ if (item) {
+ item.callout.endTour(true);
+ item.cleanup();
+ this._callouts.delete(key);
+ }
+ }
+ }
+ },
+
+ async showFeatureCalloutTour(win, browser, panelId, context) {
+ const result = await this._triggerHandler(browser, {
+ id: "newtabFeatureCalloutCheck",
+ context,
+ });
+ if (result.message.trigger) {
+ const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout(
+ {
+ win,
+ browser,
+ pref: {
+ name:
+ result.message.content?.tour_pref_name ??
+ "browser.newtab.feature-tour",
+ defaultValue: result.message.content?.tour_pref_default_value,
+ },
+ location: "newtab",
+ theme: { preset: "newtab", simulateContent: true },
+ cleanup: () => {
+ this._callouts.delete(win);
+ },
+ },
+ result.message
+ );
+ if (callout) {
+ callout.panelId = panelId;
+ this._callouts.set(win, callout);
+ }
+ }
+ },
+
+ onLocationChange(browser) {
+ const tabbrowser = browser.getTabBrowser();
+ if (browser !== tabbrowser.selectedBrowser) {
+ return;
+ }
+ const win = tabbrowser.ownerGlobal;
+ const tab = tabbrowser.selectedTab;
+ const existingCallout = this._callouts.get(win);
+ const isNewtabOrHome =
+ browser.currentURI.spec.startsWith("about:home") ||
+ browser.currentURI.spec.startsWith("about:newtab");
+ if (
+ existingCallout &&
+ (existingCallout.panelId !== tab.linkedPanel || !isNewtabOrHome)
+ ) {
+ existingCallout.callout.endTour(true);
+ existingCallout.cleanup();
+ }
+ if (!this._callouts.has(win) && isNewtabOrHome && tab.linkedPanel) {
+ this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
+ source: "open",
+ });
+ }
+ },
+
+ handleEvent(event) {
+ const tab = event.target;
+ const win = tab.ownerGlobal;
+ const { gBrowser } = win;
+ if (!gBrowser) {
+ return;
+ }
+ switch (event.type) {
+ case "SSTabRestored":
+ if (tab !== gBrowser.selectedTab) {
+ return;
+ }
+ // fall through
+ case "TabSelect": {
+ const browser = gBrowser.getBrowserForTab(tab);
+ const existingCallout = this._callouts.get(win);
+ const isNewtabOrHome =
+ browser.currentURI.spec.startsWith("about:home") ||
+ browser.currentURI.spec.startsWith("about:newtab");
+ if (
+ existingCallout &&
+ (existingCallout.panelId !== tab.linkedPanel || !isNewtabOrHome)
+ ) {
+ existingCallout.callout.endTour(true);
+ existingCallout.cleanup();
+ }
+ if (!this._callouts.has(win) && isNewtabOrHome) {
+ this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
+ source: "open",
+ });
+ }
+ break;
+ }
+ case "TabClose": {
+ const existingCallout = this._callouts.get(win);
+ if (
+ existingCallout &&
+ existingCallout.panelId === tab.linkedPanel
+ ) {
+ existingCallout.callout.endTour(true);
+ existingCallout.cleanup();
+ }
+ break;
+ }
+ }
+ },
+
+ onBrowserWindow(win) {
+ this.onLocationChange(win.gBrowser.selectedBrowser);
+ },
+ },
+ ],
+]);
diff --git a/browser/components/asrouter/modules/ActorConstants.sys.mjs b/browser/components/asrouter/modules/ActorConstants.sys.mjs
new file mode 100644
index 0000000000..4c996552ab
--- /dev/null
+++ b/browser/components/asrouter/modules/ActorConstants.sys.mjs
@@ -0,0 +1,49 @@
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export const MESSAGE_TYPE_LIST = [
+ "BLOCK_MESSAGE_BY_ID",
+ "USER_ACTION",
+ "IMPRESSION",
+ "TRIGGER",
+ // PB is Private Browsing
+ "PBNEWTAB_MESSAGE_REQUEST",
+ "DOORHANGER_TELEMETRY",
+ "TOOLBAR_BADGE_TELEMETRY",
+ "TOOLBAR_PANEL_TELEMETRY",
+ "MOMENTS_PAGE_TELEMETRY",
+ "INFOBAR_TELEMETRY",
+ "SPOTLIGHT_TELEMETRY",
+ "TOAST_NOTIFICATION_TELEMETRY",
+ "AS_ROUTER_TELEMETRY_USER_EVENT",
+
+ // Admin types
+ "ADMIN_CONNECT_STATE",
+ "UNBLOCK_MESSAGE_BY_ID",
+ "UNBLOCK_ALL",
+ "BLOCK_BUNDLE",
+ "UNBLOCK_BUNDLE",
+ "DISABLE_PROVIDER",
+ "ENABLE_PROVIDER",
+ "EVALUATE_JEXL_EXPRESSION",
+ "EXPIRE_QUERY_CACHE",
+ "FORCE_ATTRIBUTION",
+ "FORCE_WHATSNEW_PANEL",
+ "FORCE_PRIVATE_BROWSING_WINDOW",
+ "CLOSE_WHATSNEW_PANEL",
+ "OVERRIDE_MESSAGE",
+ "MODIFY_MESSAGE_JSON",
+ "RESET_PROVIDER_PREF",
+ "SET_PROVIDER_USER_PREF",
+ "RESET_GROUPS_STATE",
+ "RESET_MESSAGE_STATE",
+ "RESET_SCREEN_IMPRESSIONS",
+ "EDIT_STATE",
+];
+
+export const MESSAGE_TYPE_HASH = MESSAGE_TYPE_LIST.reduce((hash, value) => {
+ hash[value] = value;
+ return hash;
+}, {});
diff --git a/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs b/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs
new file mode 100644
index 0000000000..e0aa49ad49
--- /dev/null
+++ b/browser/components/asrouter/modules/CFRMessageProvider.sys.mjs
@@ -0,0 +1,820 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const FACEBOOK_CONTAINER_PARAMS = {
+ existing_addons: [
+ "@contain-facebook",
+ "{bb1b80be-e6b3-40a1-9b6e-9d4073343f0b}",
+ "{a50d61ca-d27b-437a-8b52-5fd801a0a88b}",
+ ],
+ open_urls: ["www.facebook.com", "facebook.com"],
+ sumo_path: "extensionrecommendations",
+ min_frecency: 10000,
+};
+const GOOGLE_TRANSLATE_PARAMS = {
+ existing_addons: [
+ "jid1-93WyvpgvxzGATw@jetpack",
+ "{087ef4e1-4286-4be6-9aa3-8d6c420ee1db}",
+ "{4170faaa-ee87-4a0e-b57a-1aec49282887}",
+ "jid1-TMndP6cdKgxLcQ@jetpack",
+ "s3google@translator",
+ "{9c63d15c-b4d9-43bd-b223-37f0a1f22e2a}",
+ "translator@zoli.bod",
+ "{8cda9ce6-7893-4f47-ac70-a65215cec288}",
+ "simple-translate@sienori",
+ "@translatenow",
+ "{a79fafce-8da6-4685-923f-7ba1015b8748})",
+ "{8a802b5a-eeab-11e2-a41d-b0096288709b}",
+ "jid0-fbHwsGfb6kJyq2hj65KnbGte3yT@jetpack",
+ "storetranslate.plugin@gmail.com",
+ "jid1-r2tWDbSkq8AZK1@jetpack",
+ "{b384b75c-c978-4c4d-b3cf-62a82d8f8f12}",
+ "jid1-f7dnBeTj8ElpWQ@jetpack",
+ "{dac8a935-4775-4918-9205-5c0600087dc4}",
+ "gtranslation2@slam.com",
+ "{e20e0de5-1667-4df4-bd69-705720e37391}",
+ "{09e26ae9-e9c1-477c-80a6-99934212f2fe}",
+ "mgxtranslator@magemagix.com",
+ "gtranslatewins@mozilla.org",
+ ],
+ open_urls: ["translate.google.com"],
+ sumo_path: "extensionrecommendations",
+ min_frecency: 10000,
+};
+const YOUTUBE_ENHANCE_PARAMS = {
+ existing_addons: [
+ "enhancerforyoutube@maximerf.addons.mozilla.org",
+ "{dc8f61ab-5e98-4027-98ef-bb2ff6060d71}",
+ "{7b1bf0b6-a1b9-42b0-b75d-252036438bdc}",
+ "jid0-UVAeBCfd34Kk5usS8A1CBiobvM8@jetpack",
+ "iridium@particlecore.github.io",
+ "jid1-ss6kLNCbNz6u0g@jetpack",
+ "{1cf918d2-f4ea-4b4f-b34e-455283fef19f}",
+ ],
+ open_urls: ["www.youtube.com", "youtube.com"],
+ sumo_path: "extensionrecommendations",
+ min_frecency: 10000,
+};
+const WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS = {
+ existing_addons: [
+ "@wikipediacontextmenusearch",
+ "{ebf47fc8-01d8-4dba-aa04-2118402f4b20}",
+ "{5737a280-b359-4e26-95b0-adec5915a854}",
+ "olivier.debroqueville@gmail.com",
+ "{3923146e-98cb-472b-9c13-f6849d34d6b8}",
+ ],
+ open_urls: ["www.wikipedia.org", "wikipedia.org"],
+ sumo_path: "extensionrecommendations",
+ min_frecency: 10000,
+};
+const REDDIT_ENHANCEMENT_PARAMS = {
+ existing_addons: ["jid1-xUfzOsOFlzSOXg@jetpack"],
+ open_urls: ["www.reddit.com", "reddit.com"],
+ sumo_path: "extensionrecommendations",
+ min_frecency: 10000,
+};
+
+const CFR_MESSAGES = [
+ {
+ id: "FACEBOOK_CONTAINER_3",
+ template: "cfr_doorhanger",
+ groups: ["cfr-message-provider"],
+ content: {
+ layout: "addon_recommendation",
+ category: "cfrAddons",
+ bucket_id: "CFR_M1",
+ notification_text: {
+ string_id: "cfr-doorhanger-extension-notification2",
+ },
+ heading_text: { string_id: "cfr-doorhanger-extension-heading" },
+ info_icon: {
+ label: { string_id: "cfr-doorhanger-extension-sumo-link" },
+ sumo_path: FACEBOOK_CONTAINER_PARAMS.sumo_path,
+ },
+ addon: {
+ id: "954390",
+ title: "Facebook Container",
+ icon: "chrome://browser/skin/addons/addon-install-downloading.svg",
+ rating: "4.6",
+ users: "299019",
+ author: "Mozilla",
+ amo_url: "https://addons.mozilla.org/firefox/addon/facebook-container/",
+ },
+ text: "Stop Facebook from tracking your activity across the web. Use Facebook the way you normally do without annoying ads following you around.",
+ buttons: {
+ primary: {
+ label: { string_id: "cfr-doorhanger-extension-ok-button" },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com", telemetrySource: "amo" },
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "cfr-doorhanger-extension-cancel-button" },
+ action: { type: "CANCEL" },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: { category: "general-cfraddons" },
+ },
+ },
+ ],
+ },
+ },
+ frequency: { lifetime: 3 },
+ targeting: `
+ localeLanguageCode == "en" &&
+ (xpinstallEnabled == true) &&
+ (${JSON.stringify(
+ FACEBOOK_CONTAINER_PARAMS.existing_addons
+ )} intersect addonsInfo.addons|keys)|length == 0 &&
+ (${JSON.stringify(
+ FACEBOOK_CONTAINER_PARAMS.open_urls
+ )} intersect topFrecentSites[.frecency >= ${
+ FACEBOOK_CONTAINER_PARAMS.min_frecency
+ }]|mapToProperty('host'))|length > 0`,
+ trigger: { id: "openURL", params: FACEBOOK_CONTAINER_PARAMS.open_urls },
+ },
+ {
+ id: "GOOGLE_TRANSLATE_3",
+ groups: ["cfr-message-provider"],
+ template: "cfr_doorhanger",
+ content: {
+ layout: "addon_recommendation",
+ category: "cfrAddons",
+ bucket_id: "CFR_M1",
+ notification_text: {
+ string_id: "cfr-doorhanger-extension-notification2",
+ },
+ heading_text: { string_id: "cfr-doorhanger-extension-heading" },
+ info_icon: {
+ label: { string_id: "cfr-doorhanger-extension-sumo-link" },
+ sumo_path: GOOGLE_TRANSLATE_PARAMS.sumo_path,
+ },
+ addon: {
+ id: "445852",
+ title: "To Google Translate",
+ icon: "chrome://browser/skin/addons/addon-install-downloading.svg",
+ rating: "4.1",
+ users: "313474",
+ author: "Juan Escobar",
+ amo_url:
+ "https://addons.mozilla.org/firefox/addon/to-google-translate/",
+ },
+ text: "Instantly translate any webpage text. Simply highlight the text, right-click to open the context menu, and choose a text or aural translation.",
+ buttons: {
+ primary: {
+ label: { string_id: "cfr-doorhanger-extension-ok-button" },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com", telemetrySource: "amo" },
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "cfr-doorhanger-extension-cancel-button" },
+ action: { type: "CANCEL" },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: { category: "general-cfraddons" },
+ },
+ },
+ ],
+ },
+ },
+ frequency: { lifetime: 3 },
+ targeting: `
+ localeLanguageCode == "en" &&
+ (xpinstallEnabled == true) &&
+ (${JSON.stringify(
+ GOOGLE_TRANSLATE_PARAMS.existing_addons
+ )} intersect addonsInfo.addons|keys)|length == 0 &&
+ (${JSON.stringify(
+ GOOGLE_TRANSLATE_PARAMS.open_urls
+ )} intersect topFrecentSites[.frecency >= ${
+ GOOGLE_TRANSLATE_PARAMS.min_frecency
+ }]|mapToProperty('host'))|length > 0`,
+ trigger: { id: "openURL", params: GOOGLE_TRANSLATE_PARAMS.open_urls },
+ },
+ {
+ id: "YOUTUBE_ENHANCE_3",
+ groups: ["cfr-message-provider"],
+ template: "cfr_doorhanger",
+ content: {
+ layout: "addon_recommendation",
+ category: "cfrAddons",
+ bucket_id: "CFR_M1",
+ notification_text: {
+ string_id: "cfr-doorhanger-extension-notification2",
+ },
+ heading_text: { string_id: "cfr-doorhanger-extension-heading" },
+ info_icon: {
+ label: { string_id: "cfr-doorhanger-extension-sumo-link" },
+ sumo_path: YOUTUBE_ENHANCE_PARAMS.sumo_path,
+ },
+ addon: {
+ id: "700308",
+ title: "Enhancer for YouTube\u2122",
+ icon: "chrome://browser/skin/addons/addon-install-downloading.svg",
+ rating: "4.8",
+ users: "357328",
+ author: "Maxime RF",
+ amo_url:
+ "https://addons.mozilla.org/firefox/addon/enhancer-for-youtube/",
+ },
+ text: "Take control of your YouTube experience. Automatically block annoying ads, set playback speed and volume, remove annotations, and more.",
+ buttons: {
+ primary: {
+ label: { string_id: "cfr-doorhanger-extension-ok-button" },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com", telemetrySource: "amo" },
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "cfr-doorhanger-extension-cancel-button" },
+ action: { type: "CANCEL" },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: { category: "general-cfraddons" },
+ },
+ },
+ ],
+ },
+ },
+ frequency: { lifetime: 3 },
+ targeting: `
+ localeLanguageCode == "en" &&
+ (xpinstallEnabled == true) &&
+ (${JSON.stringify(
+ YOUTUBE_ENHANCE_PARAMS.existing_addons
+ )} intersect addonsInfo.addons|keys)|length == 0 &&
+ (${JSON.stringify(
+ YOUTUBE_ENHANCE_PARAMS.open_urls
+ )} intersect topFrecentSites[.frecency >= ${
+ YOUTUBE_ENHANCE_PARAMS.min_frecency
+ }]|mapToProperty('host'))|length > 0`,
+ trigger: { id: "openURL", params: YOUTUBE_ENHANCE_PARAMS.open_urls },
+ },
+ {
+ id: "WIKIPEDIA_CONTEXT_MENU_SEARCH_3",
+ groups: ["cfr-message-provider"],
+ template: "cfr_doorhanger",
+ exclude: true,
+ content: {
+ layout: "addon_recommendation",
+ category: "cfrAddons",
+ bucket_id: "CFR_M1",
+ notification_text: {
+ string_id: "cfr-doorhanger-extension-notification2",
+ },
+ heading_text: { string_id: "cfr-doorhanger-extension-heading" },
+ info_icon: {
+ label: { string_id: "cfr-doorhanger-extension-sumo-link" },
+ sumo_path: WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.sumo_path,
+ },
+ addon: {
+ id: "659026",
+ title: "Wikipedia Context Menu Search",
+ icon: "chrome://browser/skin/addons/addon-install-downloading.svg",
+ rating: "4.9",
+ users: "3095",
+ author: "Nick Diedrich",
+ amo_url:
+ "https://addons.mozilla.org/firefox/addon/wikipedia-context-menu-search/",
+ },
+ text: "Get to a Wikipedia page fast, from anywhere on the web. Just highlight any webpage text and right-click to open the context menu to start a Wikipedia search.",
+ buttons: {
+ primary: {
+ label: { string_id: "cfr-doorhanger-extension-ok-button" },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com", telemetrySource: "amo" },
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "cfr-doorhanger-extension-cancel-button" },
+ action: { type: "CANCEL" },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: { category: "general-cfraddons" },
+ },
+ },
+ ],
+ },
+ },
+ frequency: { lifetime: 3 },
+ targeting: `
+ localeLanguageCode == "en" &&
+ (xpinstallEnabled == true) &&
+ (${JSON.stringify(
+ WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.existing_addons
+ )} intersect addonsInfo.addons|keys)|length == 0 &&
+ (${JSON.stringify(
+ WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.open_urls
+ )} intersect topFrecentSites[.frecency >= ${
+ WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.min_frecency
+ }]|mapToProperty('host'))|length > 0`,
+ trigger: {
+ id: "openURL",
+ params: WIKIPEDIA_CONTEXT_MENU_SEARCH_PARAMS.open_urls,
+ },
+ },
+ {
+ id: "REDDIT_ENHANCEMENT_3",
+ groups: ["cfr-message-provider"],
+ template: "cfr_doorhanger",
+ exclude: true,
+ content: {
+ layout: "addon_recommendation",
+ category: "cfrAddons",
+ bucket_id: "CFR_M1",
+ notification_text: {
+ string_id: "cfr-doorhanger-extension-notification2",
+ },
+ heading_text: { string_id: "cfr-doorhanger-extension-heading" },
+ info_icon: {
+ label: { string_id: "cfr-doorhanger-extension-sumo-link" },
+ sumo_path: REDDIT_ENHANCEMENT_PARAMS.sumo_path,
+ },
+ addon: {
+ id: "387429",
+ title: "Reddit Enhancement Suite",
+ icon: "chrome://browser/skin/addons/addon-install-downloading.svg",
+ rating: "4.6",
+ users: "258129",
+ author: "honestbleeps",
+ amo_url:
+ "https://addons.mozilla.org/firefox/addon/reddit-enhancement-suite/",
+ },
+ text: "New features include Inline Image Viewer, Never Ending Reddit (never click 'next page' again), Keyboard Navigation, Account Switcher, and User Tagger.",
+ buttons: {
+ primary: {
+ label: { string_id: "cfr-doorhanger-extension-ok-button" },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: "https://example.com", telemetrySource: "amo" },
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "cfr-doorhanger-extension-cancel-button" },
+ action: { type: "CANCEL" },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: { category: "general-cfraddons" },
+ },
+ },
+ ],
+ },
+ },
+ frequency: { lifetime: 3 },
+ targeting: `
+ localeLanguageCode == "en" &&
+ (xpinstallEnabled == true) &&
+ (${JSON.stringify(
+ REDDIT_ENHANCEMENT_PARAMS.existing_addons
+ )} intersect addonsInfo.addons|keys)|length == 0 &&
+ (${JSON.stringify(
+ REDDIT_ENHANCEMENT_PARAMS.open_urls
+ )} intersect topFrecentSites[.frecency >= ${
+ REDDIT_ENHANCEMENT_PARAMS.min_frecency
+ }]|mapToProperty('host'))|length > 0`,
+ trigger: { id: "openURL", params: REDDIT_ENHANCEMENT_PARAMS.open_urls },
+ },
+ {
+ id: "DOH_ROLLOUT_CONFIRMATION",
+ groups: ["cfr-message-provider"],
+ targeting: `
+ "doh-rollout.enabled"|preferenceValue &&
+ !"doh-rollout.disable-heuristics"|preferenceValue &&
+ !"doh-rollout.skipHeuristicsCheck"|preferenceValue &&
+ !"doh-rollout.doorhanger-decision"|preferenceValue
+ `,
+ template: "cfr_doorhanger",
+ content: {
+ skip_address_bar_notifier: true,
+ persistent_doorhanger: true,
+ anchor_id: "PanelUI-menu-button",
+ layout: "icon_and_message",
+ text: { string_id: "cfr-doorhanger-doh-body" },
+ icon: "chrome://global/skin/icons/security.svg",
+ buttons: {
+ secondary: [
+ {
+ label: { string_id: "cfr-doorhanger-doh-secondary-button" },
+ action: {
+ type: "DISABLE_DOH",
+ },
+ },
+ ],
+ primary: {
+ label: { string_id: "cfr-doorhanger-doh-primary-button-2" },
+ action: {
+ type: "ACCEPT_DOH",
+ },
+ },
+ },
+ bucket_id: "DOH_ROLLOUT_CONFIRMATION",
+ heading_text: { string_id: "cfr-doorhanger-doh-header" },
+ info_icon: {
+ label: {
+ string_id: "cfr-doorhanger-extension-sumo-link",
+ },
+ sumo_path: "extensionrecommendations",
+ },
+ notification_text: "Message from Firefox",
+ category: "cfrFeatures",
+ },
+ trigger: {
+ id: "openURL",
+ patterns: ["*://*/*"],
+ },
+ },
+ {
+ id: "SAVE_LOGIN",
+ groups: ["cfr-message-provider"],
+ frequency: {
+ lifetime: 3,
+ },
+ targeting:
+ "(!type || type == 'save') && isFxAEnabled == true && usesFirefoxSync == false",
+ template: "cfr_doorhanger",
+ content: {
+ layout: "icon_and_message",
+ text: "Securely store and sync your passwords to all your devices.",
+ icon: "chrome://browser/content/aboutlogins/icons/intro-illustration.svg",
+ icon_class: "cfr-doorhanger-large-icon",
+ buttons: {
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-cancel-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: {
+ category: "general-cfrfeatures",
+ },
+ },
+ },
+ ],
+ primary: {
+ label: {
+ value: "Turn on Sync",
+ attributes: { accesskey: "T" },
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: {
+ category: "sync",
+ entrypoint: "cfr-save-login",
+ },
+ },
+ },
+ },
+ bucket_id: "CFR_SAVE_LOGIN",
+ heading_text: "Never Lose a Password Again",
+ info_icon: {
+ label: {
+ string_id: "cfr-doorhanger-extension-sumo-link",
+ },
+ sumo_path: "extensionrecommendations",
+ },
+ notification_text: {
+ string_id: "cfr-doorhanger-feature-notification",
+ },
+ category: "cfrFeatures",
+ },
+ trigger: {
+ id: "newSavedLogin",
+ },
+ },
+ {
+ id: "UPDATE_LOGIN",
+ groups: ["cfr-message-provider"],
+ frequency: {
+ lifetime: 3,
+ },
+ targeting:
+ "type == 'update' && isFxAEnabled == true && usesFirefoxSync == false",
+ template: "cfr_doorhanger",
+ content: {
+ layout: "icon_and_message",
+ text: "Securely store and sync your passwords to all your devices.",
+ icon: "chrome://browser/content/aboutlogins/icons/intro-illustration.svg",
+ icon_class: "cfr-doorhanger-large-icon",
+ buttons: {
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-cancel-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: {
+ category: "general-cfrfeatures",
+ },
+ },
+ },
+ ],
+ primary: {
+ label: {
+ value: "Turn on Sync",
+ attributes: { accesskey: "T" },
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: {
+ category: "sync",
+ entrypoint: "cfr-update-login",
+ },
+ },
+ },
+ },
+ bucket_id: "CFR_UPDATE_LOGIN",
+ heading_text: "Never Lose a Password Again",
+ info_icon: {
+ label: {
+ string_id: "cfr-doorhanger-extension-sumo-link",
+ },
+ sumo_path: "extensionrecommendations",
+ },
+ notification_text: {
+ string_id: "cfr-doorhanger-feature-notification",
+ },
+ category: "cfrFeatures",
+ },
+ trigger: {
+ id: "newSavedLogin",
+ },
+ },
+ {
+ id: "MILESTONE_MESSAGE",
+ groups: ["cfr-message-provider"],
+ template: "milestone_message",
+ content: {
+ layout: "short_message",
+ category: "cfrFeatures",
+ anchor_id: "tracking-protection-icon-container",
+ skip_address_bar_notifier: true,
+ bucket_id: "CFR_MILESTONE_MESSAGE",
+ heading_text: { string_id: "cfr-doorhanger-milestone-heading2" },
+ notification_text: "",
+ text: "",
+ buttons: {
+ primary: {
+ label: { string_id: "cfr-doorhanger-milestone-ok-button" },
+ action: { type: "OPEN_PROTECTION_REPORT" },
+ event: "PROTECTION",
+ },
+ secondary: [
+ {
+ label: { string_id: "cfr-doorhanger-milestone-close-button" },
+ action: { type: "CANCEL" },
+ event: "DISMISS",
+ },
+ ],
+ },
+ },
+ targeting: "pageLoad >= 1",
+ frequency: {
+ lifetime: 7, // Length of privacy.contentBlocking.cfr-milestone.milestones pref
+ },
+ trigger: {
+ id: "contentBlocking",
+ params: ["ContentBlockingMilestone"],
+ },
+ },
+ {
+ id: "HEARTBEAT_TACTIC_2",
+ groups: ["cfr-message-provider"],
+ template: "cfr_urlbar_chiclet",
+ content: {
+ layout: "chiclet_open_url",
+ category: "cfrHeartbeat",
+ bucket_id: "HEARTBEAT_TACTIC_2",
+ notification_text: "Improve Firefox",
+ active_color: "#595e91",
+ action: {
+ url: "http://example.com/%VERSION%/",
+ where: "tabshifted",
+ },
+ },
+ targeting: "false",
+ frequency: {
+ lifetime: 3,
+ },
+ trigger: {
+ id: "openURL",
+ patterns: ["*://*/*"],
+ },
+ },
+ {
+ id: "HOMEPAGE_REMEDIATION_82",
+ groups: ["cfr-message-provider"],
+ frequency: {
+ lifetime: 3,
+ },
+ targeting:
+ "!homePageSettings.isDefault && homePageSettings.isCustomUrl && homePageSettings.urls[.host == 'google.com']|length > 0 && visitsCount >= 3 && userPrefs.cfrFeatures",
+ template: "cfr_doorhanger",
+ content: {
+ layout: "icon_and_message",
+ text: "Update your homepage to search Google while also being able to search your Firefox history and bookmarks.",
+ icon: "chrome://global/skin/icons/search-glass.svg",
+ buttons: {
+ secondary: [
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-cancel-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: {
+ category: "general-cfrfeatures",
+ },
+ },
+ },
+ ],
+ primary: {
+ label: {
+ value: "Activate now",
+ attributes: {
+ accesskey: "A",
+ },
+ },
+ action: {
+ type: "CONFIGURE_HOMEPAGE",
+ data: {
+ homePage: "default",
+ newtab: "default",
+ layout: {
+ search: true,
+ topsites: false,
+ highlights: false,
+ topstories: false,
+ },
+ },
+ },
+ },
+ },
+ bucket_id: "HOMEPAGE_REMEDIATION_82",
+ heading_text: "A better search experience",
+ info_icon: {
+ label: {
+ string_id: "cfr-doorhanger-extension-sumo-link",
+ },
+ sumo_path: "extensionrecommendations",
+ },
+ notification_text: {
+ string_id: "cfr-doorhanger-feature-notification",
+ },
+ category: "cfrFeatures",
+ },
+ trigger: {
+ id: "openURL",
+ params: ["google.com", "www.google.com"],
+ },
+ },
+ {
+ id: "INFOBAR_ACTION_86",
+ groups: ["cfr-message-provider"],
+ targeting: "false",
+ template: "infobar",
+ content: {
+ type: "global",
+ text: { string_id: "default-browser-notification-message" },
+ buttons: [
+ {
+ label: { string_id: "default-browser-notification-button" },
+ primary: true,
+ accessKey: "O",
+ action: {
+ type: "SET_DEFAULT_BROWSER",
+ },
+ },
+ ],
+ },
+ trigger: { id: "defaultBrowserCheck" },
+ },
+ {
+ id: "PREF_OBSERVER_MESSAGE_94",
+ groups: ["cfr-message-provider"],
+ targeting: "true",
+ template: "infobar",
+ content: {
+ type: "global",
+ text: "This is a message triggered when a pref value changes",
+ buttons: [
+ {
+ label: "OK",
+ primary: true,
+ accessKey: "O",
+ action: {
+ type: "CANCEL",
+ },
+ },
+ ],
+ },
+ trigger: { id: "preferenceObserver", params: ["foo.bar"] },
+ },
+];
+
+export const CFRMessageProvider = {
+ getMessages() {
+ return Promise.resolve(CFR_MESSAGES.filter(msg => !msg.exclude));
+ },
+};
diff --git a/browser/components/asrouter/modules/CFRPageActions.sys.mjs b/browser/components/asrouter/modules/CFRPageActions.sys.mjs
new file mode 100644
index 0000000000..cf7719d9eb
--- /dev/null
+++ b/browser/components/asrouter/modules/CFRPageActions.sys.mjs
@@ -0,0 +1,1086 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// We use importESModule here instead of static import so that
+// the Karma test environment won't choke on this module. This
+// is because the Karma test environment already stubs out
+// XPCOMUtils and overrides importESModule to be a no-op (which
+// can't be done for a static import statement).
+
+// eslint-disable-next-line mozilla/use-static-import
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "TrackingDBService",
+ "@mozilla.org/tracking-db-service;1",
+ "nsITrackingDBService"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "milestones",
+ "browser.contentblocking.cfr-milestone.milestones",
+ "[]",
+ null,
+ JSON.parse
+);
+
+const POPUP_NOTIFICATION_ID = "contextual-feature-recommendation";
+const SUMO_BASE_URL = Services.urlFormatter.formatURLPref(
+ "app.support.baseURL"
+);
+const ADDONS_API_URL =
+ "https://services.addons.mozilla.org/api/v4/addons/addon";
+
+const DELAY_BEFORE_EXPAND_MS = 1000;
+const CATEGORY_ICONS = {
+ cfrAddons: "webextensions-icon",
+ cfrFeatures: "recommendations-icon",
+ cfrHeartbeat: "highlights-icon",
+};
+
+/**
+ * A WeakMap from browsers to {host, recommendation} pairs. Recommendations are
+ * defined in the ExtensionDoorhanger.schema.json.
+ *
+ * A recommendation is specific to a browser and host and is active until the
+ * given browser is closed or the user navigates (within that browser) away from
+ * the host.
+ */
+let RecommendationMap = new WeakMap();
+
+/**
+ * A WeakMap from windows to their CFR PageAction.
+ */
+let PageActionMap = new WeakMap();
+
+/**
+ * We need one PageAction for each window
+ */
+export class PageAction {
+ constructor(win, dispatchCFRAction) {
+ this.window = win;
+
+ this.urlbar = win.gURLBar; // The global URLBar object
+ this.urlbarinput = win.gURLBar.textbox; // The URLBar DOM node
+
+ this.container = win.document.getElementById(
+ "contextual-feature-recommendation"
+ );
+ this.button = win.document.getElementById("cfr-button");
+ this.label = win.document.getElementById("cfr-label");
+
+ // This should NOT be use directly to dispatch message-defined actions attached to buttons.
+ // Please use dispatchUserAction instead.
+ this._dispatchCFRAction = dispatchCFRAction;
+
+ this._popupStateChange = this._popupStateChange.bind(this);
+ this._collapse = this._collapse.bind(this);
+ this._cfrUrlbarButtonClick = this._cfrUrlbarButtonClick.bind(this);
+ this._executeNotifierAction = this._executeNotifierAction.bind(this);
+ this.dispatchUserAction = this.dispatchUserAction.bind(this);
+
+ // Saved timeout IDs for scheduled state changes, so they can be cancelled
+ this.stateTransitionTimeoutIDs = [];
+
+ ChromeUtils.defineLazyGetter(this, "isDarkTheme", () => {
+ try {
+ return this.window.document.documentElement.hasAttribute(
+ "lwt-toolbar-field-brighttext"
+ );
+ } catch (e) {
+ return false;
+ }
+ });
+ }
+
+ addImpression(recommendation) {
+ this._dispatchImpression(recommendation);
+ // Only send an impression ping upon the first expansion.
+ // Note that when the user clicks on the "show" button on the asrouter admin
+ // page (both `bucket_id` and `id` will be set as null), we don't want to send
+ // the impression ping in that case.
+ if (!!recommendation.id && !!recommendation.content.bucket_id) {
+ this._sendTelemetry({
+ message_id: recommendation.id,
+ bucket_id: recommendation.content.bucket_id,
+ event: "IMPRESSION",
+ });
+ }
+ }
+
+ reloadL10n() {
+ lazy.RemoteL10n.reloadL10n();
+ }
+
+ async showAddressBarNotifier(recommendation, shouldExpand = false) {
+ this.container.hidden = false;
+
+ let notificationText = await this.getStrings(
+ recommendation.content.notification_text
+ );
+ this.label.value = notificationText;
+ if (notificationText.attributes) {
+ this.button.setAttribute(
+ "tooltiptext",
+ notificationText.attributes.tooltiptext
+ );
+ // For a11y, we want the more descriptive text.
+ this.container.setAttribute(
+ "aria-label",
+ notificationText.attributes.tooltiptext
+ );
+ }
+ this.container.setAttribute(
+ "data-cfr-icon",
+ CATEGORY_ICONS[recommendation.content.category]
+ );
+ if (recommendation.content.active_color) {
+ this.container.style.setProperty(
+ "--cfr-active-color",
+ recommendation.content.active_color
+ );
+ }
+
+ if (recommendation.content.active_text_color) {
+ this.container.style.setProperty(
+ "--cfr-active-text-color",
+ recommendation.content.active_text_color
+ );
+ }
+
+ // Wait for layout to flush to avoid a synchronous reflow then calculate the
+ // label width. We can safely get the width even though the recommendation is
+ // collapsed; the label itself remains full width (with its overflow hidden)
+ let [{ width }] = await this.window.promiseDocumentFlushed(() =>
+ this.label.getClientRects()
+ );
+ this.urlbarinput.style.setProperty("--cfr-label-width", `${width}px`);
+
+ this.container.addEventListener("click", this._cfrUrlbarButtonClick);
+ // Collapse the recommendation on url bar focus in order to free up more
+ // space to display and edit the url
+ this.urlbar.addEventListener("focus", this._collapse);
+
+ if (shouldExpand) {
+ this._clearScheduledStateChanges();
+
+ // After one second, expand
+ this._expand(DELAY_BEFORE_EXPAND_MS);
+
+ this.addImpression(recommendation);
+ }
+
+ if (notificationText.attributes) {
+ this.window.A11yUtils.announce({
+ raw: notificationText.attributes["a11y-announcement"],
+ source: this.container,
+ });
+ }
+ }
+
+ hideAddressBarNotifier() {
+ this.container.hidden = true;
+ this._clearScheduledStateChanges();
+ this.urlbarinput.removeAttribute("cfr-recommendation-state");
+ this.container.removeEventListener("click", this._cfrUrlbarButtonClick);
+ this.urlbar.removeEventListener("focus", this._collapse);
+ if (this.currentNotification) {
+ this.window.PopupNotifications.remove(this.currentNotification);
+ this.currentNotification = null;
+ }
+ }
+
+ _expand(delay) {
+ if (delay > 0) {
+ this.stateTransitionTimeoutIDs.push(
+ this.window.setTimeout(() => {
+ this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded");
+ }, delay)
+ );
+ } else {
+ // Non-delayed state change overrides any scheduled state changes
+ this._clearScheduledStateChanges();
+ this.urlbarinput.setAttribute("cfr-recommendation-state", "expanded");
+ }
+ }
+
+ _collapse(delay) {
+ if (delay > 0) {
+ this.stateTransitionTimeoutIDs.push(
+ this.window.setTimeout(() => {
+ if (
+ this.urlbarinput.getAttribute("cfr-recommendation-state") ===
+ "expanded"
+ ) {
+ this.urlbarinput.setAttribute(
+ "cfr-recommendation-state",
+ "collapsed"
+ );
+ }
+ }, delay)
+ );
+ } else {
+ // Non-delayed state change overrides any scheduled state changes
+ this._clearScheduledStateChanges();
+ if (
+ this.urlbarinput.getAttribute("cfr-recommendation-state") === "expanded"
+ ) {
+ this.urlbarinput.setAttribute("cfr-recommendation-state", "collapsed");
+ }
+ }
+ }
+
+ _clearScheduledStateChanges() {
+ while (this.stateTransitionTimeoutIDs.length) {
+ // clearTimeout is safe even with invalid/expired IDs
+ this.window.clearTimeout(this.stateTransitionTimeoutIDs.pop());
+ }
+ }
+
+ // This is called when the popup closes as a result of interaction _outside_
+ // the popup, e.g. by hitting <esc>
+ _popupStateChange(state) {
+ if (state === "shown") {
+ if (this._autoFocus) {
+ this.window.document.commandDispatcher.advanceFocusIntoSubtree(
+ this.currentNotification.owner.panel
+ );
+ this._autoFocus = false;
+ }
+ } else if (state === "removed") {
+ if (this.currentNotification) {
+ this.window.PopupNotifications.remove(this.currentNotification);
+ this.currentNotification = null;
+ }
+ } else if (state === "dismissed") {
+ const message = RecommendationMap.get(this.currentNotification?.browser);
+ this._sendTelemetry({
+ message_id: message?.id,
+ bucket_id: message?.content.bucket_id,
+ event: "DISMISS",
+ });
+ this._collapse();
+ }
+ }
+
+ shouldShowDoorhanger(recommendation) {
+ if (recommendation.content.layout === "chiclet_open_url") {
+ return false;
+ }
+
+ return true;
+ }
+
+ dispatchUserAction(action) {
+ this._dispatchCFRAction(
+ { type: "USER_ACTION", data: action },
+ this.window.gBrowser.selectedBrowser
+ );
+ }
+
+ _dispatchImpression(message) {
+ this._dispatchCFRAction({ type: "IMPRESSION", data: message });
+ }
+
+ _sendTelemetry(ping) {
+ const data = { action: "cfr_user_event", source: "CFR", ...ping };
+ if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) {
+ data.is_private = true;
+ }
+ this._dispatchCFRAction({
+ type: "DOORHANGER_TELEMETRY",
+ data,
+ });
+ }
+
+ _blockMessage(messageID) {
+ this._dispatchCFRAction({
+ type: "BLOCK_MESSAGE_BY_ID",
+ data: { id: messageID },
+ });
+ }
+
+ maybeLoadCustomElement(win) {
+ if (!win.customElements.get("remote-text")) {
+ Services.scriptloader.loadSubScript(
+ "resource://activity-stream/data/custom-elements/paragraph.js",
+ win
+ );
+ }
+ }
+
+ /**
+ * getStrings - Handles getting the localized strings vs message overrides.
+ * If string_id is not defined it assumes you passed in an override
+ * message and it just returns it.
+ * If subAttribute is provided, the string for it is returned.
+ * @return A string. One of 1) passed in string 2) a String object with
+ * attributes property if there are attributes 3) the sub attribute.
+ */
+ async getStrings(string, subAttribute = "") {
+ if (!string.string_id) {
+ if (subAttribute) {
+ if (string.attributes) {
+ return string.attributes[subAttribute];
+ }
+
+ console.error(`String ${string.value} does not contain any attributes`);
+ return subAttribute;
+ }
+
+ if (typeof string.value === "string") {
+ const stringWithAttributes = new String(string.value); // eslint-disable-line no-new-wrappers
+ stringWithAttributes.attributes = string.attributes;
+ return stringWithAttributes;
+ }
+
+ return string;
+ }
+
+ const [localeStrings] = await lazy.RemoteL10n.l10n.formatMessages([
+ {
+ id: string.string_id,
+ args: string.args,
+ },
+ ]);
+
+ const mainString = new String(localeStrings.value); // eslint-disable-line no-new-wrappers
+ if (localeStrings.attributes) {
+ const attributes = localeStrings.attributes.reduce((acc, attribute) => {
+ acc[attribute.name] = attribute.value;
+ return acc;
+ }, {});
+ mainString.attributes = attributes;
+ }
+
+ return subAttribute ? mainString.attributes[subAttribute] : mainString;
+ }
+
+ async _setAddonRating(document, content) {
+ const footerFilledStars = this.window.document.getElementById(
+ "cfr-notification-footer-filled-stars"
+ );
+ const footerEmptyStars = this.window.document.getElementById(
+ "cfr-notification-footer-empty-stars"
+ );
+ const footerUsers = this.window.document.getElementById(
+ "cfr-notification-footer-users"
+ );
+
+ const rating = content.addon?.rating;
+ if (rating) {
+ const MAX_RATING = 5;
+ const STARS_WIDTH = 16 * MAX_RATING;
+ const calcWidth = stars => `${(stars / MAX_RATING) * STARS_WIDTH}px`;
+ const filledWidth =
+ rating <= MAX_RATING ? calcWidth(rating) : calcWidth(MAX_RATING);
+ const emptyWidth =
+ rating <= MAX_RATING ? calcWidth(MAX_RATING - rating) : calcWidth(0);
+
+ footerFilledStars.style.width = filledWidth;
+ footerEmptyStars.style.width = emptyWidth;
+
+ const ratingString = await this.getStrings(
+ {
+ string_id: "cfr-doorhanger-extension-rating",
+ args: { total: rating },
+ },
+ "tooltiptext"
+ );
+ footerFilledStars.setAttribute("tooltiptext", ratingString);
+ footerEmptyStars.setAttribute("tooltiptext", ratingString);
+ } else {
+ footerFilledStars.style.width = "";
+ footerEmptyStars.style.width = "";
+ footerFilledStars.removeAttribute("tooltiptext");
+ footerEmptyStars.removeAttribute("tooltiptext");
+ }
+
+ const users = content.addon?.users;
+ if (users) {
+ footerUsers.setAttribute("value", users);
+ footerUsers.hidden = false;
+ } else {
+ // Prevent whitespace around empty label from affecting other spacing
+ footerUsers.hidden = true;
+ footerUsers.removeAttribute("value");
+ }
+ }
+
+ _createElementAndAppend({ type, id }, parent) {
+ let element = this.window.document.createXULElement(type);
+ if (id) {
+ element.setAttribute("id", id);
+ }
+ parent.appendChild(element);
+ return element;
+ }
+
+ async _renderMilestonePopup(message, browser) {
+ this.maybeLoadCustomElement(this.window);
+
+ let { content, id } = message;
+ let { primary, secondary } = content.buttons;
+ let earliestDate = await lazy.TrackingDBService.getEarliestRecordedDate();
+ let timestamp = earliestDate ?? new Date().getTime();
+ let panelTitle = "";
+ let headerLabel = this.window.document.getElementById(
+ "cfr-notification-header-label"
+ );
+ let reachedMilestone = 0;
+ let totalSaved = await lazy.TrackingDBService.sumAllEvents();
+ for (let milestone of lazy.milestones) {
+ if (totalSaved >= milestone) {
+ reachedMilestone = milestone;
+ }
+ }
+ if (headerLabel.firstChild) {
+ headerLabel.firstChild.remove();
+ }
+ headerLabel.appendChild(
+ lazy.RemoteL10n.createElement(this.window.document, "span", {
+ content: message.content.heading_text,
+ attributes: {
+ blockedCount: reachedMilestone,
+ date: timestamp,
+ },
+ })
+ );
+
+ // Use the message layout as a CSS selector to hide different parts of the
+ // notification template markup
+ this.window.document
+ .getElementById("contextual-feature-recommendation-notification")
+ .setAttribute("data-notification-category", content.layout);
+ this.window.document
+ .getElementById("contextual-feature-recommendation-notification")
+ .setAttribute("data-notification-bucket", content.bucket_id);
+
+ let primaryBtnString = await this.getStrings(primary.label);
+ let primaryActionCallback = () => {
+ this.dispatchUserAction(primary.action);
+ this._sendTelemetry({
+ message_id: id,
+ bucket_id: content.bucket_id,
+ event: "CLICK_BUTTON",
+ });
+
+ RecommendationMap.delete(browser);
+ // Invalidate the pref after the user interacts with the button.
+ // We don't need to show the illustration in the privacy panel.
+ Services.prefs.clearUserPref(
+ "browser.contentblocking.cfr-milestone.milestone-shown-time"
+ );
+ };
+
+ let secondaryBtnString = await this.getStrings(secondary[0].label);
+ let secondaryActionsCallback = () => {
+ this.dispatchUserAction(secondary[0].action);
+ this._sendTelemetry({
+ message_id: id,
+ bucket_id: content.bucket_id,
+ event: "DISMISS",
+ });
+ RecommendationMap.delete(browser);
+ };
+
+ let mainAction = {
+ label: primaryBtnString,
+ accessKey: primaryBtnString.attributes.accesskey,
+ callback: primaryActionCallback,
+ };
+
+ let secondaryActions = [
+ {
+ label: secondaryBtnString,
+ accessKey: secondaryBtnString.attributes.accesskey,
+ callback: secondaryActionsCallback,
+ },
+ ];
+
+ // Actually show the notification
+ this.currentNotification = this.window.PopupNotifications.show(
+ browser,
+ POPUP_NOTIFICATION_ID,
+ panelTitle,
+ "cfr",
+ mainAction,
+ secondaryActions,
+ {
+ hideClose: true,
+ persistWhileVisible: true,
+ recordTelemetryInPrivateBrowsing: content.show_in_private_browsing,
+ }
+ );
+ Services.prefs.setIntPref(
+ "browser.contentblocking.cfr-milestone.milestone-achieved",
+ reachedMilestone
+ );
+ Services.prefs.setStringPref(
+ "browser.contentblocking.cfr-milestone.milestone-shown-time",
+ Date.now().toString()
+ );
+ }
+
+ // eslint-disable-next-line max-statements
+ async _renderPopup(message, browser) {
+ this.maybeLoadCustomElement(this.window);
+
+ const { id, content } = message;
+
+ const headerLabel = this.window.document.getElementById(
+ "cfr-notification-header-label"
+ );
+ const headerLink = this.window.document.getElementById(
+ "cfr-notification-header-link"
+ );
+ const headerImage = this.window.document.getElementById(
+ "cfr-notification-header-image"
+ );
+ const footerText = this.window.document.getElementById(
+ "cfr-notification-footer-text"
+ );
+ const footerLink = this.window.document.getElementById(
+ "cfr-notification-footer-learn-more-link"
+ );
+ const { primary, secondary } = content.buttons;
+ let primaryActionCallback;
+ let persistent = !!content.persistent_doorhanger;
+ let options = {
+ persistent,
+ persistWhileVisible: persistent,
+ recordTelemetryInPrivateBrowsing: content.show_in_private_browsing,
+ };
+ let panelTitle;
+
+ headerLabel.value = await this.getStrings(content.heading_text);
+ if (content.info_icon) {
+ headerLink.setAttribute(
+ "href",
+ SUMO_BASE_URL + content.info_icon.sumo_path
+ );
+ headerImage.setAttribute(
+ "tooltiptext",
+ await this.getStrings(content.info_icon.label, "tooltiptext")
+ );
+ }
+ headerLink.onclick = () =>
+ this._sendTelemetry({
+ message_id: id,
+ bucket_id: content.bucket_id,
+ event: "RATIONALE",
+ });
+ // Use the message layout as a CSS selector to hide different parts of the
+ // notification template markup
+ this.window.document
+ .getElementById("contextual-feature-recommendation-notification")
+ .setAttribute("data-notification-category", content.layout);
+ this.window.document
+ .getElementById("contextual-feature-recommendation-notification")
+ .setAttribute("data-notification-bucket", content.bucket_id);
+
+ const author = this.window.document.getElementById(
+ "cfr-notification-author"
+ );
+ if (author.firstChild) {
+ author.firstChild.remove();
+ }
+
+ switch (content.layout) {
+ case "icon_and_message":
+ //Clearing content and styles that may have been set by a prior addon_recommendation CFR
+ this._setAddonRating(this.window.document, content);
+ author.appendChild(
+ lazy.RemoteL10n.createElement(this.window.document, "span", {
+ content: content.text,
+ })
+ );
+ primaryActionCallback = () => {
+ this._blockMessage(id);
+ this.dispatchUserAction(primary.action);
+ this.hideAddressBarNotifier();
+ this._sendTelemetry({
+ message_id: id,
+ bucket_id: content.bucket_id,
+ event: "ENABLE",
+ });
+ RecommendationMap.delete(browser);
+ };
+
+ let getIcon = () => {
+ if (content.icon_dark_theme && this.isDarkTheme) {
+ return content.icon_dark_theme;
+ }
+ return content.icon;
+ };
+
+ let learnMoreURL = content.learn_more
+ ? SUMO_BASE_URL + content.learn_more
+ : null;
+
+ panelTitle = await this.getStrings(content.heading_text);
+ options = {
+ popupIconURL: getIcon(),
+ popupIconClass: content.icon_class,
+ learnMoreURL,
+ ...options,
+ };
+ break;
+ default:
+ const authorText = await this.getStrings({
+ string_id: "cfr-doorhanger-extension-author",
+ args: { name: content.addon.author },
+ });
+ panelTitle = await this.getStrings(content.addon.title);
+ await this._setAddonRating(this.window.document, content);
+ if (footerText.firstChild) {
+ footerText.firstChild.remove();
+ }
+ if (footerText.lastChild) {
+ footerText.lastChild.remove();
+ }
+
+ // Main body content of the dropdown
+ footerText.appendChild(
+ lazy.RemoteL10n.createElement(this.window.document, "span", {
+ content: content.text,
+ })
+ );
+
+ footerLink.value = await this.getStrings({
+ string_id: "cfr-doorhanger-extension-learn-more-link",
+ });
+ footerLink.setAttribute("href", content.addon.amo_url);
+ footerLink.onclick = () =>
+ this._sendTelemetry({
+ message_id: id,
+ bucket_id: content.bucket_id,
+ event: "LEARN_MORE",
+ });
+
+ footerText.appendChild(footerLink);
+ options = {
+ popupIconURL: content.addon.icon,
+ popupIconClass: content.icon_class,
+ name: authorText,
+ ...options,
+ };
+
+ primaryActionCallback = async () => {
+ primary.action.data.url =
+ // eslint-disable-next-line no-use-before-define
+ await CFRPageActions._fetchLatestAddonVersion(content.addon.id);
+ this._blockMessage(id);
+ this.dispatchUserAction(primary.action);
+ this.hideAddressBarNotifier();
+ this._sendTelemetry({
+ message_id: id,
+ bucket_id: content.bucket_id,
+ event: "INSTALL",
+ });
+ RecommendationMap.delete(browser);
+ };
+ }
+
+ const primaryBtnStrings = await this.getStrings(primary.label);
+ const mainAction = {
+ label: primaryBtnStrings,
+ accessKey: primaryBtnStrings.attributes.accesskey,
+ callback: primaryActionCallback,
+ };
+
+ let _renderSecondaryButtonAction = async (event, button) => {
+ let label = await this.getStrings(button.label);
+ let { attributes } = label;
+
+ return {
+ label,
+ accessKey: attributes.accesskey,
+ callback: () => {
+ if (button.action) {
+ this.dispatchUserAction(button.action);
+ } else {
+ this._blockMessage(id);
+ this.hideAddressBarNotifier();
+ RecommendationMap.delete(browser);
+ }
+
+ this._sendTelemetry({
+ message_id: id,
+ bucket_id: content.bucket_id,
+ event,
+ });
+ // We want to collapse if needed when we dismiss
+ this._collapse();
+ },
+ };
+ };
+
+ // For each secondary action, define default telemetry event
+ const defaultSecondaryEvent = ["DISMISS", "BLOCK", "MANAGE"];
+ const secondaryActions = await Promise.all(
+ secondary.map((button, i) => {
+ return _renderSecondaryButtonAction(
+ button.event || defaultSecondaryEvent[i],
+ button
+ );
+ })
+ );
+
+ // If the recommendation button is focused, it was probably activated via
+ // the keyboard. Therefore, focus the first element in the notification when
+ // it appears.
+ // We don't use the autofocus option provided by PopupNotifications.show
+ // because it doesn't focus the first element; i.e. the user still has to
+ // press tab once. That's not good enough, especially for screen reader
+ // users. Instead, we handle this ourselves in _popupStateChange.
+ this._autoFocus = this.window.document.activeElement === this.container;
+
+ // Actually show the notification
+ this.currentNotification = this.window.PopupNotifications.show(
+ browser,
+ POPUP_NOTIFICATION_ID,
+ panelTitle,
+ "cfr",
+ mainAction,
+ secondaryActions,
+ {
+ ...options,
+ hideClose: true,
+ eventCallback: this._popupStateChange,
+ }
+ );
+ }
+
+ _executeNotifierAction(browser, message) {
+ switch (message.content.layout) {
+ case "chiclet_open_url":
+ this._dispatchCFRAction(
+ {
+ type: "USER_ACTION",
+ data: {
+ type: "OPEN_URL",
+ data: {
+ args: message.content.action.url,
+ where: message.content.action.where,
+ },
+ },
+ },
+ this.window
+ );
+ break;
+ }
+
+ this._blockMessage(message.id);
+ this.hideAddressBarNotifier();
+ RecommendationMap.delete(browser);
+ }
+
+ /**
+ * Respond to a user click on the recommendation by showing a doorhanger/
+ * popup notification or running the action defined in the message
+ */
+ async _cfrUrlbarButtonClick(event) {
+ const browser = this.window.gBrowser.selectedBrowser;
+ if (!RecommendationMap.has(browser)) {
+ // There's no recommendation for this browser, so the user shouldn't have
+ // been able to click
+ this.hideAddressBarNotifier();
+ return;
+ }
+ const message = RecommendationMap.get(browser);
+ const { id, content } = message;
+
+ this._sendTelemetry({
+ message_id: id,
+ bucket_id: content.bucket_id,
+ event: "CLICK_DOORHANGER",
+ });
+
+ if (this.shouldShowDoorhanger(message)) {
+ // The recommendation should remain either collapsed or expanded while the
+ // doorhanger is showing
+ this._clearScheduledStateChanges(browser, message);
+ await this.showPopup();
+ } else {
+ await this._executeNotifierAction(browser, message);
+ }
+ }
+
+ _getVisibleElement(idOrEl) {
+ const element =
+ typeof idOrEl === "string"
+ ? idOrEl && this.window.document.getElementById(idOrEl)
+ : idOrEl;
+ if (!element) {
+ return null; // element doesn't exist at all
+ }
+ const { visibility, display } = this.window.getComputedStyle(element);
+ if (
+ !this.window.isElementVisible(element) ||
+ visibility !== "visible" ||
+ display === "none"
+ ) {
+ // CSS rules like visibility: hidden or display: none. these result in
+ // element being invisible and unclickable.
+ return null;
+ }
+ let widget = lazy.CustomizableUI.getWidget(idOrEl);
+ if (
+ widget &&
+ (this.window.CustomizationHandler.isCustomizing() ||
+ widget.areaType?.includes("panel"))
+ ) {
+ // The element is a customizable widget (a toolbar item, e.g. the
+ // reload button or the downloads button). Widgets can be in various
+ // areas, like the overflow panel or the customization palette.
+ // Widgets in the palette are present in the chrome's DOM during
+ // customization, but can't be used.
+ return null;
+ }
+ return element;
+ }
+
+ async showPopup() {
+ const browser = this.window.gBrowser.selectedBrowser;
+ const message = RecommendationMap.get(browser);
+ const { content } = message;
+
+ // A hacky way of setting the popup anchor outside the usual url bar icon box
+ // See https://searchfox.org/mozilla-central/rev/eb07633057d66ab25f9db4c5900eeb6913da7579/toolkit/modules/PopupNotifications.sys.mjs#44
+ browser.cfrpopupnotificationanchor =
+ this._getVisibleElement(content.anchor_id) ||
+ this._getVisibleElement(content.alt_anchor_id) ||
+ this._getVisibleElement(this.button) ||
+ this._getVisibleElement(this.container);
+
+ await this._renderPopup(message, browser);
+ }
+
+ async showMilestonePopup() {
+ const browser = this.window.gBrowser.selectedBrowser;
+ const message = RecommendationMap.get(browser);
+ const { content } = message;
+
+ // A hacky way of setting the popup anchor outside the usual url bar icon box
+ // See https://searchfox.org/mozilla-central/rev/eb07633057d66ab25f9db4c5900eeb6913da7579/toolkit/modules/PopupNotifications.sys.mjs#44
+ browser.cfrpopupnotificationanchor =
+ this.window.document.getElementById(content.anchor_id) || this.container;
+
+ await this._renderMilestonePopup(message, browser);
+ return true;
+ }
+}
+
+function isHostMatch(browser, host) {
+ return (
+ browser.documentURI.scheme.startsWith("http") &&
+ browser.documentURI.host === host
+ );
+}
+
+export const CFRPageActions = {
+ // For testing purposes
+ RecommendationMap,
+ PageActionMap,
+
+ /**
+ * To be called from browser.js on a location change, passing in the browser
+ * that's been updated
+ */
+ updatePageActions(browser) {
+ const win = browser.ownerGlobal;
+ const pageAction = PageActionMap.get(win);
+ if (!pageAction || browser !== win.gBrowser.selectedBrowser) {
+ return;
+ }
+ if (RecommendationMap.has(browser)) {
+ const recommendation = RecommendationMap.get(browser);
+ if (
+ !recommendation.content.skip_address_bar_notifier &&
+ (isHostMatch(browser, recommendation.host) ||
+ // If there is no host associated we assume we're back on a tab
+ // that had a CFR message so we should show it again
+ !recommendation.host)
+ ) {
+ // The browser has a recommendation specified with this host, so show
+ // the page action
+ pageAction.showAddressBarNotifier(recommendation);
+ } else if (!recommendation.content.persistent_doorhanger) {
+ if (recommendation.retain) {
+ // Keep the recommendation first time the user navigates away just in
+ // case they will go back to the previous page
+ pageAction.hideAddressBarNotifier();
+ recommendation.retain = false;
+ } else {
+ // The user has navigated away from the specified host in the given
+ // browser, so the recommendation is no longer valid and should be removed
+ RecommendationMap.delete(browser);
+ pageAction.hideAddressBarNotifier();
+ }
+ }
+ } else {
+ // There's no recommendation specified for this browser, so hide the page action
+ pageAction.hideAddressBarNotifier();
+ }
+ },
+
+ /**
+ * Fetch the URL to the latest add-on xpi so the recommendation can download it.
+ * @param id The add-on ID
+ * @return A string for the URL that was fetched
+ */
+ async _fetchLatestAddonVersion(id) {
+ let url = null;
+ try {
+ const response = await fetch(`${ADDONS_API_URL}/${id}/`, {
+ credentials: "omit",
+ });
+ if (response.status !== 204 && response.ok) {
+ const json = await response.json();
+ url = json.current_version.files[0].url;
+ }
+ } catch (e) {
+ console.error(
+ "Failed to get the latest add-on version for this recommendation"
+ );
+ }
+ return url;
+ },
+
+ /**
+ * Force a recommendation to be shown. Should only happen via the Admin page.
+ * @param browser The browser for the recommendation
+ * @param recommendation The recommendation to show
+ * @param dispatchCFRAction A function to dispatch resulting actions to
+ * @return Did adding the recommendation succeed?
+ */
+ async forceRecommendation(browser, recommendation, dispatchCFRAction) {
+ if (!browser) {
+ return false;
+ }
+ // If we are forcing via the Admin page, the browser comes in a different format
+ const win = browser.ownerGlobal;
+ const { id, content } = recommendation;
+ RecommendationMap.set(browser, {
+ id,
+ content,
+ retain: true,
+ });
+ if (!PageActionMap.has(win)) {
+ PageActionMap.set(win, new PageAction(win, dispatchCFRAction));
+ }
+
+ if (content.skip_address_bar_notifier) {
+ if (recommendation.template === "milestone_message") {
+ await PageActionMap.get(win).showMilestonePopup();
+ PageActionMap.get(win).addImpression(recommendation);
+ } else {
+ await PageActionMap.get(win).showPopup();
+ PageActionMap.get(win).addImpression(recommendation);
+ }
+ } else {
+ await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
+ }
+ return true;
+ },
+
+ /**
+ * Add a recommendation specific to the given browser and host.
+ * @param browser The browser for the recommendation
+ * @param host The host for the recommendation
+ * @param recommendation The recommendation to show
+ * @param dispatchCFRAction A function to dispatch resulting actions to
+ * @return Did adding the recommendation succeed?
+ */
+ async addRecommendation(browser, host, recommendation, dispatchCFRAction) {
+ if (!browser) {
+ return false;
+ }
+ const win = browser.ownerGlobal;
+ if (
+ browser !== win.gBrowser.selectedBrowser ||
+ // We can have recommendations without URL restrictions
+ (host && !isHostMatch(browser, host))
+ ) {
+ return false;
+ }
+ if (RecommendationMap.has(browser)) {
+ // Don't replace an existing message
+ return false;
+ }
+ const { id, content } = recommendation;
+ if (
+ !content.show_in_private_browsing &&
+ lazy.PrivateBrowsingUtils.isWindowPrivate(win)
+ ) {
+ return false;
+ }
+ RecommendationMap.set(browser, {
+ id,
+ host,
+ content,
+ retain: true,
+ });
+ if (!PageActionMap.has(win)) {
+ PageActionMap.set(win, new PageAction(win, dispatchCFRAction));
+ }
+
+ if (content.skip_address_bar_notifier) {
+ if (recommendation.template === "milestone_message") {
+ await PageActionMap.get(win).showMilestonePopup();
+ PageActionMap.get(win).addImpression(recommendation);
+ } else {
+ // Tracking protection messages
+ await PageActionMap.get(win).showPopup();
+ PageActionMap.get(win).addImpression(recommendation);
+ }
+ } else {
+ // Doorhanger messages
+ await PageActionMap.get(win).showAddressBarNotifier(recommendation, true);
+ }
+ return true;
+ },
+
+ /**
+ * Clear all recommendations and hide all PageActions
+ */
+ clearRecommendations() {
+ // WeakMaps aren't iterable so we have to test all existing windows
+ for (const win of Services.wm.getEnumerator("navigator:browser")) {
+ if (win.closed || !PageActionMap.has(win)) {
+ continue;
+ }
+ PageActionMap.get(win).hideAddressBarNotifier();
+ }
+ // WeakMaps don't have a `clear` method
+ PageActionMap = new WeakMap();
+ RecommendationMap = new WeakMap();
+ this.PageActionMap = PageActionMap;
+ this.RecommendationMap = RecommendationMap;
+ },
+
+ /**
+ * Reload the l10n Fluent files for all PageActions
+ */
+ reloadL10n() {
+ for (const win of Services.wm.getEnumerator("navigator:browser")) {
+ if (win.closed || !PageActionMap.has(win)) {
+ continue;
+ }
+ PageActionMap.get(win).reloadL10n();
+ }
+ },
+};
diff --git a/browser/components/asrouter/modules/FeatureCallout.sys.mjs b/browser/components/asrouter/modules/FeatureCallout.sys.mjs
new file mode 100644
index 0000000000..01998662f6
--- /dev/null
+++ b/browser/components/asrouter/modules/FeatureCallout.sys.mjs
@@ -0,0 +1,2100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.sys.mjs",
+ ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs",
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ PageEventManager: "resource:///modules/asrouter/PageEventManager.sys.mjs",
+});
+
+const TRANSITION_MS = 500;
+const CONTAINER_ID = "feature-callout";
+const CONTENT_BOX_ID = "multi-stage-message-root";
+const BUNDLE_SRC =
+ "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js";
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ const { Logger } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/Logger.sys.mjs"
+ );
+ return new Logger("FeatureCallout");
+});
+
+/**
+ * Feature Callout fetches messages relevant to a given source and displays them
+ * in the parent page pointing to the element they describe.
+ */
+export class FeatureCallout {
+ /**
+ * @typedef {Object} FeatureCalloutOptions
+ * @property {Window} win window in which messages will be rendered.
+ * @property {{name: String, defaultValue?: String}} [pref] optional pref used
+ * to track progress through a given feature tour. for example:
+ * {
+ * name: "browser.pdfjs.feature-tour",
+ * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }',
+ * }
+ * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional)
+ * @property {String} [location] string to pass as the page when requesting
+ * messages from ASRouter and sending telemetry.
+ * @property {String} context either "chrome" or "content". "chrome" is used
+ * when the callout is shown in the browser chrome, and "content" is used
+ * when the callout is shown in a content page like Firefox View.
+ * @property {MozBrowser} [browser] <browser> element responsible for the
+ * feature callout. for content pages, this is the browser element that the
+ * callout is being shown in. for chrome, this is the active browser.
+ * @property {Function} [listener] callback to be invoked on various callout
+ * events to keep the broker informed of the callout's state.
+ * @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets
+ */
+
+ /** @param {FeatureCalloutOptions} options */
+ constructor({
+ win,
+ pref,
+ location,
+ context,
+ browser,
+ listener,
+ theme = {},
+ } = {}) {
+ this.win = win;
+ this.doc = win.document;
+ this.browser = browser || this.win.docShell.chromeEventHandler;
+ this.config = null;
+ this.loadingConfig = false;
+ this.message = null;
+ if (pref?.name) {
+ this.pref = pref;
+ }
+ this._featureTourProgress = null;
+ this.currentScreen = null;
+ this.renderObserver = null;
+ this.savedFocus = null;
+ this.ready = false;
+ this._positionListenersRegistered = false;
+ this._panelConflictListenersRegistered = false;
+ this.AWSetup = false;
+ this.location = location;
+ this.context = context;
+ this.listener = listener;
+ this._initTheme(theme);
+
+ this._handlePrefChange = this._handlePrefChange.bind(this);
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "cfrFeaturesUserPref",
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ true
+ );
+ this.setupFeatureTourProgress();
+
+ // When the window is focused, ensure tour is synced with tours in any other
+ // instances of the parent page. This does not apply when the Callout is
+ // shown in the browser chrome.
+ if (this.context !== "chrome") {
+ this.win.addEventListener("visibilitychange", this);
+ }
+
+ this.win.addEventListener("unload", this);
+ }
+
+ setupFeatureTourProgress() {
+ if (this.featureTourProgress) {
+ return;
+ }
+ if (this.pref?.name) {
+ this._handlePrefChange(null, null, this.pref.name);
+ Services.prefs.addObserver(this.pref.name, this._handlePrefChange);
+ }
+ }
+
+ teardownFeatureTourProgress() {
+ if (this.pref?.name) {
+ Services.prefs.removeObserver(this.pref.name, this._handlePrefChange);
+ }
+ this._featureTourProgress = null;
+ }
+
+ get featureTourProgress() {
+ return this._featureTourProgress;
+ }
+
+ /**
+ * Get the page event manager and instantiate it if necessary. Only used by
+ * _attachPageEventListeners, since we don't want to do this unnecessary work
+ * if a message with page event listeners hasn't loaded. Other consumers
+ * should use `this._pageEventManager?.property` instead.
+ */
+ get _loadPageEventManager() {
+ if (!this._pageEventManager) {
+ this._pageEventManager = new lazy.PageEventManager(this.win);
+ }
+ return this._pageEventManager;
+ }
+
+ _addPositionListeners() {
+ if (!this._positionListenersRegistered) {
+ this.win.addEventListener("resize", this);
+ this._positionListenersRegistered = true;
+ }
+ }
+
+ _removePositionListeners() {
+ if (this._positionListenersRegistered) {
+ this.win.removeEventListener("resize", this);
+ this._positionListenersRegistered = false;
+ }
+ }
+
+ _addPanelConflictListeners() {
+ if (!this._panelConflictListenersRegistered) {
+ this.win.addEventListener("popupshowing", this);
+ this.win.gURLBar.controller.addQueryListener(this);
+ this._panelConflictListenersRegistered = true;
+ }
+ }
+
+ _removePanelConflictListeners() {
+ if (this._panelConflictListenersRegistered) {
+ this.win.removeEventListener("popupshowing", this);
+ this.win.gURLBar.controller.removeQueryListener(this);
+ this._panelConflictListenersRegistered = false;
+ }
+ }
+
+ /**
+ * Close the tour when the urlbar is opened in the chrome. Set up by
+ * gURLBar.controller.addQueryListener in _addPanelConflictListeners.
+ */
+ onViewOpen() {
+ this.endTour();
+ }
+
+ _handlePrefChange(subject, topic, prefName) {
+ switch (prefName) {
+ case this.pref?.name:
+ try {
+ this._featureTourProgress = JSON.parse(
+ Services.prefs.getStringPref(
+ this.pref.name,
+ this.pref.defaultValue ?? null
+ )
+ );
+ } catch (error) {
+ this._featureTourProgress = null;
+ }
+ if (topic === "nsPref:changed") {
+ this._maybeAdvanceScreens();
+ }
+ break;
+ }
+ }
+
+ _maybeAdvanceScreens() {
+ if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) {
+ return;
+ }
+
+ // If we have more than one screen, it means that we're displaying a feature
+ // tour, and transitions are handled based on the value of a tour progress
+ // pref. Otherwise, just show the feature callout. If a pref change results
+ // from an event in a Spotlight message, initialize the feature callout with
+ // the next message in the tour.
+ if (
+ this.config?.screens.length === 1 ||
+ this.currentScreen === "spotlight"
+ ) {
+ this.showFeatureCallout();
+ return;
+ }
+
+ let prefVal = this.featureTourProgress;
+ // End the tour according to the tour progress pref or if the user disabled
+ // contextual feature recommendations.
+ if (prefVal.complete || !this.cfrFeaturesUserPref) {
+ this.endTour();
+ } else if (prefVal.screen !== this.currentScreen?.id) {
+ // Pref changes only matter to us insofar as they let us advance an
+ // ongoing tour. If the tour was closed and the pref changed later, e.g.
+ // by editing the pref directly, we don't want to start up the tour again.
+ // This is more important in the chrome, which is always open.
+ if (this.context === "chrome" && !this.currentScreen) {
+ return;
+ }
+ this.ready = false;
+ this._container?.classList.toggle(
+ "hidden",
+ this._container?.localName !== "panel"
+ );
+ this._pageEventManager?.emit({
+ type: "touradvance",
+ target: this._container,
+ });
+ const onFadeOut = async () => {
+ // If the initial message was deployed from outside by ASRouter as a
+ // result of a trigger, we can't continue it through _loadConfig, since
+ // that effectively requests a message with a `featureCalloutCheck`
+ // trigger. So we need to load up the same message again, merely
+ // changing the startScreen index. Just check that the next screen and
+ // the current screen are both within the message's screens array.
+ let nextMessage = null;
+ if (
+ this.context === "chrome" &&
+ this.message?.trigger.id !== "featureCalloutCheck"
+ ) {
+ if (
+ this.config?.screens.some(s => s.id === this.currentScreen?.id) &&
+ this.config.screens.some(s => s.id === prefVal.screen)
+ ) {
+ nextMessage = this.message;
+ }
+ }
+ this._container?.remove();
+ this.renderObserver?.disconnect();
+ this._removePositionListeners();
+ this._removePanelConflictListeners();
+ this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
+ if (nextMessage) {
+ const isMessageUnblocked = await lazy.ASRouter.isUnblockedMessage(
+ nextMessage
+ );
+ if (!isMessageUnblocked) {
+ this.endTour();
+ return;
+ }
+ }
+ let updated = await this._updateConfig(nextMessage);
+ if (!updated && !this.currentScreen) {
+ this.endTour();
+ return;
+ }
+ let rendering = await this._renderCallout();
+ if (!rendering) {
+ this.endTour();
+ }
+ };
+ if (this._container?.localName === "panel") {
+ this._container.removeEventListener("popuphiding", this);
+ this._container.addEventListener("popuphidden", onFadeOut, {
+ once: true,
+ });
+ this._container.hidePopup(true);
+ } else {
+ this.win.setTimeout(onFadeOut, TRANSITION_MS);
+ }
+ }
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "focus": {
+ if (!this._container) {
+ return;
+ }
+ // If focus has fired on the feature callout window itself, or on something
+ // contained in that window, ignore it, as we can't possibly place the focus
+ // on it after the callout is closd.
+ if (
+ event.target === this._container ||
+ (Node.isInstance(event.target) &&
+ this._container.contains(event.target))
+ ) {
+ return;
+ }
+ // Save this so that if the next focus event is re-entering the popup,
+ // then we'll put the focus back here where the user left it once we exit
+ // the feature callout series.
+ if (this.doc.activeElement) {
+ let element = this.doc.activeElement;
+ this.savedFocus = {
+ element,
+ focusVisible: element.matches(":focus-visible"),
+ };
+ } else {
+ this.savedFocus = null;
+ }
+ break;
+ }
+
+ case "keypress": {
+ if (event.key !== "Escape") {
+ return;
+ }
+ if (!this._container) {
+ return;
+ }
+ let focusedElement =
+ this.context === "chrome"
+ ? Services.focus.focusedElement
+ : this.doc.activeElement;
+ // If the window has a focused element, let it handle the ESC key instead.
+ if (
+ !focusedElement ||
+ focusedElement === this.doc.body ||
+ (focusedElement === this.browser && this.theme.simulateContent) ||
+ this._container.contains(focusedElement)
+ ) {
+ this.win.AWSendEventTelemetry?.({
+ event: "DISMISS",
+ event_context: {
+ source: `KEY_${event.key}`,
+ page: this.location,
+ },
+ message_id: this.config?.id.toUpperCase(),
+ });
+ this._dismiss();
+ event.preventDefault();
+ }
+ break;
+ }
+
+ case "visibilitychange":
+ this._maybeAdvanceScreens();
+ break;
+
+ case "resize":
+ case "toggle":
+ this.win.requestAnimationFrame(() => this._positionCallout());
+ break;
+
+ case "popupshowing":
+ // If another panel is showing, close the tour.
+ if (
+ event.target !== this._container &&
+ event.target.localName === "panel" &&
+ event.target.id !== "ctrlTab-panel" &&
+ event.target.ownerGlobal === this.win
+ ) {
+ this.endTour();
+ }
+ break;
+
+ case "popuphiding":
+ if (event.target === this._container) {
+ this.endTour();
+ }
+ break;
+
+ case "unload":
+ try {
+ this.teardownFeatureTourProgress();
+ } catch (error) {}
+ break;
+
+ default:
+ }
+ }
+
+ async _addCalloutLinkElements() {
+ for (const path of [
+ "browser/newtab/onboarding.ftl",
+ "browser/spotlight.ftl",
+ "branding/brand.ftl",
+ "toolkit/branding/brandings.ftl",
+ "browser/newtab/asrouter.ftl",
+ "browser/featureCallout.ftl",
+ ]) {
+ this.win.MozXULElement.insertFTLIfNeeded(path);
+ }
+
+ const addChromeSheet = href => {
+ try {
+ this.win.windowUtils.loadSheetUsingURIString(
+ href,
+ Ci.nsIDOMWindowUtils.AUTHOR_SHEET
+ );
+ } catch (error) {
+ // the sheet was probably already loaded. I don't think there's a way to
+ // check for this via JS, but the method checks and throws if it's
+ // already loaded, so we can just treat the error as expected.
+ }
+ };
+ const addStylesheet = href => {
+ if (this.win.isChromeWindow) {
+ // for chrome, load the stylesheet using a special method to make sure
+ // it's loaded synchronously before the first paint & position.
+ return addChromeSheet(href);
+ }
+ if (this.doc.querySelector(`link[href="${href}"]`)) {
+ return null;
+ }
+ const link = this.doc.head.appendChild(this.doc.createElement("link"));
+ link.rel = "stylesheet";
+ link.href = href;
+ return null;
+ };
+ // Update styling to be compatible with about:welcome bundle
+ await addStylesheet(
+ "chrome://browser/content/aboutwelcome/aboutwelcome.css"
+ );
+ }
+
+ /**
+ * @typedef {
+ * | "topleft"
+ * | "topright"
+ * | "bottomleft"
+ * | "bottomright"
+ * | "leftcenter"
+ * | "rightcenter"
+ * | "topcenter"
+ * | "bottomcenter"
+ * } PopupAttachmentPoint
+ *
+ * @see nsMenuPopupFrame
+ *
+ * Each attachment point corresponds to an attachment point on the edge of a
+ * frame. For example, "topleft" corresponds to the frame's top left corner,
+ * and "rightcenter" corresponds to the center of the right edge of the frame.
+ */
+
+ /**
+ * @typedef {Object} PanelPosition Specifies how the callout panel should be
+ * positioned relative to the anchor element, by providing which point on
+ * the callout should be aligned with which point on the anchor element.
+ * @property {PopupAttachmentPoint} anchor_attachment
+ * @property {PopupAttachmentPoint} callout_attachment
+ * @property {Number} [offset_x] Offset in pixels to apply to the callout
+ * position in the horizontal direction.
+ * @property {Number} [offset_y] The same in the vertical direction.
+ *
+ * This is used when you want the callout to be displayed as a <panel>
+ * element. A panel is critical when the callout is displayed in the browser
+ * chrome, anchored to an element whose position on the screen is dynamic,
+ * such as a button. When the anchor moves, the panel will automatically move
+ * with it. Also, when the elements are aligned so that the callout would
+ * extend beyond the edge of the screen, the panel will automatically flip
+ * itself to the other side of the anchor element. This requires specifying
+ * both an anchor attachment point and a callout attachment point. For
+ * example, to get the callout to appear under a button, with its arrow on the
+ * right side of the callout:
+ * { anchor_attachment: "bottomcenter", callout_attachment: "topright" }
+ */
+
+ /**
+ * @typedef {
+ * | "top"
+ * | "bottom"
+ * | "end"
+ * | "start"
+ * | "top-end"
+ * | "top-start"
+ * | "top-center-arrow-end"
+ * | "top-center-arrow-start"
+ * } HTMLArrowPosition
+ *
+ * @see FeatureCallout._positionCallout()
+ * The position of the callout arrow relative to the callout container. Only
+ * used for HTML callouts, typically in content pages. If the position
+ * contains a dash, the value before the dash refers to which edge of the
+ * feature callout the arrow points from. The value after the dash describes
+ * where along that edge the arrow sits, with middle as the default.
+ */
+
+ /**
+ * @typedef {Object} PositionOverride CSS properties to override
+ * the callout's position relative to the anchor element. Although the
+ * callout is not actually a child of the anchor element, this allows
+ * absolute positioning of the callout relative to the anchor element. In
+ * other words, { top: "0px", left: "0px" } will position the callout in the
+ * top left corner of the anchor element, in the same way these properties
+ * would position a child element.
+ * @property {String} [top]
+ * @property {String} [left]
+ * @property {String} [right]
+ * @property {String} [bottom]
+ */
+
+ /**
+ * @typedef {Object} AnchorConfig
+ * @property {String} selector CSS selector for the anchor node.
+ * @property {PanelPosition} [panel_position] Used to show the callout in a
+ * XUL panel. Only works in chrome documents, like the main browser window.
+ * @property {HTMLArrowPosition} [arrow_position] Used to show the callout in
+ * an HTML div container. Mutually exclusive with panel_position.
+ * @property {PositionOverride} [absolute_position] Only used for HTML
+ * callouts, i.e. when panel_position is not specified. Allows absolute
+ * positioning of the callout relative to the anchor element.
+ * @property {Boolean} [hide_arrow] Whether to hide the arrow.
+ * @property {Boolean} [no_open_on_anchor] Whether to set the [open] style on
+ * the anchor element when the callout is shown. False to set it, true to
+ * not set it. This only works for panel callouts. Not all elements have an
+ * [open] style. Buttons do, for example. It's usually similar to :active.
+ * @property {Number} [arrow_width] The desired width of the arrow in a number
+ * of pixels. 33.94113 by default (this corresponds to 24px edges).
+ */
+
+ /**
+ * @typedef {Object} Anchor
+ * @property {String} selector
+ * @property {PanelPosition} [panel_position]
+ * @property {HTMLArrowPosition} [arrow_position]
+ * @property {PositionOverride} [absolute_position]
+ * @property {Boolean} [hide_arrow]
+ * @property {Boolean} [no_open_on_anchor]
+ * @property {Number} [arrow_width]
+ * @property {Element} element The anchor node resolved from the selector.
+ * @property {String} [panel_position_string] The panel_position joined into a
+ * string, e.g. "bottomleft topright". Passed to XULPopupElement::openPopup.
+ */
+
+ /**
+ * Return the first visible anchor element for the current screen. Screens can
+ * specify multiple anchors in an array, and the first one that is visible
+ * will be used. If none are visible, return null.
+ * @returns {Anchor|null}
+ */
+ _getAnchor() {
+ /** @type {AnchorConfig[]} */
+ const anchors = Array.isArray(this.currentScreen?.anchors)
+ ? this.currentScreen.anchors
+ : [];
+ for (let anchor of anchors) {
+ if (!anchor || typeof anchor !== "object") {
+ lazy.log.debug(
+ `In ${this.location}: Invalid anchor config. Expected an object, got: ${anchor}`
+ );
+ continue;
+ }
+ const { selector, arrow_position, panel_position } = anchor;
+ let panel_position_string;
+ if (panel_position) {
+ panel_position_string = this._getPanelPositionString(panel_position);
+ // if the positionString doesn't match the format we expect, don't
+ // render the callout.
+ if (!panel_position_string && !arrow_position) {
+ lazy.log.debug(
+ `In ${
+ this.location
+ }: Invalid panel_position config. Expected an object with anchor_attachment and callout_attachment properties, got: ${JSON.stringify(
+ panel_position
+ )}`
+ );
+ continue;
+ }
+ }
+ if (
+ arrow_position &&
+ !this._HTMLArrowPositions.includes(arrow_position)
+ ) {
+ lazy.log.debug(
+ `In ${
+ this.location
+ }: Invalid arrow_position config. Expected one of ${JSON.stringify(
+ this._HTMLArrowPositions
+ )}, got: ${arrow_position}`
+ );
+ continue;
+ }
+ const element = selector && this.doc.querySelector(selector);
+ if (!element) {
+ continue; // Element doesn't exist at all.
+ }
+ const isVisible = () => {
+ if (
+ this.context === "chrome" &&
+ typeof this.win.isElementVisible === "function"
+ ) {
+ // In chrome windows, we can use the isElementVisible function to
+ // check that the element has non-zero width and height. If it was
+ // hidden, it would most likely have zero width and/or height.
+ if (!this.win.isElementVisible(element)) {
+ return false;
+ }
+ }
+ // CSS rules like visibility: hidden or display: none. These result in
+ // element being invisible and unclickable.
+ const style = this.win.getComputedStyle(element);
+ return style?.visibility === "visible" && style?.display !== "none";
+ };
+ if (!isVisible()) {
+ continue;
+ }
+ if (
+ this.context === "chrome" &&
+ element.id &&
+ anchor.selector.includes(`#${element.id}`)
+ ) {
+ let widget = lazy.CustomizableUI.getWidget(element.id);
+ if (
+ widget &&
+ (this.win.CustomizationHandler.isCustomizing() ||
+ widget.areaType?.includes("panel"))
+ ) {
+ // The element is a customizable widget (a toolbar item, e.g. the
+ // reload button or the downloads button). Widgets can be in various
+ // areas, like the overflow panel or the customization palette.
+ // Widgets in the palette are present in the chrome's DOM during
+ // customization, but can't be used.
+ continue;
+ }
+ }
+ return { ...anchor, panel_position_string, element };
+ }
+ return null;
+ }
+
+ /** @see PopupAttachmentPoint */
+ _popupAttachmentPoints = [
+ "topleft",
+ "topright",
+ "bottomleft",
+ "bottomright",
+ "leftcenter",
+ "rightcenter",
+ "topcenter",
+ "bottomcenter",
+ ];
+
+ /**
+ * Return a string representing the position of the panel relative to the
+ * anchor element. Passed to XULPopupElement::openPopup. The string is of the
+ * form "anchor_attachment callout_attachment".
+ *
+ * @param {PanelPosition} panelPosition
+ * @returns {String|null} A string like "bottomcenter topright", or null if
+ * the panelPosition object is invalid.
+ */
+ _getPanelPositionString(panelPosition) {
+ const { anchor_attachment, callout_attachment } = panelPosition;
+ if (
+ !this._popupAttachmentPoints.includes(anchor_attachment) ||
+ !this._popupAttachmentPoints.includes(callout_attachment)
+ ) {
+ return null;
+ }
+ let positionString = `${anchor_attachment} ${callout_attachment}`;
+ return positionString;
+ }
+
+ /**
+ * Set/override methods on a panel element. Can be used to override methods on
+ * the custom element class, or to add additional methods.
+ *
+ * @param {MozPanel} panel The panel to set methods for
+ */
+ _setPanelMethods(panel) {
+ // This method is optionally called by MozPanel::_setSideAttribute, though
+ // it does not exist on the class.
+ panel.setArrowPosition = function setArrowPosition(event) {
+ if (!this.hasAttribute("show-arrow")) {
+ return;
+ }
+ let { alignmentPosition, alignmentOffset, popupAlignment } = event;
+ let positionParts = alignmentPosition?.match(
+ /^(before|after|start|end)_(before|after|start|end)$/
+ );
+ if (!positionParts) {
+ return;
+ }
+ // Hide the arrow if the `flip` behavior has caused the panel to
+ // offset relative to its anchor, since the arrow would no longer
+ // point at the true anchor. This differs from an arrow that is
+ // intentionally hidden by the user in message.
+ if (this.getAttribute("hide-arrow") !== "permanent") {
+ if (alignmentOffset) {
+ this.setAttribute("hide-arrow", "temporary");
+ } else {
+ this.removeAttribute("hide-arrow");
+ }
+ }
+ let arrowPosition = "top";
+ switch (positionParts[1]) {
+ case "start":
+ case "end": {
+ // Inline arrow, i.e. arrow is on one of the left/right edges.
+ let isRTL =
+ this.ownerGlobal.getComputedStyle(this).direction === "rtl";
+ let isRight = isRTL ^ (positionParts[1] === "start");
+ let side = isRight ? "end" : "start";
+ arrowPosition = `inline-${side}`;
+ if (popupAlignment?.includes("center")) {
+ arrowPosition = `inline-${side}`;
+ } else if (positionParts[2] === "before") {
+ arrowPosition = `inline-${side}-top`;
+ } else if (positionParts[2] === "after") {
+ arrowPosition = `inline-${side}-bottom`;
+ }
+ break;
+ }
+ case "before":
+ case "after": {
+ // Block arrow, i.e. arrow is on one of the top/bottom edges.
+ let side = positionParts[1] === "before" ? "bottom" : "top";
+ arrowPosition = side;
+ if (popupAlignment?.includes("center")) {
+ arrowPosition = side;
+ } else if (positionParts[2] === "end") {
+ arrowPosition = `${side}-end`;
+ } else if (positionParts[2] === "start") {
+ arrowPosition = `${side}-start`;
+ }
+ break;
+ }
+ }
+ this.setAttribute("arrow-position", arrowPosition);
+ };
+ }
+
+ _createContainer() {
+ const anchor = this._getAnchor();
+ // Don't render the callout if none of the anchors is visible.
+ if (!anchor) {
+ return false;
+ }
+
+ const { autohide, padding } = this.currentScreen.content;
+ const {
+ panel_position_string,
+ hide_arrow,
+ no_open_on_anchor,
+ arrow_width,
+ } = anchor;
+ const needsPanel = "MozXULElement" in this.win && !!panel_position_string;
+
+ if (this._container) {
+ if (needsPanel ^ (this._container?.localName === "panel")) {
+ this._container.remove();
+ }
+ }
+
+ if (!this._container?.parentElement) {
+ if (needsPanel) {
+ let fragment = this.win.MozXULElement.parseXULToFragment(`<panel
+ class="panel-no-padding"
+ orient="vertical"
+ ignorekeys="true"
+ noautofocus="true"
+ flip="slide"
+ type="arrow"
+ position="${panel_position_string}"
+ ${hide_arrow ? "" : 'show-arrow=""'}
+ ${autohide ? "" : 'noautohide="true"'}
+ ${no_open_on_anchor ? 'no-open-on-anchor=""' : ""}
+ />`);
+ this._container = fragment.firstElementChild;
+ this._setPanelMethods(this._container);
+ } else {
+ this._container = this.doc.createElement("div");
+ this._container?.classList.add("hidden");
+ }
+ this._container.classList.add("featureCallout", "callout-arrow");
+ if (hide_arrow) {
+ this._container.setAttribute("hide-arrow", "permanent");
+ } else {
+ this._container.removeAttribute("hide-arrow");
+ }
+ this._container.id = CONTAINER_ID;
+ this._container.setAttribute(
+ "aria-describedby",
+ `#${CONTAINER_ID} .welcome-text`
+ );
+ this._container.tabIndex = 0;
+ if (arrow_width) {
+ this._container.style.setProperty("--arrow-width", `${arrow_width}px`);
+ } else {
+ this._container.style.removeProperty("--arrow-width");
+ }
+ if (padding) {
+ this._container.style.setProperty("--callout-padding", `${padding}px`);
+ } else {
+ this._container.style.removeProperty("--callout-padding");
+ }
+ let contentBox = this.doc.createElement("div");
+ contentBox.id = CONTENT_BOX_ID;
+ contentBox.classList.add("onboardingContainer");
+ // This value is reported as the "page" in about:welcome telemetry
+ contentBox.dataset.page = this.location;
+ this._applyTheme();
+ if (needsPanel && this.win.isChromeWindow) {
+ this.doc.getElementById("mainPopupSet").appendChild(this._container);
+ } else {
+ this.doc.body.prepend(this._container);
+ }
+ const makeArrow = classPrefix => {
+ const arrowRotationBox = this.doc.createElement("div");
+ arrowRotationBox.classList.add("arrow-box", `${classPrefix}-arrow-box`);
+ const arrow = this.doc.createElement("div");
+ arrow.classList.add("arrow", `${classPrefix}-arrow`);
+ arrowRotationBox.appendChild(arrow);
+ return arrowRotationBox;
+ };
+ this._container.appendChild(makeArrow("shadow"));
+ this._container.appendChild(contentBox);
+ this._container.appendChild(makeArrow("background"));
+ }
+ return this._container;
+ }
+
+ /** @see HTMLArrowPosition */
+ _HTMLArrowPositions = [
+ "top",
+ "bottom",
+ "end",
+ "start",
+ "top-end",
+ "top-start",
+ "top-center-arrow-end",
+ "top-center-arrow-start",
+ ];
+
+ /**
+ * Set callout's position relative to parent element
+ */
+ _positionCallout() {
+ const container = this._container;
+ const anchor = this._getAnchor();
+ if (!container || !anchor) {
+ this.endTour();
+ return;
+ }
+ const parentEl = anchor.element;
+ const { doc } = this;
+ const arrowPosition = anchor.arrow_position || "top";
+ const arrowWidth = anchor.arrow_width || 33.94113;
+ const arrowHeight = arrowWidth / 2;
+ const overlapAmount = 5;
+ let overlap = overlapAmount - arrowHeight;
+ // Is the document layout right to left?
+ const RTL = this.doc.dir === "rtl";
+ const customPosition = anchor.absolute_position;
+
+ const getOffset = el => {
+ const rect = el.getBoundingClientRect();
+ return {
+ left: rect.left + this.win.scrollX,
+ right: rect.right + this.win.scrollX,
+ top: rect.top + this.win.scrollY,
+ bottom: rect.bottom + this.win.scrollY,
+ };
+ };
+
+ const centerVertically = () => {
+ let topOffset =
+ (container.getBoundingClientRect().height -
+ parentEl.getBoundingClientRect().height) /
+ 2;
+ container.style.top = `${getOffset(parentEl).top - topOffset}px`;
+ };
+
+ /**
+ * Horizontally align a top/bottom-positioned callout according to the
+ * passed position.
+ * @param {String} position one of...
+ * - "center": for use with top/bottom. arrow is in the center, and the
+ * center of the callout aligns with the parent center.
+ * - "center-arrow-start": for use with center-arrow-top-start. arrow is
+ * on the start (left) side of the callout, and the callout is aligned
+ * so that the arrow points to the center of the parent element.
+ * - "center-arrow-end": for use with center-arrow-top-end. arrow is on
+ * the end, and the arrow points to the center of the parent.
+ * - "start": currently unused. align the callout's starting edge with the
+ * parent's starting edge.
+ * - "end": currently unused. same as start but for the ending edge.
+ */
+ const alignHorizontally = position => {
+ switch (position) {
+ case "center": {
+ const sideOffset =
+ (parentEl.getBoundingClientRect().width -
+ container.getBoundingClientRect().width) /
+ 2;
+ const containerSide = RTL
+ ? doc.documentElement.clientWidth -
+ getOffset(parentEl).right +
+ sideOffset
+ : getOffset(parentEl).left + sideOffset;
+ container.style[RTL ? "right" : "left"] = `${Math.max(
+ containerSide,
+ 0
+ )}px`;
+ break;
+ }
+ case "end":
+ case "start": {
+ const containerSide =
+ RTL ^ (position === "end")
+ ? parentEl.getBoundingClientRect().left +
+ parentEl.getBoundingClientRect().width -
+ container.getBoundingClientRect().width
+ : parentEl.getBoundingClientRect().left;
+ container.style.left = `${Math.max(containerSide, 0)}px`;
+ break;
+ }
+ case "center-arrow-end":
+ case "center-arrow-start": {
+ const parentRect = parentEl.getBoundingClientRect();
+ const containerWidth = container.getBoundingClientRect().width;
+ const containerSide =
+ RTL ^ position.endsWith("end")
+ ? parentRect.left +
+ parentRect.width / 2 +
+ 12 +
+ arrowWidth / 2 -
+ containerWidth
+ : parentRect.left + parentRect.width / 2 - 12 - arrowWidth / 2;
+ const maxContainerSide =
+ doc.documentElement.clientWidth - containerWidth;
+ container.style.left = `${Math.min(
+ maxContainerSide,
+ Math.max(containerSide, 0)
+ )}px`;
+ }
+ }
+ };
+
+ // Remember not to use HTML-only properties/methods like offsetHeight. Try
+ // to use getBoundingClientRect() instead, which is available on XUL
+ // elements. This is necessary to support feature callout in chrome, which
+ // is still largely XUL-based.
+ const positioners = {
+ // availableSpace should be the space between the edge of the page in the
+ // assumed direction and the edge of the parent (with the callout being
+ // intended to fit between those two edges) while needed space should be
+ // the space necessary to fit the callout container.
+ top: {
+ availableSpace() {
+ return (
+ doc.documentElement.clientHeight -
+ getOffset(parentEl).top -
+ parentEl.getBoundingClientRect().height
+ );
+ },
+ neededSpace: container.getBoundingClientRect().height - overlap,
+ position() {
+ // Point to an element above the callout
+ let containerTop =
+ getOffset(parentEl).top +
+ parentEl.getBoundingClientRect().height -
+ overlap;
+ container.style.top = `${Math.max(0, containerTop)}px`;
+ alignHorizontally("center");
+ },
+ },
+ bottom: {
+ availableSpace() {
+ return getOffset(parentEl).top;
+ },
+ neededSpace: container.getBoundingClientRect().height - overlap,
+ position() {
+ // Point to an element below the callout
+ let containerTop =
+ getOffset(parentEl).top -
+ container.getBoundingClientRect().height +
+ overlap;
+ container.style.top = `${Math.max(0, containerTop)}px`;
+ alignHorizontally("center");
+ },
+ },
+ right: {
+ availableSpace() {
+ return getOffset(parentEl).left;
+ },
+ neededSpace: container.getBoundingClientRect().width - overlap,
+ position() {
+ // Point to an element to the right of the callout
+ let containerLeft =
+ getOffset(parentEl).left -
+ container.getBoundingClientRect().width +
+ overlap;
+ container.style.left = `${Math.max(0, containerLeft)}px`;
+ if (
+ container.getBoundingClientRect().height <=
+ parentEl.getBoundingClientRect().height
+ ) {
+ container.style.top = `${getOffset(parentEl).top}px`;
+ } else {
+ centerVertically();
+ }
+ },
+ },
+ left: {
+ availableSpace() {
+ return doc.documentElement.clientWidth - getOffset(parentEl).right;
+ },
+ neededSpace: container.getBoundingClientRect().width - overlap,
+ position() {
+ // Point to an element to the left of the callout
+ let containerLeft =
+ getOffset(parentEl).left +
+ parentEl.getBoundingClientRect().width -
+ overlap;
+ container.style.left = `${Math.max(0, containerLeft)}px`;
+ if (
+ container.getBoundingClientRect().height <=
+ parentEl.getBoundingClientRect().height
+ ) {
+ container.style.top = `${getOffset(parentEl).top}px`;
+ } else {
+ centerVertically();
+ }
+ },
+ },
+ "top-start": {
+ availableSpace() {
+ return (
+ doc.documentElement.clientHeight -
+ getOffset(parentEl).top -
+ parentEl.getBoundingClientRect().height
+ );
+ },
+ neededSpace: container.getBoundingClientRect().height - overlap,
+ position() {
+ // Point to an element above and at the start of the callout
+ let containerTop =
+ getOffset(parentEl).top +
+ parentEl.getBoundingClientRect().height -
+ overlap;
+ container.style.top = `${Math.max(0, containerTop)}px`;
+ alignHorizontally("start");
+ },
+ },
+ "top-end": {
+ availableSpace() {
+ return (
+ doc.documentElement.clientHeight -
+ getOffset(parentEl).top -
+ parentEl.getBoundingClientRect().height
+ );
+ },
+ neededSpace: container.getBoundingClientRect().height - overlap,
+ position() {
+ // Point to an element above and at the end of the callout
+ let containerTop =
+ getOffset(parentEl).top +
+ parentEl.getBoundingClientRect().height -
+ overlap;
+ container.style.top = `${Math.max(0, containerTop)}px`;
+ alignHorizontally("end");
+ },
+ },
+ "top-center-arrow-start": {
+ availableSpace() {
+ return (
+ doc.documentElement.clientHeight -
+ getOffset(parentEl).top -
+ parentEl.getBoundingClientRect().height
+ );
+ },
+ neededSpace: container.getBoundingClientRect().height - overlap,
+ position() {
+ // Point to an element above and at the start of the callout
+ let containerTop =
+ getOffset(parentEl).top +
+ parentEl.getBoundingClientRect().height -
+ overlap;
+ container.style.top = `${Math.max(0, containerTop)}px`;
+ alignHorizontally("center-arrow-start");
+ },
+ },
+ "top-center-arrow-end": {
+ availableSpace() {
+ return (
+ doc.documentElement.clientHeight -
+ getOffset(parentEl).top -
+ parentEl.getBoundingClientRect().height
+ );
+ },
+ neededSpace: container.getBoundingClientRect().height - overlap,
+ position() {
+ // Point to an element above and at the end of the callout
+ let containerTop =
+ getOffset(parentEl).top +
+ parentEl.getBoundingClientRect().height -
+ overlap;
+ container.style.top = `${Math.max(0, containerTop)}px`;
+ alignHorizontally("center-arrow-end");
+ },
+ },
+ };
+
+ const clearPosition = () => {
+ Object.keys(positioners).forEach(position => {
+ container.style[position] = "unset";
+ });
+ container.removeAttribute("arrow-position");
+ };
+
+ const setArrowPosition = position => {
+ let val;
+ switch (position) {
+ case "bottom":
+ val = "bottom";
+ break;
+ case "left":
+ val = "inline-start";
+ break;
+ case "right":
+ val = "inline-end";
+ break;
+ case "top-start":
+ case "top-center-arrow-start":
+ val = RTL ? "top-end" : "top-start";
+ break;
+ case "top-end":
+ case "top-center-arrow-end":
+ val = RTL ? "top-start" : "top-end";
+ break;
+ case "top":
+ default:
+ val = "top";
+ break;
+ }
+
+ container.setAttribute("arrow-position", val);
+ };
+
+ const addValueToPixelValue = (value, pixelValue) => {
+ return `${parseFloat(pixelValue) + value}px`;
+ };
+
+ const subtractPixelValueFromValue = (pixelValue, value) => {
+ return `${value - parseFloat(pixelValue)}px`;
+ };
+
+ const overridePosition = () => {
+ // We override _every_ positioner here, because we want to manually set
+ // all container.style.positions in every positioner's "position" function
+ // regardless of the actual arrow position
+
+ // Note: We override the position functions with new functions here, but
+ // they don't actually get executed until the respective position
+ // functions are called and this function is not executed unless the
+ // message has a custom position property.
+
+ // We're positioning relative to a parent element's bounds, if that parent
+ // element exists.
+
+ for (const position in positioners) {
+ if (!Object.prototype.hasOwnProperty.call(positioners, position)) {
+ continue;
+ }
+
+ positioners[position].position = () => {
+ if (customPosition.top) {
+ container.style.top = addValueToPixelValue(
+ parentEl.getBoundingClientRect().top,
+ customPosition.top
+ );
+ }
+
+ if (customPosition.left) {
+ const leftPosition = addValueToPixelValue(
+ parentEl.getBoundingClientRect().left,
+ customPosition.left
+ );
+
+ if (RTL) {
+ container.style.right = leftPosition;
+ } else {
+ container.style.left = leftPosition;
+ }
+ }
+
+ if (customPosition.right) {
+ const rightPosition = subtractPixelValueFromValue(
+ customPosition.right,
+ parentEl.getBoundingClientRect().right -
+ container.getBoundingClientRect().width
+ );
+
+ if (RTL) {
+ container.style.right = rightPosition;
+ } else {
+ container.style.left = rightPosition;
+ }
+ }
+
+ if (customPosition.bottom) {
+ container.style.top = subtractPixelValueFromValue(
+ customPosition.bottom,
+ parentEl.getBoundingClientRect().bottom -
+ container.getBoundingClientRect().height
+ );
+ }
+ };
+ }
+ };
+
+ const calloutFits = position => {
+ // Does callout element fit in this position relative
+ // to the parent element without going off screen?
+
+ // Only consider which edge of the callout the arrow points from,
+ // not the alignment of the arrow along the edge of the callout
+ let [edgePosition] = position.split("-");
+ return (
+ positioners[edgePosition].availableSpace() >
+ positioners[edgePosition].neededSpace
+ );
+ };
+
+ const choosePosition = () => {
+ let position = arrowPosition;
+ if (!this._HTMLArrowPositions.includes(position)) {
+ // Configured arrow position is not valid
+ position = null;
+ }
+ if (["start", "end"].includes(position)) {
+ // position here is referencing the direction that the callout container
+ // is pointing to, and therefore should be the _opposite_ side of the
+ // arrow eg. if arrow is at the "end" in LTR layouts, the container is
+ // pointing at an element to the right of itself, while in RTL layouts
+ // it is pointing to the left of itself
+ position = RTL ^ (position === "start") ? "left" : "right";
+ }
+ // If we're overriding the position, we don't need to sort for available space
+ if (customPosition || (position && calloutFits(position))) {
+ return position;
+ }
+ let sortedPositions = ["top", "bottom", "left", "right"]
+ .filter(p => p !== position)
+ .filter(calloutFits)
+ .sort((a, b) => {
+ return (
+ positioners[b].availableSpace() - positioners[b].neededSpace >
+ positioners[a].availableSpace() - positioners[a].neededSpace
+ );
+ });
+ // If the callout doesn't fit in any position, use the configured one.
+ // The callout will be adjusted to overlap the parent element so that
+ // the former doesn't go off screen.
+ return sortedPositions[0] || position;
+ };
+
+ clearPosition(container);
+
+ if (customPosition) {
+ overridePosition();
+ }
+
+ let finalPosition = choosePosition();
+ if (finalPosition) {
+ positioners[finalPosition].position();
+ setArrowPosition(finalPosition);
+ }
+
+ container.classList.remove("hidden");
+ }
+
+ /** Expose top level functions expected by the aboutwelcome bundle. */
+ _setupWindowFunctions() {
+ if (this.AWSetup) {
+ return;
+ }
+
+ const handleActorMessage =
+ lazy.AboutWelcomeParent.prototype.onContentMessage.bind({});
+ const getActionHandler = name => data =>
+ handleActorMessage(`AWPage:${name}`, data, this.doc);
+
+ const telemetryMessageHandler = getActionHandler("TELEMETRY_EVENT");
+ const AWSendEventTelemetry = data => {
+ if (this.config?.metrics !== "block") {
+ return telemetryMessageHandler(data);
+ }
+ return null;
+ };
+ this._windowFuncs = {
+ AWGetFeatureConfig: () => this.config,
+ AWGetSelectedTheme: getActionHandler("GET_SELECTED_THEME"),
+ // Do not send telemetry if message config sets metrics as 'block'.
+ AWSendEventTelemetry,
+ AWSendToDeviceEmailsSupported: getActionHandler(
+ "SEND_TO_DEVICE_EMAILS_SUPPORTED"
+ ),
+ AWSendToParent: (name, data) => getActionHandler(name)(data),
+ AWFinish: () => this.endTour(),
+ AWEvaluateScreenTargeting: getActionHandler("EVALUATE_SCREEN_TARGETING"),
+ };
+ for (const [name, func] of Object.entries(this._windowFuncs)) {
+ this.win[name] = func;
+ }
+
+ this.AWSetup = true;
+ }
+
+ /** Clean up the functions defined above. */
+ _clearWindowFunctions() {
+ if (this.AWSetup) {
+ this.AWSetup = false;
+
+ for (const name of Object.keys(this._windowFuncs)) {
+ delete this.win[name];
+ }
+ }
+ }
+
+ /**
+ * Emit an event to the broker, if one is present.
+ * @param {String} name
+ * @param {any} data
+ */
+ _emitEvent(name, data) {
+ this.listener?.(this.win, name, data);
+ }
+
+ endTour(skipFadeOut = false) {
+ // We don't want focus events that happen during teardown to affect
+ // this.savedFocus
+ this.win.removeEventListener("focus", this, {
+ capture: true,
+ passive: true,
+ });
+ this.win.removeEventListener("keypress", this, { capture: true });
+ this._pageEventManager?.emit({
+ type: "tourend",
+ target: this._container,
+ });
+ this._container?.removeEventListener("popuphiding", this);
+ this._pageEventManager?.clear();
+
+ // Delete almost everything to get this ready to show a different message.
+ this.teardownFeatureTourProgress();
+ this.pref = null;
+ this.ready = false;
+ this.message = null;
+ this.content = null;
+ this.currentScreen = null;
+ // wait for fade out transition
+ this._container?.classList.toggle(
+ "hidden",
+ this._container?.localName !== "panel"
+ );
+ this._clearWindowFunctions();
+ const onFadeOut = () => {
+ this._container?.remove();
+ this.renderObserver?.disconnect();
+ this._removePositionListeners();
+ this._removePanelConflictListeners();
+ this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
+ // Put the focus back to the last place the user focused outside of the
+ // featureCallout windows.
+ if (this.savedFocus) {
+ this.savedFocus.element.focus({
+ focusVisible: this.savedFocus.focusVisible,
+ });
+ }
+ this.savedFocus = null;
+ this._emitEvent("end");
+ };
+ if (this._container?.localName === "panel") {
+ this._container.addEventListener("popuphidden", onFadeOut, {
+ once: true,
+ });
+ this._container.hidePopup(!skipFadeOut);
+ } else if (this._container) {
+ this.win.setTimeout(onFadeOut, skipFadeOut ? 0 : TRANSITION_MS);
+ } else {
+ onFadeOut();
+ }
+ }
+
+ _dismiss() {
+ let action = this.currentScreen?.content.dismiss_button?.action;
+ if (action?.type) {
+ this.win.AWSendToParent("SPECIAL_ACTION", action);
+ if (!action.dismiss) {
+ return;
+ }
+ }
+ this.endTour();
+ }
+
+ async _addScriptsAndRender() {
+ const reactSrc = "resource://activity-stream/vendor/react.js";
+ const domSrc = "resource://activity-stream/vendor/react-dom.js";
+ // Add React script
+ const getReactReady = () => {
+ return new Promise(resolve => {
+ let reactScript = this.doc.createElement("script");
+ reactScript.src = reactSrc;
+ this.doc.head.appendChild(reactScript);
+ reactScript.addEventListener("load", resolve);
+ });
+ };
+ // Add ReactDom script
+ const getDomReady = () => {
+ return new Promise(resolve => {
+ let domScript = this.doc.createElement("script");
+ domScript.src = domSrc;
+ this.doc.head.appendChild(domScript);
+ domScript.addEventListener("load", resolve);
+ });
+ };
+ // Load React, then React Dom
+ if (!this.doc.querySelector(`[src="${reactSrc}"]`)) {
+ await getReactReady();
+ }
+ if (!this.doc.querySelector(`[src="${domSrc}"]`)) {
+ await getDomReady();
+ }
+ // Load the bundle to render the content as configured.
+ this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
+ let bundleScript = this.doc.createElement("script");
+ bundleScript.src = BUNDLE_SRC;
+ this.doc.head.appendChild(bundleScript);
+ }
+
+ _observeRender(container) {
+ this.renderObserver?.observe(container, { childList: true });
+ }
+
+ /**
+ * Update the internal config with a new message. If a message is not
+ * provided, try requesting one from ASRouter. The message content is stored
+ * in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome
+ * bundle will use that function to get the content when it executes.
+ * @param {Object} [message] ASRouter message. Omit to request a new one.
+ * @returns {Promise<boolean>} true if a message is loaded, false if not.
+ */
+ async _updateConfig(message) {
+ if (this.loadingConfig) {
+ return false;
+ }
+
+ this.message = message || (await this._loadConfig());
+
+ switch (this.message.template) {
+ case "feature_callout":
+ break;
+ case "spotlight":
+ // Special handling for spotlight messages, which can be configured as a
+ // kind of introduction to a feature tour.
+ this.currentScreen = "spotlight";
+ // fall through
+ default:
+ return false;
+ }
+
+ this.config = this.message.content;
+
+ // Set the default start screen.
+ let newScreen = this.config?.screens?.[this.config?.startScreen || 0];
+ // If we have a feature tour in progress, try to set the start screen to
+ // whichever screen is configured in the feature tour pref.
+ if (
+ this.config.screens &&
+ this.config?.tour_pref_name &&
+ this.config.tour_pref_name === this.pref?.name &&
+ this.featureTourProgress
+ ) {
+ const newIndex = this.config.screens.findIndex(
+ screen => screen.id === this.featureTourProgress.screen
+ );
+ if (newIndex !== -1) {
+ newScreen = this.config.screens[newIndex];
+ if (newScreen?.id !== this.currentScreen?.id) {
+ // This is how we tell the bundle to render the correct screen.
+ this.config.startScreen = newIndex;
+ }
+ }
+ }
+ if (newScreen?.id === this.currentScreen?.id) {
+ return false;
+ }
+
+ this.currentScreen = newScreen;
+ return true;
+ }
+
+ /**
+ * Request a message from ASRouter, targeting the `browser` and `page` values
+ * passed to the constructor.
+ * @returns {Promise<Object>} the requested message.
+ */
+ async _loadConfig() {
+ this.loadingConfig = true;
+ await lazy.ASRouter.waitForInitialized;
+ let result = await lazy.ASRouter.sendTriggerMessage({
+ browser: this.browser,
+ // triggerId and triggerContext
+ id: "featureCalloutCheck",
+ context: { source: this.location },
+ });
+ this.loadingConfig = false;
+ return result.message;
+ }
+
+ /**
+ * Try to render the callout in the current document.
+ * @returns {Promise<Boolean>} whether the callout was rendered.
+ */
+ async _renderCallout() {
+ this._setupWindowFunctions();
+ await this._addCalloutLinkElements();
+ let container = this._createContainer();
+ if (container) {
+ // This results in rendering the Feature Callout
+ await this._addScriptsAndRender();
+ this._observeRender(container.querySelector(`#${CONTENT_BOX_ID}`));
+ if (container.localName === "div") {
+ this._addPositionListeners();
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * For each member of the screen's page_event_listeners array, add a listener.
+ * @param {Array<PageEventListenerConfig>} listeners
+ *
+ * @typedef {Object} PageEventListenerConfig
+ * @property {PageEventListenerParams} params Event listener parameters
+ * @property {PageEventListenerAction} action Sent when the event fires
+ *
+ * @typedef {Object} PageEventListenerParams See PageEventManager.sys.mjs
+ * @property {String} type Event type string e.g. `click`
+ * @property {String} [selectors] Target selector, e.g. `tag.class, #id[attr]`
+ * @property {PageEventListenerOptions} [options] addEventListener options
+ *
+ * @typedef {Object} PageEventListenerOptions
+ * @property {Boolean} [capture] Use event capturing phase?
+ * @property {Boolean} [once] Remove listener after first event?
+ * @property {Boolean} [preventDefault] Prevent default action?
+ * @property {Number} [interval] Used only for `timeout` and `interval` event
+ * types. These don't set up real event listeners, but instead invoke the
+ * action on a timer.
+ *
+ * @typedef {Object} PageEventListenerAction Action sent to AboutWelcomeParent
+ * @property {String} [type] Action type, e.g. `OPEN_URL`
+ * @property {Object} [data] Extra data, properties depend on action type
+ * @property {Boolean} [dismiss] Dismiss screen after performing action?
+ * @property {Boolean} [reposition] Reposition screen after performing action?
+ */
+ _attachPageEventListeners(listeners) {
+ listeners?.forEach(({ params, action }) =>
+ this._loadPageEventManager[params.options?.once ? "once" : "on"](
+ params,
+ event => {
+ this._handlePageEventAction(action, event);
+ if (params.options?.preventDefault) {
+ event.preventDefault?.();
+ }
+ }
+ )
+ );
+ }
+
+ /**
+ * Perform an action in response to a page event.
+ * @param {PageEventListenerAction} action
+ * @param {Event} event Triggering event
+ */
+ _handlePageEventAction(action, event) {
+ const page = this.location;
+ const message_id = this.config?.id.toUpperCase();
+ const source =
+ typeof event.target === "string"
+ ? event.target
+ : this._getUniqueElementIdentifier(event.target);
+ if (action.type) {
+ this.win.AWSendEventTelemetry?.({
+ event: "PAGE_EVENT",
+ event_context: {
+ action: action.type,
+ reason: event.type?.toUpperCase(),
+ source,
+ page,
+ },
+ message_id,
+ });
+ this.win.AWSendToParent("SPECIAL_ACTION", action);
+ }
+ if (action.dismiss) {
+ this.win.AWSendEventTelemetry?.({
+ event: "DISMISS",
+ event_context: { source: `PAGE_EVENT:${source}`, page },
+ message_id,
+ });
+ this._dismiss();
+ }
+ if (action.reposition) {
+ this.win.requestAnimationFrame(() => this._positionCallout());
+ }
+ }
+
+ /**
+ * For a given element, calculate a unique string that identifies it.
+ * @param {Element} target Element to calculate the selector for
+ * @returns {String} Computed event target selector, e.g. `button#next`
+ */
+ _getUniqueElementIdentifier(target) {
+ let source;
+ if (Element.isInstance(target)) {
+ source = target.localName;
+ if (target.className) {
+ source += `.${[...target.classList].join(".")}`;
+ }
+ if (target.id) {
+ source += `#${target.id}`;
+ }
+ if (target.attributes.length) {
+ source += `${[...target.attributes]
+ .filter(attr => ["is", "role", "open"].includes(attr.name))
+ .map(attr => `[${attr.name}="${attr.value}"]`)
+ .join("")}`;
+ }
+ if (this.doc.querySelectorAll(source).length > 1) {
+ let uniqueAncestor = target.closest(`[id]:not(:scope, :root, body)`);
+ if (uniqueAncestor) {
+ source = `${this._getUniqueElementIdentifier(
+ uniqueAncestor
+ )} > ${source}`;
+ }
+ }
+ }
+ return source;
+ }
+
+ /**
+ * Get the element that should be initially focused. Prioritize the primary
+ * button, then the secondary button, then any additional button, excluding
+ * pseudo-links and the dismiss button. If no button is found, focus the first
+ * input element. If no affirmative action is found, focus the first button,
+ * which is probably the dismiss button. If no button is found, focus the
+ * container itself.
+ * @returns {Element|null} The element to focus when the callout is shown.
+ */
+ getInitialFocus() {
+ if (!this._container) {
+ return null;
+ }
+ return (
+ this._container.querySelector(
+ ".primary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)"
+ ) ||
+ this._container.querySelector(
+ ".secondary:not(:disabled, [hidden], .text-link, .cta-link, .split-button)"
+ ) ||
+ this._container.querySelector(
+ "button:not(:disabled, [hidden], .text-link, .cta-link, .dismiss-button, .split-button)"
+ ) ||
+ this._container.querySelector("input:not(:disabled, [hidden])") ||
+ this._container.querySelector(
+ "button:not(:disabled, [hidden], .text-link, .cta-link)"
+ ) ||
+ this._container
+ );
+ }
+
+ /**
+ * Show a feature callout message, either by requesting one from ASRouter or
+ * by showing a message passed as an argument.
+ * @param {Object} [message] optional message to show instead of requesting one
+ * @returns {Promise<Boolean>} true if a message was shown
+ */
+ async showFeatureCallout(message) {
+ let updated = await this._updateConfig(message);
+
+ if (!updated || !this.config?.screens?.length) {
+ return !!this.currentScreen;
+ }
+
+ if (!this.renderObserver) {
+ this.renderObserver = new this.win.MutationObserver(() => {
+ // Check if the Feature Callout screen has loaded for the first time
+ if (!this.ready && this._container.querySelector(".screen")) {
+ const onRender = () => {
+ this.ready = true;
+ this._pageEventManager?.clear();
+ this._attachPageEventListeners(
+ this.currentScreen?.content?.page_event_listeners
+ );
+ this.getInitialFocus()?.focus();
+ this.win.addEventListener("keypress", this, { capture: true });
+ if (this._container.localName === "div") {
+ this.win.addEventListener("focus", this, {
+ capture: true, // get the event before retargeting
+ passive: true,
+ });
+ this._positionCallout();
+ } else {
+ this._container.classList.remove("hidden");
+ }
+ };
+ if (
+ this._container.localName === "div" &&
+ this.doc.activeElement &&
+ !this.savedFocus
+ ) {
+ let element = this.doc.activeElement;
+ this.savedFocus = {
+ element,
+ focusVisible: element.matches(":focus-visible"),
+ };
+ }
+ // Once the screen element is added to the DOM, wait for the
+ // animation frame after next to ensure that _positionCallout
+ // has access to the rendered screen with the correct height
+ if (this._container.localName === "div") {
+ this.win.requestAnimationFrame(() => {
+ this.win.requestAnimationFrame(onRender);
+ });
+ } else if (this._container.localName === "panel") {
+ const anchor = this._getAnchor();
+ if (!anchor) {
+ this.endTour();
+ return;
+ }
+ const position = anchor.panel_position_string;
+ this._container.addEventListener("popupshown", onRender, {
+ once: true,
+ });
+ this._container.addEventListener("popuphiding", this);
+ this._addPanelConflictListeners();
+ this._container.openPopup(anchor.element, { position });
+ }
+ }
+ });
+ }
+
+ this._pageEventManager?.clear();
+ this.ready = false;
+ this._container?.remove();
+ this.renderObserver?.disconnect();
+
+ if (!this.cfrFeaturesUserPref) {
+ this.endTour();
+ return false;
+ }
+
+ let rendering = (await this._renderCallout()) && !!this.currentScreen;
+ if (!rendering) {
+ this.endTour();
+ }
+
+ if (this.message.template) {
+ lazy.ASRouter.addImpression(this.message);
+ }
+ return rendering;
+ }
+
+ /**
+ * @typedef {Object} FeatureCalloutTheme An object with a set of custom color
+ * schemes and/or a preset key. If both are provided, the preset will be
+ * applied first, then the custom themes will override the preset values.
+ * @property {String} [preset] Key of {@link FeatureCallout.themePresets}
+ * @property {ColorScheme} [light] Custom light scheme
+ * @property {ColorScheme} [dark] Custom dark scheme
+ * @property {ColorScheme} [hcm] Custom high contrast scheme
+ * @property {ColorScheme} [all] Custom scheme that will be applied in all
+ * cases, but overridden by the other schemes if they are present. This is
+ * useful if the values are already controlled by the browser theme.
+ * @property {Boolean} [simulateContent] Set to true if the feature callout
+ * exists in the browser chrome but is meant to be displayed over the
+ * content area to appear as if it is part of the page. This will cause the
+ * styles to use a media query targeting the content instead of the chrome,
+ * so that if the browser theme doesn't match the content color scheme, the
+ * callout will correctly follow the content scheme. This is currently used
+ * for the feature callouts displayed over the PDF.js viewer.
+ */
+
+ /**
+ * @typedef {Object} ColorScheme An object with key-value pairs, with keys
+ * from {@link FeatureCallout.themePropNames}, mapped to CSS color values
+ */
+
+ /**
+ * Combine the preset and custom themes into a single object and store it.
+ * @param {FeatureCalloutTheme} theme
+ */
+ _initTheme(theme) {
+ /** @type {FeatureCalloutTheme} */
+ this.theme = Object.assign(
+ {},
+ FeatureCallout.themePresets[theme.preset],
+ theme
+ );
+ }
+
+ /**
+ * Apply all the theme colors to the feature callout's root element as CSS
+ * custom properties in inline styles. These custom properties are consumed by
+ * _feature-callout-theme.scss, which is bundled with the other styles that
+ * are loaded by {@link FeatureCallout.prototype._addCalloutLinkElements}.
+ */
+ _applyTheme() {
+ if (this._container) {
+ // This tells the stylesheets to use -moz-content-prefers-color-scheme
+ // instead of prefers-color-scheme, in order to follow the content color
+ // scheme instead of the chrome color scheme, in case of a mismatch when
+ // the feature callout exists in the chrome but is meant to look like it's
+ // part of the content of a page in a browser tab (like PDF.js).
+ this._container.classList.toggle(
+ "simulateContent",
+ !!this.theme.simulateContent
+ );
+ for (const type of ["light", "dark", "hcm"]) {
+ const scheme = this.theme[type];
+ for (const name of FeatureCallout.themePropNames) {
+ this._setThemeVariable(
+ `--fc-${name}-${type}`,
+ scheme?.[name] || this.theme.all?.[name]
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Set or remove a CSS custom property on the feature callout container
+ * @param {String} name Name of the CSS custom property
+ * @param {String|void} [value] Value of the property, or omit to remove it
+ */
+ _setThemeVariable(name, value) {
+ if (value) {
+ this._container.style.setProperty(name, value);
+ } else {
+ this._container.style.removeProperty(name);
+ }
+ }
+
+ /** A list of all the theme properties that can be set */
+ static themePropNames = [
+ "background",
+ "color",
+ "border",
+ "accent-color",
+ "button-background",
+ "button-color",
+ "button-border",
+ "button-background-hover",
+ "button-color-hover",
+ "button-border-hover",
+ "button-background-active",
+ "button-color-active",
+ "button-border-active",
+ "primary-button-background",
+ "primary-button-color",
+ "primary-button-border",
+ "primary-button-background-hover",
+ "primary-button-color-hover",
+ "primary-button-border-hover",
+ "primary-button-background-active",
+ "primary-button-color-active",
+ "primary-button-border-active",
+ "link-color",
+ "link-color-hover",
+ "link-color-active",
+ ];
+
+ /** @type {Object<String, FeatureCalloutTheme>} */
+ static themePresets = {
+ // For themed system pages like New Tab and Firefox View. Themed content
+ // colors inherit from the user's theme through contentTheme.js.
+ "themed-content": {
+ all: {
+ background: "var(--newtab-background-color-secondary)",
+ color: "var(--newtab-text-primary-color, var(--in-content-page-color))",
+ border:
+ "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #000)",
+ "accent-color": "var(--in-content-primary-button-background)",
+ "button-background": "color-mix(in srgb, transparent 93%, #000)",
+ "button-color":
+ "var(--newtab-text-primary-color, var(--in-content-page-color))",
+ "button-border": "transparent",
+ "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
+ "button-color-hover":
+ "var(--newtab-text-primary-color, var(--in-content-page-color))",
+ "button-border-hover": "transparent",
+ "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
+ "button-color-active":
+ "var(--newtab-text-primary-color, var(--in-content-page-color))",
+ "button-border-active": "transparent",
+ "primary-button-background":
+ "var(--in-content-primary-button-background)",
+ "primary-button-color": "var(--in-content-primary-button-text-color)",
+ "primary-button-border":
+ "var(--in-content-primary-button-border-color)",
+ "primary-button-background-hover":
+ "var(--in-content-primary-button-background-hover)",
+ "primary-button-color-hover":
+ "var(--in-content-primary-button-text-color-hover)",
+ "primary-button-border-hover":
+ "var(--in-content-primary-button-border-hover)",
+ "primary-button-background-active":
+ "var(--in-content-primary-button-background-active)",
+ "primary-button-color-active":
+ "var(--in-content-primary-button-text-color-active)",
+ "primary-button-border-active":
+ "var(--in-content-primary-button-border-active)",
+ "link-color": "LinkText",
+ "link-color-hover": "LinkText",
+ "link-color-active": "ActiveText",
+ "link-color-visited": "VisitedText",
+ },
+ dark: {
+ border:
+ "color-mix(in srgb, var(--newtab-background-color-secondary) 80%, #FFF)",
+ "button-background": "color-mix(in srgb, transparent 80%, #000)",
+ "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
+ "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
+ },
+ hcm: {
+ background: "-moz-dialog",
+ color: "-moz-dialogtext",
+ border: "-moz-dialogtext",
+ "accent-color": "LinkText",
+ "button-background": "ButtonFace",
+ "button-color": "ButtonText",
+ "button-border": "ButtonText",
+ "button-background-hover": "ButtonText",
+ "button-color-hover": "ButtonFace",
+ "button-border-hover": "ButtonText",
+ "button-background-active": "ButtonText",
+ "button-color-active": "ButtonFace",
+ "button-border-active": "ButtonText",
+ },
+ },
+ // PDF.js colors are from toolkit/components/pdfjs/content/web/viewer.css
+ pdfjs: {
+ all: {
+ background: "#FFF",
+ color: "rgb(12, 12, 13)",
+ border: "#CFCFD8",
+ "accent-color": "#0A84FF",
+ "button-background": "rgb(215, 215, 219)",
+ "button-color": "rgb(12, 12, 13)",
+ "button-border": "transparent",
+ "button-background-hover": "rgb(221, 222, 223)",
+ "button-color-hover": "rgb(12, 12, 13)",
+ "button-border-hover": "transparent",
+ "button-background-active": "rgb(221, 222, 223)",
+ "button-color-active": "rgb(12, 12, 13)",
+ "button-border-active": "transparent",
+ // use default primary button colors in _feature-callout-theme.scss
+ "link-color": "LinkText",
+ "link-color-hover": "LinkText",
+ "link-color-active": "ActiveText",
+ "link-color-visited": "VisitedText",
+ },
+ dark: {
+ background: "#1C1B22",
+ color: "#F9F9FA",
+ border: "#3A3944",
+ "button-background": "rgb(74, 74, 79)",
+ "button-color": "#F9F9FA",
+ "button-background-hover": "rgb(102, 102, 103)",
+ "button-color-hover": "#F9F9FA",
+ "button-background-active": "rgb(102, 102, 103)",
+ "button-color-active": "#F9F9FA",
+ },
+ hcm: {
+ background: "-moz-dialog",
+ color: "-moz-dialogtext",
+ border: "CanvasText",
+ "accent-color": "Highlight",
+ "button-background": "ButtonFace",
+ "button-color": "ButtonText",
+ "button-border": "ButtonText",
+ "button-background-hover": "Highlight",
+ "button-color-hover": "CanvasText",
+ "button-border-hover": "Highlight",
+ "button-background-active": "Highlight",
+ "button-color-active": "CanvasText",
+ "button-border-active": "Highlight",
+ },
+ },
+ newtab: {
+ all: {
+ background: "var(--newtab-background-color-secondary, #FFF)",
+ color: "var(--newtab-text-primary-color, WindowText)",
+ border:
+ "color-mix(in srgb, var(--newtab-background-color-secondary, #FFF) 80%, #000)",
+ "accent-color": "#0061e0",
+ "button-background": "color-mix(in srgb, transparent 93%, #000)",
+ "button-color": "var(--newtab-text-primary-color, WindowText)",
+ "button-border": "transparent",
+ "button-background-hover": "color-mix(in srgb, transparent 88%, #000)",
+ "button-color-hover": "var(--newtab-text-primary-color, WindowText)",
+ "button-border-hover": "transparent",
+ "button-background-active": "color-mix(in srgb, transparent 80%, #000)",
+ "button-color-active": "var(--newtab-text-primary-color, WindowText)",
+ "button-border-active": "transparent",
+ // use default primary button colors in _feature-callout-theme.scss
+ "link-color": "rgb(0, 97, 224)",
+ "link-color-hover": "rgb(0, 97, 224)",
+ "link-color-active": "color-mix(in srgb, rgb(0, 97, 224) 80%, #000)",
+ "link-color-visited": "rgb(0, 97, 224)",
+ },
+ dark: {
+ "accent-color": "rgb(0, 221, 255)",
+ background: "var(--newtab-background-color-secondary, #42414D)",
+ border:
+ "color-mix(in srgb, var(--newtab-background-color-secondary, #42414D) 80%, #FFF)",
+ "button-background": "color-mix(in srgb, transparent 80%, #000)",
+ "button-background-hover": "color-mix(in srgb, transparent 65%, #000)",
+ "button-background-active": "color-mix(in srgb, transparent 55%, #000)",
+ "link-color": "rgb(0, 221, 255)",
+ "link-color-hover": "rgb(0,221,255)",
+ "link-color-active": "color-mix(in srgb, rgb(0, 221, 255) 60%, #FFF)",
+ "link-color-visited": "rgb(0, 221, 255)",
+ },
+ hcm: {
+ background: "-moz-dialog",
+ color: "-moz-dialogtext",
+ border: "-moz-dialogtext",
+ "accent-color": "SelectedItem",
+ "button-background": "ButtonFace",
+ "button-color": "ButtonText",
+ "button-border": "ButtonText",
+ "button-background-hover": "ButtonText",
+ "button-color-hover": "ButtonFace",
+ "button-border-hover": "ButtonText",
+ "button-background-active": "ButtonText",
+ "button-color-active": "ButtonFace",
+ "button-border-active": "ButtonText",
+ "link-color": "LinkText",
+ "link-color-hover": "LinkText",
+ "link-color-active": "ActiveText",
+ "link-color-visited": "VisitedText",
+ },
+ },
+ // These colors are intended to inherit the user's theme properties from the
+ // main chrome window, for callouts to be anchored to chrome elements.
+ // Specific schemes aren't necessary since the theme and frontend
+ // stylesheets handle these variables' values.
+ chrome: {
+ all: {
+ background: "var(--arrowpanel-background)",
+ color: "var(--arrowpanel-color)",
+ border: "var(--arrowpanel-border-color)",
+ "accent-color": "var(--focus-outline-color)",
+ "button-background": "var(--button-bgcolor)",
+ "button-color": "var(--button-color)",
+ "button-border": "transparent",
+ "button-background-hover": "var(--button-hover-bgcolor)",
+ "button-color-hover": "var(--button-color)",
+ "button-border-hover": "transparent",
+ "button-background-active": "var(--button-active-bgcolor)",
+ "button-color-active": "var(--button-color)",
+ "button-border-active": "transparent",
+ "primary-button-background": "var(--button-primary-bgcolor)",
+ "primary-button-color": "var(--button-primary-color)",
+ "primary-button-border": "transparent",
+ "primary-button-background-hover":
+ "var(--button-primary-hover-bgcolor)",
+ "primary-button-color-hover": "var(--button-primary-color)",
+ "primary-button-border-hover": "transparent",
+ "primary-button-background-active":
+ "var(--button-primary-active-bgcolor)",
+ "primary-button-color-active": "var(--button-primary-color)",
+ "primary-button-border-active": "transparent",
+ "link-color": "LinkText",
+ "link-color-hover": "LinkText",
+ "link-color-active": "ActiveText",
+ "link-color-visited": "VisitedText",
+ },
+ },
+ };
+}
diff --git a/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs b/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs
new file mode 100644
index 0000000000..7ede6c9bf8
--- /dev/null
+++ b/browser/components/asrouter/modules/FeatureCalloutBroker.sys.mjs
@@ -0,0 +1,215 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FeatureCallout: "resource:///modules/asrouter/FeatureCallout.sys.mjs",
+});
+
+/**
+ * @typedef {Object} FeatureCalloutOptions
+ * @property {Window} win window in which messages will be rendered.
+ * @property {{name: String, defaultValue?: String}} [pref] optional pref used
+ * to track progress through a given feature tour. for example:
+ * {
+ * name: "browser.pdfjs.feature-tour",
+ * defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }',
+ * }
+ * or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional)
+ * @property {String} [location] string to pass as the page when requesting
+ * messages from ASRouter and sending telemetry.
+ * @property {MozBrowser} [browser] <browser> element responsible for the
+ * feature callout. for content pages, this is the browser element that the
+ * callout is being shown in. for chrome, this is the active browser.
+ * @property {Function} [cleanup] callback to be invoked when the callout is
+ * removed or the window is unloaded.
+ * @property {FeatureCalloutTheme} [theme] optional dynamic color theme.
+ */
+
+/** @typedef {import("resource:///modules/asrouter/FeatureCallout.sys.mjs").FeatureCalloutTheme} FeatureCalloutTheme */
+
+/**
+ * @typedef {Object} FeatureCalloutItem
+ * @property {lazy.FeatureCallout} callout instance of FeatureCallout.
+ * @property {Function} [cleanup] cleanup callback.
+ * @property {Boolean} showing whether the callout is currently showing.
+ */
+
+export class _FeatureCalloutBroker {
+ /**
+ * Make a new FeatureCallout instance and store it in the callout map. Also
+ * add an unload listener to the window to clean up the callout when the
+ * window is unloaded.
+ * @param {FeatureCalloutOptions} config
+ */
+ makeFeatureCallout(config) {
+ const { win, pref, location, browser, theme } = config;
+ // Use an AbortController to clean up the unload listener in case the
+ // callout is cleaned up before the window is unloaded.
+ const controller = new AbortController();
+ const cleanup = () => {
+ this.#calloutMap.delete(win);
+ controller.abort();
+ config.cleanup?.();
+ };
+ this.#calloutMap.set(win, {
+ callout: new lazy.FeatureCallout({
+ win,
+ pref,
+ location,
+ context: "chrome",
+ browser,
+ listener: this.handleFeatureCalloutCallback.bind(this),
+ theme,
+ }),
+ cleanup,
+ showing: false,
+ });
+ win.addEventListener("unload", cleanup, { signal: controller.signal });
+ }
+
+ /**
+ * Show a feature callout message. For use by ASRouter, to be invoked when a
+ * trigger has matched to a feature_callout message.
+ * @param {MozBrowser} browser <browser> element associated with the trigger.
+ * @param {Object} message feature_callout message from ASRouter.
+ * @see {@link FeatureCalloutMessages.sys.mjs}
+ * @returns {Promise<Boolean>} whether the callout was shown.
+ */
+ async showFeatureCallout(browser, message) {
+ // Only show one callout at a time, across all windows.
+ if (this.isCalloutShowing) {
+ return false;
+ }
+ const win = browser.ownerGlobal;
+ // Avoid showing feature callouts if a dialog or panel is showing.
+ if (
+ win.gDialogBox?.dialog ||
+ [...win.document.querySelectorAll("panel")].some(p => p.state === "open")
+ ) {
+ return false;
+ }
+ const currentCallout = this.#calloutMap.get(win);
+ // If a custom callout was previously showing, but is no longer showing,
+ // tear down the FeatureCallout instance. We avoid tearing them down when
+ // they stop showing because they may be shown again, and we want to avoid
+ // the overhead of creating a new FeatureCallout instance. But the custom
+ // callout instance may be incompatible with the new ASRouter message, so
+ // we tear it down and create a new one.
+ if (currentCallout && currentCallout.callout.location !== "chrome") {
+ currentCallout.cleanup();
+ }
+ let item = this.#calloutMap.get(win);
+ let callout = item?.callout;
+ if (item) {
+ // If a callout previously showed in this instance, but the new message's
+ // tour_pref_name is different, update the old instance's tour properties.
+ callout.teardownFeatureTourProgress();
+ if (message.content.tour_pref_name) {
+ callout.pref = {
+ name: message.content.tour_pref_name,
+ defaultValue: message.content.tour_pref_default_value,
+ };
+ callout.setupFeatureTourProgress();
+ } else {
+ callout.pref = null;
+ }
+ } else {
+ const options = {
+ win,
+ location: "chrome",
+ browser,
+ theme: { preset: "chrome" },
+ };
+ if (message.content.tour_pref_name) {
+ options.pref = {
+ name: message.content.tour_pref_name,
+ defaultValue: message.content.tour_pref_default_value,
+ };
+ }
+ this.makeFeatureCallout(options);
+ item = this.#calloutMap.get(win);
+ callout = item.callout;
+ }
+ // Set this to true for now so that we can't be interrupted by another
+ // invocation. We'll set it to false below if it ended up not showing.
+ item.showing = true;
+ item.showing = await callout.showFeatureCallout(message).catch(() => {
+ item.cleanup();
+ return false;
+ });
+ return item.showing;
+ }
+
+ /**
+ * Make a new FeatureCallout instance specific to a special location, tearing
+ * down the existing generic FeatureCallout if it exists, and (if no message
+ * is passed) requesting a feature callout message to show. Does nothing if a
+ * callout is already in progress. This allows the PDF.js feature tour, which
+ * simulates content, to be shown in the chrome window without interfering
+ * with chrome feature callouts.
+ * @param {FeatureCalloutOptions} config
+ * @param {Object} message feature_callout message from ASRouter.
+ * @see {@link FeatureCalloutMessages.sys.mjs}
+ * @returns {FeatureCalloutItem|null} the callout item, if one was created.
+ */
+ showCustomFeatureCallout(config, message) {
+ if (this.isCalloutShowing) {
+ return null;
+ }
+ const { win, pref, location } = config;
+ const currentCallout = this.#calloutMap.get(win);
+ if (currentCallout && currentCallout.location !== location) {
+ currentCallout.cleanup();
+ }
+ let item = this.#calloutMap.get(win);
+ let callout = item?.callout;
+ if (item) {
+ callout.teardownFeatureTourProgress();
+ callout.pref = pref;
+ if (pref) {
+ callout.setupFeatureTourProgress();
+ }
+ } else {
+ this.makeFeatureCallout(config);
+ item = this.#calloutMap.get(win);
+ callout = item.callout;
+ }
+ item.showing = true;
+ // In this case, callers are not necessarily async, so we don't await.
+ callout
+ .showFeatureCallout(message)
+ .then(showing => {
+ item.showing = showing;
+ })
+ .catch(() => {
+ item.cleanup();
+ item.showing = false;
+ });
+ /** @type {FeatureCalloutItem} */
+ return item;
+ }
+
+ handleFeatureCalloutCallback(win, event, data) {
+ switch (event) {
+ case "end":
+ const item = this.#calloutMap.get(win);
+ if (item) {
+ item.showing = false;
+ }
+ break;
+ }
+ }
+
+ /** @returns {Boolean} whether a callout is currently showing. */
+ get isCalloutShowing() {
+ return [...this.#calloutMap.values()].some(({ showing }) => showing);
+ }
+
+ /** @type {Map<Window, FeatureCalloutItem>} */
+ #calloutMap = new Map();
+}
+
+export const FeatureCalloutBroker = new _FeatureCalloutBroker();
diff --git a/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs b/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs
new file mode 100644
index 0000000000..38c9a8d848
--- /dev/null
+++ b/browser/components/asrouter/modules/FeatureCalloutMessages.sys.mjs
@@ -0,0 +1,1299 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Eventually, make this a messaging system
+// provider instead of adding these message
+// into OnboardingMessageProvider.sys.mjs
+const FIREFOX_VIEW_PREF = "browser.firefox-view.feature-tour";
+const PDFJS_PREF = "browser.pdfjs.feature-tour";
+// Empty screens are included as placeholders to ensure step
+// indicator shows the correct number of total steps in the tour
+const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
+
+// Generate a JEXL targeting string based on the `complete` property being true
+// in a given Feature Callout tour progress preference value (which is JSON).
+const matchIncompleteTargeting = (prefName, defaultValue = false) => {
+ // regExpMatch() is a JEXL filter expression. Here we check if 'complete'
+ // exists in the pref's value, and returns true if the tour is incomplete.
+ const prefVal = `'${prefName}' | preferenceValue`;
+ // prefVal might be null if the preference doesn't exist. in this case, don't
+ // try to pipe into regExpMatch.
+ const completeMatch = `${prefVal} | regExpMatch('(?<=complete":)(.*)(?=})')`;
+ return `((${prefVal}) ? ((${completeMatch}) ? (${completeMatch}[1] != "true") : ${String(
+ defaultValue
+ )}) : ${String(defaultValue)})`;
+};
+
+// Generate a JEXL targeting string based on the current screen id found in a
+// given Feature Callout tour progress preference.
+const matchCurrentScreenTargeting = (prefName, screenIdRegEx = ".*") => {
+ // regExpMatch() is a JEXL filter expression. Here we check if 'screen' exists
+ // in the pref's value, and if it matches the screenIdRegEx. Returns
+ // null otherwise.
+ const prefVal = `'${prefName}' | preferenceValue`;
+ const screenMatch = `${prefVal} | regExpMatch('(?<=screen"\s*:)\s*"(${screenIdRegEx})(?="\s*,)')`;
+ const screenValMatches = `(${screenMatch}) ? !!(${screenMatch}[1]) : false`;
+ return `(${screenValMatches})`;
+};
+
+/**
+ * add24HourImpressionJEXLTargeting -
+ * Creates a "hasn't been viewed in > 24 hours"
+ * JEXL string and adds it to each message specified
+ *
+ * @param {array} messageIds - IDs of messages that the targeting string will be added to
+ * @param {string} prefix - The prefix of messageIDs that will used to create the JEXL string
+ * @param {array} messages - The array of messages that will be edited
+ * @returns {array} - The array of messages with the appropriate targeting strings edited
+ */
+function add24HourImpressionJEXLTargeting(
+ messageIds,
+ prefix,
+ uneditedMessages
+) {
+ let noImpressionsIn24HoursString = uneditedMessages
+ .filter(message => message.id.startsWith(prefix))
+ .map(
+ message =>
+ // If the last impression is null or if epoch time
+ // of the impression is < current time - 24hours worth of MS
+ `(messageImpressions.${message.id}[messageImpressions.${
+ message.id
+ } | length - 1] == null || messageImpressions.${
+ message.id
+ }[messageImpressions.${message.id} | length - 1] < ${
+ Date.now() - ONE_DAY_IN_MS
+ })`
+ )
+ .join(" && ");
+
+ // We're appending the string here instead of using
+ // template strings to avoid a recursion error from
+ // using the 'messages' variable within itself
+ return uneditedMessages.map(message => {
+ if (messageIds.includes(message.id)) {
+ message.targeting += `&& ${noImpressionsIn24HoursString}`;
+ }
+
+ return message;
+ });
+}
+
+// Exporting the about:firefoxview messages as a method here
+// acts as a safety guard against mutations of the original objects
+const MESSAGES = () => {
+ let messages = [
+ {
+ id: "FIREFOX_VIEW_SPOTLIGHT",
+ template: "spotlight",
+ content: {
+ id: "FIREFOX_VIEW_PROMO",
+ template: "multistage",
+ modal: "tab",
+ tour_pref_name: FIREFOX_VIEW_PREF,
+ screens: [
+ {
+ id: "DEFAULT_MODAL_UI",
+ content: {
+ title: {
+ fontSize: "32px",
+ fontWeight: 400,
+ string_id: "firefoxview-spotlight-promo-title",
+ },
+ subtitle: {
+ fontSize: "15px",
+ fontWeight: 400,
+ marginBlock: "10px",
+ marginInline: "40px",
+ string_id: "firefoxview-spotlight-promo-subtitle",
+ },
+ logo: { height: "48px" },
+ primary_button: {
+ label: {
+ string_id: "firefoxview-spotlight-promo-primarybutton",
+ },
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: FIREFOX_VIEW_PREF,
+ value: JSON.stringify({
+ screen: "FEATURE_CALLOUT_1",
+ complete: false,
+ }),
+ },
+ },
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "firefoxview-spotlight-promo-secondarybutton",
+ },
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: FIREFOX_VIEW_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ priority: 3,
+ trigger: {
+ id: "featureCalloutCheck",
+ },
+ frequency: {
+ // Add the highest possible cap to ensure impressions are recorded while allowing the Spotlight to sync across windows/tabs with Firefox View open
+ lifetime: 100,
+ },
+ targeting: `!inMr2022Holdback && source == "about:firefoxview" &&
+ !'browser.newtabpage.activity-stream.asrouter.providers.cfr'|preferenceIsUserSet &&
+ 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features'|preferenceValue &&
+ ${matchCurrentScreenTargeting(
+ FIREFOX_VIEW_PREF,
+ "FIREFOX_VIEW_SPOTLIGHT"
+ )} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`,
+ },
+ {
+ id: "FIREFOX_VIEW_FEATURE_TOUR",
+ template: "feature_callout",
+ content: {
+ id: "FIREFOX_VIEW_FEATURE_TOUR",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ tour_pref_name: FIREFOX_VIEW_PREF,
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_1",
+ anchors: [
+ {
+ selector: "#tab-pickup-container",
+ arrow_position: "top",
+ },
+ ],
+ content: {
+ position: "callout",
+ title: {
+ string_id: "callout-firefox-view-tab-pickup-title",
+ },
+ subtitle: {
+ string_id: "callout-firefox-view-tab-pickup-subtitle",
+ },
+ logo: {
+ imageURL: "chrome://browser/content/callout-tab-pickup.svg",
+ darkModeImageURL:
+ "chrome://browser/content/callout-tab-pickup-dark.svg",
+ height: "128px",
+ },
+ primary_button: {
+ label: {
+ string_id: "callout-primary-advance-button-label",
+ },
+ style: "secondary",
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: FIREFOX_VIEW_PREF,
+ value: JSON.stringify({
+ screen: "FEATURE_CALLOUT_2",
+ complete: false,
+ }),
+ },
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: FIREFOX_VIEW_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "toggle",
+ selectors: "#tab-pickup-container",
+ },
+ action: { reposition: true },
+ },
+ ],
+ },
+ },
+ {
+ id: "FEATURE_CALLOUT_2",
+ anchors: [
+ {
+ selector: "#recently-closed-tabs-container",
+ arrow_position: "bottom",
+ },
+ ],
+ content: {
+ position: "callout",
+ title: {
+ string_id: "callout-firefox-view-recently-closed-title",
+ },
+ subtitle: {
+ string_id: "callout-firefox-view-recently-closed-subtitle",
+ },
+ primary_button: {
+ label: {
+ string_id: "callout-primary-complete-button-label",
+ },
+ style: "secondary",
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: FIREFOX_VIEW_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: FIREFOX_VIEW_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "toggle",
+ selectors: "#recently-closed-tabs-container",
+ },
+ action: { reposition: true },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ priority: 3,
+ targeting: `!inMr2022Holdback && source == "about:firefoxview" && ${matchCurrentScreenTargeting(
+ FIREFOX_VIEW_PREF,
+ "FEATURE_CALLOUT_[0-9]"
+ )} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`,
+ trigger: { id: "featureCalloutCheck" },
+ },
+ {
+ id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER",
+ template: "feature_callout",
+ content: {
+ id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FIREFOX_VIEW_TAB_PICKUP_REMINDER",
+ anchors: [
+ {
+ selector: "#tab-pickup-container",
+ arrow_position: "top",
+ },
+ ],
+ content: {
+ position: "callout",
+ title: {
+ string_id:
+ "continuous-onboarding-firefox-view-tab-pickup-title",
+ },
+ subtitle: {
+ string_id:
+ "continuous-onboarding-firefox-view-tab-pickup-subtitle",
+ },
+ logo: {
+ imageURL: "chrome://browser/content/callout-tab-pickup.svg",
+ darkModeImageURL:
+ "chrome://browser/content/callout-tab-pickup-dark.svg",
+ height: "128px",
+ },
+ primary_button: {
+ label: {
+ string_id: "mr1-onboarding-get-started-primary-button-label",
+ },
+ style: "secondary",
+ action: {
+ type: "CLICK_ELEMENT",
+ navigate: true,
+ data: {
+ selector:
+ "#tab-pickup-container button.primary:not(#error-state-button)",
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "toggle",
+ selectors: "#tab-pickup-container",
+ },
+ action: { reposition: true },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ priority: 2,
+ targeting: `!inMr2022Holdback && source == "about:firefoxview" && "browser.firefox-view.view-count" | preferenceValue > 2
+ && (("identity.fxaccounts.enabled" | preferenceValue == false) || !(("services.sync.engine.tabs" | preferenceValue == true) && ("services.sync.username" | preferenceValue))) && (!messageImpressions.FIREFOX_VIEW_SPOTLIGHT[messageImpressions.FIREFOX_VIEW_SPOTLIGHT | length - 1] || messageImpressions.FIREFOX_VIEW_SPOTLIGHT[messageImpressions.FIREFOX_VIEW_SPOTLIGHT | length - 1] < currentDate|date - ${ONE_DAY_IN_MS})`,
+ frequency: {
+ lifetime: 1,
+ },
+ trigger: { id: "featureCalloutCheck" },
+ },
+ {
+ id: "PDFJS_FEATURE_TOUR_A",
+ template: "feature_callout",
+ content: {
+ id: "PDFJS_FEATURE_TOUR",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ tour_pref_name: PDFJS_PREF,
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_1_A",
+ anchors: [
+ {
+ selector: "hbox#browser",
+ arrow_position: "top-end",
+ absolute_position: { top: "43px", right: "51px" },
+ },
+ ],
+ content: {
+ position: "callout",
+ title: {
+ string_id: "callout-pdfjs-edit-title",
+ },
+ subtitle: {
+ string_id: "callout-pdfjs-edit-body-a",
+ },
+ primary_button: {
+ label: {
+ string_id: "callout-pdfjs-edit-button",
+ },
+ style: "secondary",
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PDFJS_PREF,
+ value: JSON.stringify({
+ screen: "FEATURE_CALLOUT_2_A",
+ complete: false,
+ }),
+ },
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PDFJS_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ id: "FEATURE_CALLOUT_2_A",
+ anchors: [
+ {
+ selector: "hbox#browser",
+ arrow_position: "top-end",
+ absolute_position: { top: "43px", right: "23px" },
+ },
+ ],
+ content: {
+ position: "callout",
+ title: {
+ string_id: "callout-pdfjs-draw-title",
+ },
+ subtitle: {
+ string_id: "callout-pdfjs-draw-body-a",
+ },
+ primary_button: {
+ label: {
+ string_id: "callout-pdfjs-draw-button",
+ },
+ style: "secondary",
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PDFJS_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PDFJS_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ priority: 1,
+ targeting: `source == "open" && ${matchCurrentScreenTargeting(
+ PDFJS_PREF,
+ "FEATURE_CALLOUT_[0-9]_A"
+ )} && ${matchIncompleteTargeting(PDFJS_PREF)}`,
+ trigger: { id: "pdfJsFeatureCalloutCheck" },
+ },
+ {
+ id: "PDFJS_FEATURE_TOUR_B",
+ template: "feature_callout",
+ content: {
+ id: "PDFJS_FEATURE_TOUR",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ tour_pref_name: PDFJS_PREF,
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_1_B",
+ anchors: [
+ {
+ selector: "hbox#browser",
+ arrow_position: "top-end",
+ absolute_position: { top: "43px", right: "51px" },
+ },
+ ],
+ content: {
+ position: "callout",
+ title: {
+ string_id: "callout-pdfjs-edit-title",
+ },
+ subtitle: {
+ string_id: "callout-pdfjs-edit-body-b",
+ },
+ primary_button: {
+ label: {
+ string_id: "callout-pdfjs-edit-button",
+ },
+ style: "secondary",
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PDFJS_PREF,
+ value: JSON.stringify({
+ screen: "FEATURE_CALLOUT_2_B",
+ complete: false,
+ }),
+ },
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PDFJS_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ },
+ },
+ {
+ id: "FEATURE_CALLOUT_2_B",
+ anchors: [
+ {
+ selector: "hbox#browser",
+ arrow_position: "top-end",
+ absolute_position: { top: "43px", right: "23px" },
+ },
+ ],
+ content: {
+ position: "callout",
+ title: {
+ string_id: "callout-pdfjs-draw-title",
+ },
+ subtitle: {
+ string_id: "callout-pdfjs-draw-body-b",
+ },
+ primary_button: {
+ label: {
+ string_id: "callout-pdfjs-draw-button",
+ },
+ style: "secondary",
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PDFJS_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ dismiss_button: {
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: PDFJS_PREF,
+ value: JSON.stringify({
+ screen: "",
+ complete: true,
+ }),
+ },
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ priority: 1,
+ targeting: `source == "open" && ${matchCurrentScreenTargeting(
+ PDFJS_PREF,
+ "FEATURE_CALLOUT_[0-9]_B"
+ )} && ${matchIncompleteTargeting(PDFJS_PREF)}`,
+ trigger: { id: "pdfJsFeatureCalloutCheck" },
+ },
+ {
+ // "Callout 1" in the Fakespot Figma spec
+ id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT",
+ template: "feature_callout",
+ content: {
+ id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT",
+ anchors: [
+ {
+ selector: "#shopping-sidebar-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ no_open_on_anchor: true,
+ },
+ ],
+ content: {
+ position: "callout",
+ title_logo: {
+ imageURL:
+ "chrome://browser/content/shopping/assets/shopping.svg",
+ alignment: "top",
+ },
+ title: {
+ string_id: "shopping-callout-closed-opted-in-subtitle",
+ marginInline: "3px 40px",
+ fontWeight: "inherit",
+ },
+ dismiss_button: {
+ action: { dismiss: true },
+ size: "small",
+ marginBlock: "24px 0",
+ marginInline: "0 24px",
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "click",
+ selectors: "#shopping-sidebar-button",
+ },
+ action: { dismiss: true },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ priority: 1,
+ // Auto-open feature flag is not enabled; User is opted in; First time closing sidebar; Has not seen either on-closed callout before; Has not opted out of CFRs.
+ targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT|length && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT|length`,
+ trigger: { id: "shoppingProductPageWithSidebarClosed" },
+ frequency: { lifetime: 1 },
+ },
+ {
+ // "Callout 3" in the Fakespot Figma spec
+ id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT",
+ template: "feature_callout",
+ content: {
+ id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT",
+ anchors: [
+ {
+ selector: "#shopping-sidebar-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ no_open_on_anchor: true,
+ },
+ ],
+ content: {
+ position: "callout",
+ title_logo: {
+ imageURL:
+ "chrome://browser/content/shopping/assets/shopping.svg",
+ },
+ title: {
+ string_id: "shopping-callout-closed-not-opted-in-title",
+ marginInline: "3px 40px",
+ },
+ subtitle: {
+ string_id: "shopping-callout-closed-not-opted-in-subtitle",
+ },
+ dismiss_button: {
+ action: { dismiss: true },
+ size: "small",
+ marginBlock: "24px 0",
+ marginInline: "0 24px",
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "click",
+ selectors: "#shopping-sidebar-button",
+ },
+ action: { dismiss: true },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ priority: 1,
+ // Auto-open feature flag is not enabled; User is not opted in; First time closing sidebar; Has not seen either on-closed callout before; Has not opted out of CFRs.
+ targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue != 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT|length && !messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT|length`,
+ trigger: { id: "shoppingProductPageWithSidebarClosed" },
+ frequency: { lifetime: 1 },
+ },
+ {
+ // "callout 2" in the Fakespot Figma spec
+ id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT",
+ template: "feature_callout",
+ content: {
+ id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT",
+ anchors: [
+ {
+ selector: "#shopping-sidebar-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ no_open_on_anchor: true,
+ },
+ ],
+ content: {
+ position: "callout",
+ title: { string_id: "shopping-callout-pdp-opted-in-title" },
+ subtitle: { string_id: "shopping-callout-pdp-opted-in-subtitle" },
+ logo: {
+ imageURL:
+ "chrome://browser/content/shopping/assets/ratingLight.avif",
+ darkModeImageURL:
+ "chrome://browser/content/shopping/assets/ratingDark.avif",
+ height: "216px",
+ },
+ dismiss_button: {
+ action: { dismiss: true },
+ size: "small",
+ marginBlock: "24px 0",
+ marginInline: "0 24px",
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "click",
+ selectors: "#shopping-sidebar-button",
+ },
+ action: { dismiss: true },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ priority: 1,
+ // Auto-open feature flag is not enabled; User is opted in; Has not opted out of CFRs; Has seen either on-closed callout before, but not within the last 24hrs or in this session.
+ targeting: `!isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue != true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && ((currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT[messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT | length - 1] | date) / 3600000 > 24 || (currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT[messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT | length - 1] | date) / 3600000 > 24)`,
+ trigger: { id: "shoppingProductPageWithSidebarClosed" },
+ frequency: { lifetime: 1 },
+ },
+ {
+ // "Callout 1" in the Fakespot Figma spec, but
+ // targeting not opted-in users only for rediscoverability experiment 2.
+ id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN",
+ template: "feature_callout",
+ content: {
+ id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN",
+ anchors: [
+ {
+ selector: "#shopping-sidebar-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ no_open_on_anchor: true,
+ },
+ ],
+ content: {
+ position: "callout",
+ width: "401px",
+ title: {
+ string_id: "shopping-callout-closed-not-opted-in-revised-title",
+ },
+ subtitle: {
+ string_id:
+ "shopping-callout-closed-not-opted-in-revised-subtitle",
+ letterSpacing: "0",
+ },
+ logo: {
+ imageURL:
+ "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg",
+ height: "214px",
+ },
+ dismiss_button: {
+ action: { dismiss: true },
+ size: "small",
+ marginBlock: "28px 0",
+ marginInline: "0 28px",
+ },
+ primary_button: {
+ label: {
+ string_id:
+ "shopping-callout-closed-not-opted-in-revised-button",
+ marginBlock: "0 -8px",
+ },
+ style: "secondary",
+ action: {
+ dismiss: true,
+ },
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "click",
+ selectors: "#shopping-sidebar-button",
+ },
+ action: { dismiss: true },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ priority: 1,
+ // Auto-open feature flag is enabled; User is not opted in; First time closing sidebar; Has not opted out of CFRs.
+ targeting: `isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue != 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`,
+ trigger: { id: "shoppingProductPageWithSidebarClosed" },
+ frequency: { lifetime: 1 },
+ skip_in_tests:
+ "not tested in automation and might pop up unexpectedly during review checker tests",
+ },
+ {
+ // "Callout 3" in the Fakespot Figma spec, but
+ // displayed if auto-open version of "callout 1" was seen already and 24 hours have passed.
+ id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER",
+ template: "feature_callout",
+ content: {
+ id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FAKESPOT_CALLOUT_PDP_NOT_OPTED_IN_REMINDER",
+ anchors: [
+ {
+ selector: "#shopping-sidebar-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ no_open_on_anchor: true,
+ },
+ ],
+ content: {
+ position: "callout",
+ width: "401px",
+ title: {
+ string_id: "shopping-callout-not-opted-in-reminder-title",
+ fontSize: "20px",
+ letterSpacing: "0",
+ },
+ subtitle: {
+ string_id: "shopping-callout-not-opted-in-reminder-subtitle",
+ letterSpacing: "0",
+ },
+ logo: {
+ imageURL:
+ "chrome://browser/content/shopping/assets/reviewsVisualCallout.svg",
+ alt: {
+ string_id: "shopping-callout-not-opted-in-reminder-img-alt",
+ },
+ height: "214px",
+ },
+ dismiss_button: {
+ action: {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ data: {
+ actions: [],
+ },
+ dismiss: true,
+ },
+ size: "small",
+ marginBlock: "28px 0",
+ marginInline: "0 28px",
+ },
+ primary_button: {
+ label: {
+ string_id:
+ "shopping-callout-not-opted-in-reminder-close-button",
+ marginBlock: "0 -8px",
+ },
+ style: "secondary",
+ action: {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ data: {
+ actions: [],
+ },
+ dismiss: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id:
+ "shopping-callout-not-opted-in-reminder-open-button",
+ marginBlock: "0 -8px",
+ },
+ style: "primary",
+ action: {
+ type: "MULTI_ACTION",
+ collectSelect: true,
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "browser.shopping.experience2023.active",
+ value: true,
+ },
+ },
+ },
+ ],
+ },
+ dismiss: true,
+ },
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "click",
+ selectors: "#shopping-sidebar-button",
+ },
+ action: { dismiss: true },
+ },
+ ],
+ tiles: {
+ type: "multiselect",
+ style: {
+ flexDirection: "column",
+ alignItems: "flex-start",
+ },
+ data: [
+ {
+ id: "checkbox-dont-show-again",
+ type: "checkbox",
+ defaultValue: false,
+ style: {
+ alignItems: "center",
+ },
+ label: {
+ string_id:
+ "shopping-callout-not-opted-in-reminder-ignore-checkbox",
+ },
+ icon: {
+ style: {
+ width: "16px",
+ height: "16px",
+ marginInline: "0 8px",
+ },
+ },
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "messaging-system-action.shopping-callouts-1-block",
+ value: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ priority: 2,
+ // Auto-open feature flag is enabled; User is not opted in; Has not opted out of CFRs; Has seen callout 1 before, but not within the last 5 days.
+ targeting:
+ "!isSidebarClosing && 'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 0 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false && !'messaging-system-action.shopping-callouts-1-block' | preferenceValue && (currentDate | date - messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN[messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_AUTO_OPEN | length - 1] | date) / 3600000 > 24",
+ trigger: {
+ id: "shoppingProductPageWithSidebarClosed",
+ },
+ frequency: {
+ custom: [
+ {
+ cap: 1,
+ period: 432000000,
+ },
+ ],
+ lifetime: 3,
+ },
+ skip_in_tests:
+ "not tested in automation and might pop up unexpectedly during review checker tests",
+ },
+ {
+ // "Callout 4" in the Fakespot Figma spec, for rediscoverability experiment 2.
+ id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN",
+ template: "feature_callout",
+ content: {
+ id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FAKESPOT_CALLOUT_DISABLED_AUTO_OPEN",
+ anchors: [
+ {
+ selector: "#shopping-sidebar-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ no_open_on_anchor: true,
+ },
+ ],
+ content: {
+ position: "callout",
+ width: "401px",
+ title: {
+ string_id: "shopping-callout-disabled-auto-open-title",
+ },
+ subtitle: {
+ string_id: "shopping-callout-disabled-auto-open-subtitle",
+ letterSpacing: "0",
+ },
+ logo: {
+ imageURL:
+ "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg",
+ height: "214px",
+ },
+ dismiss_button: {
+ action: { dismiss: true },
+ size: "small",
+ marginBlock: "28px 0",
+ marginInline: "0 28px",
+ },
+ primary_button: {
+ label: {
+ string_id: "shopping-callout-disabled-auto-open-button",
+ marginBlock: "0 -8px",
+ },
+ style: "secondary",
+ action: {
+ dismiss: true,
+ },
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "click",
+ selectors: "#shopping-sidebar-button",
+ },
+ action: { dismiss: true },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ priority: 1,
+ // Auto-open feature flag is enabled; User disabled auto-open behavior; User is opted in; Has not opted out of CFRs.
+ targeting: `'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.autoOpen.userEnabled' | preferenceValue == false && 'browser.shopping.experience2023.optedIn' | preferenceValue == 1 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`,
+ trigger: {
+ id: "preferenceObserver",
+ params: ["browser.shopping.experience2023.autoOpen.userEnabled"],
+ },
+ frequency: { lifetime: 1 },
+ skip_in_tests:
+ "not tested in automation and might pop up unexpectedly during review checker tests",
+ },
+ {
+ // "Callout 5" in the Fakespot Figma spec, for rediscoverability experiment 2.
+ id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN",
+ template: "feature_callout",
+ content: {
+ id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FAKESPOT_CALLOUT_OPTED_OUT_AUTO_OPEN",
+ anchors: [
+ {
+ selector: "#shopping-sidebar-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ no_open_on_anchor: true,
+ },
+ ],
+ content: {
+ position: "callout",
+ width: "401px",
+ title: {
+ string_id: "shopping-callout-opted-out-title",
+ },
+ subtitle: {
+ string_id: "shopping-callout-opted-out-subtitle",
+ letterSpacing: "0",
+ },
+ logo: {
+ imageURL:
+ "chrome://browser/content/shopping/assets/priceTagButtonCallout.svg",
+ height: "214px",
+ },
+ dismiss_button: {
+ action: { dismiss: true },
+ size: "small",
+ marginBlock: "28px 0",
+ marginInline: "0 28px",
+ },
+ primary_button: {
+ label: {
+ string_id: "shopping-callout-opted-out-button",
+ marginBlock: "0 -8px",
+ },
+ style: "secondary",
+ action: {
+ dismiss: true,
+ },
+ },
+ page_event_listeners: [
+ {
+ params: {
+ type: "click",
+ selectors: "#shopping-sidebar-button",
+ },
+ action: { dismiss: true },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ priority: 1,
+ // Auto-open feature flag is enabled; User has opted out; Has not opted out of CFRs.
+ targeting: `'browser.shopping.experience2023.autoOpen.enabled' | preferenceValue == true && 'browser.shopping.experience2023.optedIn' | preferenceValue == 2 && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`,
+ trigger: {
+ id: "preferenceObserver",
+ params: ["browser.shopping.experience2023.optedIn"],
+ },
+ frequency: { lifetime: 1 },
+ skip_in_tests:
+ "not tested in automation and might pop up unexpectedly during review checker tests",
+ },
+
+ // cookie banner reduction onboarding
+ {
+ id: "CFR_COOKIEBANNER",
+ groups: ["cfr"],
+ template: "feature_callout",
+ content: {
+ id: "CFR_COOKIEBANNER",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "COOKIEBANNER_CALLOUT",
+ anchors: [
+ {
+ selector: "#tracking-protection-icon-container",
+ panel_position: {
+ callout_attachment: "topleft",
+ anchor_attachment: "bottomcenter",
+ },
+ },
+ ],
+ content: {
+ position: "callout",
+ autohide: true,
+ title: {
+ string_id: "cookie-banner-blocker-onboarding-header",
+ paddingInline: "12px 0",
+ },
+ subtitle: {
+ string_id: "cookie-banner-blocker-onboarding-body",
+ paddingInline: "34px 0",
+ },
+ title_logo: {
+ alignment: "top",
+ height: "20px",
+ width: "20px",
+ imageURL:
+ "chrome://browser/skin/controlcenter/3rdpartycookies-blocked.svg",
+ },
+ dismiss_button: {
+ size: "small",
+ action: { dismiss: true },
+ },
+ additional_button: {
+ label: {
+ string_id: "cookie-banner-blocker-onboarding-learn-more",
+ marginInline: "34px 0",
+ },
+ style: "link",
+ alignment: "start",
+ action: {
+ type: "OPEN_URL",
+ data: {
+ args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/cookie-banner-reduction",
+ where: "tabshifted",
+ },
+ },
+ },
+ },
+ },
+ ],
+ },
+ frequency: {
+ lifetime: 1,
+ },
+ skip_in_tests: "it's not tested in automation",
+ trigger: {
+ id: "cookieBannerHandled",
+ },
+ targeting: `'cookiebanners.ui.desktop.enabled'|preferenceValue == true && 'cookiebanners.ui.desktop.showCallout'|preferenceValue == true && 'browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features' | preferenceValue != false`,
+ },
+ ];
+ messages = add24HourImpressionJEXLTargeting(
+ ["FIREFOX_VIEW_TAB_PICKUP_REMINDER"],
+ "FIREFOX_VIEW",
+ messages
+ );
+ return messages;
+};
+
+export const FeatureCalloutMessages = {
+ getMessages() {
+ return MESSAGES();
+ },
+};
diff --git a/browser/components/asrouter/modules/InfoBar.sys.mjs b/browser/components/asrouter/modules/InfoBar.sys.mjs
new file mode 100644
index 0000000000..a287b650a5
--- /dev/null
+++ b/browser/components/asrouter/modules/InfoBar.sys.mjs
@@ -0,0 +1,169 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs",
+});
+
+class InfoBarNotification {
+ constructor(message, dispatch) {
+ this._dispatch = dispatch;
+ this.dispatchUserAction = this.dispatchUserAction.bind(this);
+ this.buttonCallback = this.buttonCallback.bind(this);
+ this.infobarCallback = this.infobarCallback.bind(this);
+ this.message = message;
+ this.notification = null;
+ }
+
+ /**
+ * Show the infobar notification and send an impression ping
+ *
+ * @param {object} browser Browser reference for the currently selected tab
+ */
+ async showNotification(browser) {
+ let { content } = this.message;
+ let { gBrowser } = browser.ownerGlobal;
+ let doc = gBrowser.ownerDocument;
+ let notificationContainer;
+ if (content.type === "global") {
+ notificationContainer = browser.ownerGlobal.gNotificationBox;
+ } else {
+ notificationContainer = gBrowser.getNotificationBox(browser);
+ }
+
+ let priority = content.priority || notificationContainer.PRIORITY_SYSTEM;
+
+ this.notification = await notificationContainer.appendNotification(
+ this.message.id,
+ {
+ label: this.formatMessageConfig(doc, content.text),
+ image: content.icon || "chrome://branding/content/icon64.png",
+ priority,
+ eventCallback: this.infobarCallback,
+ },
+ content.buttons.map(b => this.formatButtonConfig(b))
+ );
+
+ this.addImpression();
+ }
+
+ formatMessageConfig(doc, content) {
+ let docFragment = doc.createDocumentFragment();
+ // notificationbox will only `appendChild` for documentFragments
+ docFragment.appendChild(
+ lazy.RemoteL10n.createElement(doc, "span", { content })
+ );
+
+ return docFragment;
+ }
+
+ formatButtonConfig(button) {
+ let btnConfig = { callback: this.buttonCallback, ...button };
+ // notificationbox will set correct data-l10n-id attributes if passed in
+ // using the l10n-id key. Otherwise the `button.label` text is used.
+ if (button.label.string_id) {
+ btnConfig["l10n-id"] = button.label.string_id;
+ }
+
+ return btnConfig;
+ }
+
+ addImpression() {
+ // Record an impression in ASRouter for frequency capping
+ this._dispatch({ type: "IMPRESSION", data: this.message });
+ // Send a user impression telemetry ping
+ this.sendUserEventTelemetry("IMPRESSION");
+ }
+
+ /**
+ * Called when one of the infobar buttons is clicked
+ */
+ buttonCallback(notificationBox, btnDescription, target) {
+ this.dispatchUserAction(
+ btnDescription.action,
+ target.ownerGlobal.gBrowser.selectedBrowser
+ );
+ let isPrimary = target.classList.contains("primary");
+ let eventName = isPrimary
+ ? "CLICK_PRIMARY_BUTTON"
+ : "CLICK_SECONDARY_BUTTON";
+ this.sendUserEventTelemetry(eventName);
+ }
+
+ dispatchUserAction(action, selectedBrowser) {
+ this._dispatch({ type: "USER_ACTION", data: action }, selectedBrowser);
+ }
+
+ /**
+ * Called when interacting with the toolbar (but not through the buttons)
+ */
+ infobarCallback(eventType) {
+ if (eventType === "removed") {
+ this.notification = null;
+ // eslint-disable-next-line no-use-before-define
+ InfoBar._activeInfobar = null;
+ } else if (this.notification) {
+ this.sendUserEventTelemetry("DISMISSED");
+ this.notification = null;
+ // eslint-disable-next-line no-use-before-define
+ InfoBar._activeInfobar = null;
+ }
+ }
+
+ sendUserEventTelemetry(event) {
+ const ping = {
+ message_id: this.message.id,
+ event,
+ };
+ this._dispatch({
+ type: "INFOBAR_TELEMETRY",
+ data: { action: "infobar_user_event", ...ping },
+ });
+ }
+}
+
+export const InfoBar = {
+ _activeInfobar: null,
+
+ maybeLoadCustomElement(win) {
+ if (!win.customElements.get("remote-text")) {
+ Services.scriptloader.loadSubScript(
+ "resource://activity-stream/data/custom-elements/paragraph.js",
+ win
+ );
+ }
+ },
+
+ maybeInsertFTL(win) {
+ win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
+ win.MozXULElement.insertFTLIfNeeded(
+ "browser/defaultBrowserNotification.ftl"
+ );
+ },
+
+ async showInfoBarMessage(browser, message, dispatch) {
+ // Prevent stacking multiple infobars
+ if (this._activeInfobar) {
+ return null;
+ }
+
+ const win = browser?.ownerGlobal;
+
+ if (!win || lazy.PrivateBrowsingUtils.isWindowPrivate(win)) {
+ return null;
+ }
+
+ this.maybeLoadCustomElement(win);
+ this.maybeInsertFTL(win);
+
+ let notification = new InfoBarNotification(message, dispatch);
+ await notification.showNotification(browser);
+ this._activeInfobar = true;
+
+ return notification;
+ },
+};
diff --git a/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs b/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs
new file mode 100644
index 0000000000..5960ab92cc
--- /dev/null
+++ b/browser/components/asrouter/modules/MessagingExperimentConstants.sys.mjs
@@ -0,0 +1,37 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file is used to define constants related to messaging experiments. It is
+ * imported by both ASRouter as well as import-rollouts.js, a node script that
+ * imports Nimbus rollouts into tree. It doesn't have access to any Firefox
+ * APIs, XPCOM, etc. and should be kept that way.
+ */
+
+/**
+ * These are the Nimbus feature IDs that correspond to messaging experiments.
+ * Other Nimbus features contain specific variables whose keys are enumerated in
+ * FeatureManifest.yaml. Conversely, messaging experiment features contain
+ * actual messages, with the usual message keys like `template` and `targeting`.
+ * @see FeatureManifest.yaml
+ */
+export const MESSAGING_EXPERIMENTS_DEFAULT_FEATURES = [
+ "cfr",
+ "fxms-message-1",
+ "fxms-message-2",
+ "fxms-message-3",
+ "fxms-message-4",
+ "fxms-message-5",
+ "fxms-message-6",
+ "fxms-message-7",
+ "fxms-message-8",
+ "fxms-message-9",
+ "fxms-message-10",
+ "fxms-message-11",
+ "infobar",
+ "moments-page",
+ "pbNewtab",
+ "spotlight",
+ "featureCallout",
+];
diff --git a/browser/components/asrouter/modules/MomentsPageHub.sys.mjs b/browser/components/asrouter/modules/MomentsPageHub.sys.mjs
new file mode 100644
index 0000000000..84fee3b517
--- /dev/null
+++ b/browser/components/asrouter/modules/MomentsPageHub.sys.mjs
@@ -0,0 +1,171 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setInterval: "resource://gre/modules/Timer.sys.mjs",
+ clearInterval: "resource://gre/modules/Timer.sys.mjs",
+});
+
+// Frequency at which to check for new messages
+const SYSTEM_TICK_INTERVAL = 5 * 60 * 1000;
+const HOMEPAGE_OVERRIDE_PREF = "browser.startup.homepage_override.once";
+
+// For the "reach" event of Messaging Experiments
+const REACH_EVENT_CATEGORY = "messaging_experiments";
+const REACH_EVENT_METHOD = "reach";
+// Note it's not "moments-page" as Telemetry Events only accepts understores
+// for the event `object`
+const REACH_EVENT_OBJECT = "moments_page";
+
+export class _MomentsPageHub {
+ constructor() {
+ this.id = "moments-page-hub";
+ this.state = {};
+ this.checkHomepageOverridePref = this.checkHomepageOverridePref.bind(this);
+ this._initialized = false;
+ }
+
+ async init(
+ waitForInitialized,
+ { handleMessageRequest, addImpression, blockMessageById, sendTelemetry }
+ ) {
+ if (this._initialized) {
+ return;
+ }
+
+ this._initialized = true;
+ this._handleMessageRequest = handleMessageRequest;
+ this._addImpression = addImpression;
+ this._blockMessageById = blockMessageById;
+ this._sendTelemetry = sendTelemetry;
+
+ // Need to wait for ASRouter to initialize before trying to fetch messages
+ await waitForInitialized;
+
+ this.messageRequest({
+ triggerId: "momentsUpdate",
+ template: "update_action",
+ });
+
+ const _intervalId = lazy.setInterval(
+ () => this.checkHomepageOverridePref(),
+ SYSTEM_TICK_INTERVAL
+ );
+ this.state = { _intervalId };
+ }
+
+ _sendPing(ping) {
+ this._sendTelemetry({
+ type: "MOMENTS_PAGE_TELEMETRY",
+ data: { action: "moments_user_event", ...ping },
+ });
+ }
+
+ sendUserEventTelemetry(message) {
+ this._sendPing({
+ message_id: message.id,
+ bucket_id: message.id,
+ event: "MOMENTS_PAGE_SET",
+ });
+ }
+
+ /**
+ * If we don't have `expire` defined with the message it could be because
+ * it depends on user dependent parameters. Since the message matched
+ * targeting we calculate `expire` based on the current timestamp and the
+ * `expireDelta` which defines for how long it should be available.
+ * @param expireDelta {number} - Offset in milliseconds from the current date
+ */
+ getExpirationDate(expireDelta) {
+ return Date.now() + expireDelta;
+ }
+
+ executeAction(message) {
+ const { id, data } = message.content.action;
+ switch (id) {
+ case "moments-wnp":
+ const { url, expireDelta } = data;
+ let { expire } = data;
+ if (!expire) {
+ expire = this.getExpirationDate(expireDelta);
+ }
+ // In order to reset this action we can dispatch a new message that
+ // will overwrite the prev value with an expiration date from the past.
+ Services.prefs.setStringPref(
+ HOMEPAGE_OVERRIDE_PREF,
+ JSON.stringify({ message_id: message.id, url, expire })
+ );
+ // Add impression and block immediately after taking the action
+ this.sendUserEventTelemetry(message);
+ this._addImpression(message);
+ this._blockMessageById(message.id);
+ break;
+ }
+ }
+
+ _recordReachEvent(message) {
+ const extra = { branches: message.branchSlug };
+ Services.telemetry.recordEvent(
+ REACH_EVENT_CATEGORY,
+ REACH_EVENT_METHOD,
+ REACH_EVENT_OBJECT,
+ message.experimentSlug,
+ extra
+ );
+ }
+
+ async messageRequest({ triggerId, template }) {
+ const telemetryObject = { triggerId };
+ TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
+ const messages = await this._handleMessageRequest({
+ triggerId,
+ template,
+ returnAll: true,
+ });
+ TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
+
+ // Record the "reach" event for all the messages with `forReachEvent`,
+ // only execute action for the first message without forReachEvent.
+ const nonReachMessages = [];
+ for (const message of messages) {
+ if (message.forReachEvent) {
+ if (!message.forReachEvent.sent) {
+ this._recordReachEvent(message);
+ message.forReachEvent.sent = true;
+ }
+ } else {
+ nonReachMessages.push(message);
+ }
+ }
+ if (nonReachMessages.length) {
+ this.executeAction(nonReachMessages[0]);
+ }
+ }
+
+ /**
+ * Pref is set via Remote Settings message. We want to continously
+ * monitor new messages that come in to ensure the one with the
+ * highest priority is set.
+ */
+ checkHomepageOverridePref() {
+ this.messageRequest({
+ triggerId: "momentsUpdate",
+ template: "update_action",
+ });
+ }
+
+ uninit() {
+ lazy.clearInterval(this.state._intervalId);
+ this.state = {};
+ this._initialized = false;
+ }
+}
+
+/**
+ * ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate
+ * message requests and render messages.
+ */
+export const MomentsPageHub = new _MomentsPageHub();
diff --git a/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs
new file mode 100644
index 0000000000..6164e3e72a
--- /dev/null
+++ b/browser/components/asrouter/modules/OnboardingMessageProvider.sys.mjs
@@ -0,0 +1,1414 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// We use importESModule here instead of static import so that
+// the Karma test environment won't choke on this module. This
+// is because the Karma test environment already stubs out
+// XPCOMUtils and AppConstants, and overrides importESModule
+// to be a no-op (which can't be done for a static import statement).
+
+// eslint-disable-next-line mozilla/use-static-import
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+// eslint-disable-next-line mozilla/use-static-import
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+import { FeatureCalloutMessages } from "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ShellService: "resource:///modules/ShellService.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "usesFirefoxSync",
+ "services.sync.username"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "mobileDevices",
+ "services.sync.clients.devices.mobile",
+ 0
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "hidePrivatePin",
+ "browser.startup.upgradeDialog.pinPBM.disabled",
+ false
+);
+
+const L10N = new Localization([
+ "branding/brand.ftl",
+ "browser/newtab/onboarding.ftl",
+ "toolkit/branding/brandings.ftl",
+ "toolkit/branding/accounts.ftl",
+]);
+
+const HOMEPAGE_PREF = "browser.startup.homepage";
+const NEWTAB_PREF = "browser.newtabpage.enabled";
+const FOURTEEN_DAYS_IN_MS = 14 * 24 * 60 * 60 * 1000;
+
+const BASE_MESSAGES = () => [
+ {
+ id: "FXA_ACCOUNTS_BADGE",
+ template: "toolbar_badge",
+ content: {
+ delay: 10000, // delay for 10 seconds
+ target: "fxa-toolbar-menu-button",
+ },
+ targeting: "false",
+ trigger: { id: "toolbarBadgeUpdate" },
+ },
+ {
+ id: "MILESTONE_MESSAGE_87",
+ groups: ["cfr"],
+ content: {
+ text: "",
+ layout: "short_message",
+ buttons: {
+ primary: {
+ event: "PROTECTION",
+ label: {
+ string_id: "cfr-doorhanger-milestone-ok-button",
+ },
+ action: {
+ type: "OPEN_PROTECTION_REPORT",
+ },
+ },
+ secondary: [
+ {
+ event: "DISMISS",
+ label: {
+ string_id: "cfr-doorhanger-milestone-close-button",
+ },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ ],
+ },
+ category: "cfrFeatures",
+ anchor_id: "tracking-protection-icon-container",
+ bucket_id: "CFR_MILESTONE_MESSAGE",
+ heading_text: {
+ string_id: "cfr-doorhanger-milestone-heading2",
+ },
+ notification_text: "",
+ skip_address_bar_notifier: true,
+ },
+ trigger: {
+ id: "contentBlocking",
+ params: ["ContentBlockingMilestone"],
+ },
+ template: "milestone_message",
+ frequency: {
+ lifetime: 7,
+ },
+ targeting: "pageLoad >= 4 && userPrefs.cfrFeatures",
+ },
+ {
+ id: "FX_MR_106_UPGRADE",
+ template: "spotlight",
+ targeting: "true",
+ content: {
+ template: "multistage",
+ id: "FX_MR_106_UPGRADE",
+ transitions: true,
+ modal: "tab",
+ screens: [
+ {
+ id: "UPGRADE_PIN_FIREFOX",
+ content: {
+ position: "split",
+ split_narrow_bkg_position: "-155px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-pin-image-alt",
+ },
+ progress_bar: "true",
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-pintaskbar.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ logo: {},
+ title: {
+ string_id: "mr2022-onboarding-existing-pin-header",
+ },
+ subtitle: {
+ string_id: "mr2022-onboarding-existing-pin-subtitle",
+ },
+ primary_button: {
+ label: {
+ string_id: "mr2022-onboarding-pin-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ },
+ checkbox: {
+ label: {
+ string_id: "mr2022-onboarding-existing-pin-checkbox-label",
+ },
+ defaultValue: true,
+ action: {
+ type: "MULTI_ACTION",
+ navigate: true,
+ data: {
+ actions: [
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ data: {
+ privatePin: true,
+ },
+ },
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ ],
+ },
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "mr2022-onboarding-secondary-skip-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ has_arrow_icon: true,
+ },
+ },
+ },
+ {
+ id: "UPGRADE_SET_DEFAULT",
+ content: {
+ position: "split",
+ split_narrow_bkg_position: "-60px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-default-image-alt",
+ },
+ progress_bar: "true",
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-settodefault.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ logo: {},
+ title: {
+ string_id: "mr2022-onboarding-set-default-title",
+ },
+ subtitle: {
+ string_id: "mr2022-onboarding-set-default-subtitle",
+ },
+ primary_button: {
+ label: {
+ string_id: "mr2022-onboarding-set-default-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ type: "SET_DEFAULT_BROWSER",
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "mr2022-onboarding-secondary-skip-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ has_arrow_icon: true,
+ },
+ },
+ },
+ {
+ id: "UPGRADE_IMPORT_SETTINGS_EMBEDDED",
+ content: {
+ tiles: { type: "migration-wizard" },
+ position: "split",
+ split_narrow_bkg_position: "-42px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-import-image-alt",
+ },
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-import.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ progress_bar: true,
+ hide_secondary_section: "responsive",
+ migrate_start: {
+ action: {},
+ },
+ migrate_close: {
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "mr2022-onboarding-secondary-skip-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ has_arrow_icon: true,
+ },
+ },
+ },
+ {
+ id: "UPGRADE_MOBILE_DOWNLOAD",
+ content: {
+ position: "split",
+ split_narrow_bkg_position: "-160px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-mobile-download-image-alt",
+ },
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-mobilecrosspromo.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ progress_bar: true,
+ logo: {},
+ title: {
+ string_id:
+ "onboarding-mobile-download-security-and-privacy-title",
+ },
+ subtitle: {
+ string_id:
+ "onboarding-mobile-download-security-and-privacy-subtitle",
+ },
+ hero_image: {
+ url: "chrome://activity-stream/content/data/content/assets/mobile-download-qr-existing-user.svg",
+ },
+ cta_paragraph: {
+ text: {
+ string_id: "mr2022-onboarding-mobile-download-cta-text",
+ string_name: "download-label",
+ },
+ action: {
+ type: "OPEN_URL",
+ data: {
+ args: "https://www.mozilla.org/firefox/mobile/get-app/?utm_medium=firefox-desktop&utm_source=onboarding-modal&utm_campaign=mr2022&utm_content=existing-global",
+ where: "tab",
+ },
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "mr2022-onboarding-secondary-skip-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ has_arrow_icon: true,
+ },
+ },
+ },
+ {
+ id: "UPGRADE_PIN_PRIVATE_WINDOW",
+ content: {
+ position: "split",
+ split_narrow_bkg_position: "-100px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-pin-private-image-alt",
+ },
+ progress_bar: "true",
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-pinprivate.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ logo: {},
+ title: {
+ string_id: "mr2022-upgrade-onboarding-pin-private-window-header",
+ },
+ subtitle: {
+ string_id:
+ "mr2022-upgrade-onboarding-pin-private-window-subtitle",
+ },
+ primary_button: {
+ label: {
+ string_id:
+ "mr2022-upgrade-onboarding-pin-private-window-primary-button-label",
+ },
+ action: {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ data: {
+ privatePin: true,
+ },
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "mr2022-onboarding-secondary-skip-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ has_arrow_icon: true,
+ },
+ },
+ },
+ {
+ id: "UPGRADE_DATA_RECOMMENDATION",
+ content: {
+ position: "split",
+ split_narrow_bkg_position: "-80px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-privacy-segmentation-image-alt",
+ },
+ progress_bar: "true",
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-privacysegmentation.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ logo: {},
+ title: {
+ string_id: "mr2022-onboarding-privacy-segmentation-title",
+ },
+ subtitle: {
+ string_id: "mr2022-onboarding-privacy-segmentation-subtitle",
+ },
+ cta_paragraph: {
+ text: {
+ string_id: "mr2022-onboarding-privacy-segmentation-text-cta",
+ },
+ },
+ primary_button: {
+ label: {
+ string_id:
+ "mr2022-onboarding-privacy-segmentation-button-primary-label",
+ },
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "browser.dataFeatureRecommendations.enabled",
+ value: true,
+ },
+ },
+ navigate: true,
+ },
+ },
+ additional_button: {
+ label: {
+ string_id:
+ "mr2022-onboarding-privacy-segmentation-button-secondary-label",
+ },
+ style: "secondary",
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "browser.dataFeatureRecommendations.enabled",
+ value: false,
+ },
+ },
+ navigate: true,
+ },
+ },
+ },
+ },
+ {
+ id: "UPGRADE_GRATITUDE",
+ content: {
+ position: "split",
+ progress_bar: "true",
+ split_narrow_bkg_position: "-228px",
+ image_alt_text: {
+ string_id: "mr2022-onboarding-gratitude-image-alt",
+ },
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/mr-gratitude.svg') var(--mr-secondary-position) no-repeat var(--mr-screen-background-color)",
+ logo: {},
+ title: {
+ string_id: "mr2022-onboarding-gratitude-title",
+ },
+ subtitle: {
+ string_id: "mr2022-onboarding-gratitude-subtitle",
+ },
+ primary_button: {
+ label: {
+ string_id: "mr2022-onboarding-gratitude-primary-button-label",
+ },
+ action: {
+ type: "OPEN_FIREFOX_VIEW",
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "mr2022-onboarding-gratitude-secondary-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ id: "FX_100_UPGRADE",
+ template: "spotlight",
+ targeting: "false",
+ content: {
+ template: "multistage",
+ id: "FX_100_UPGRADE",
+ transitions: true,
+ screens: [
+ {
+ id: "UPGRADE_PIN_FIREFOX",
+ content: {
+ logo: {
+ imageURL:
+ "chrome://activity-stream/content/data/content/assets/heart.webp",
+ height: "73px",
+ },
+ has_noodles: true,
+ title: {
+ fontSize: "36px",
+ string_id: "fx100-upgrade-thanks-header",
+ },
+ title_style: "fancy shine",
+ background:
+ "url('chrome://activity-stream/content/data/content/assets/confetti.svg') top / 100% no-repeat var(--in-content-page-background)",
+ subtitle: {
+ string_id: "fx100-upgrade-thanks-keep-body",
+ },
+ primary_button: {
+ label: {
+ string_id: "fx100-thank-you-pin-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "onboarding-not-now-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ id: "PB_NEWTAB_FOCUS_PROMO",
+ type: "default",
+ template: "pb_newtab",
+ groups: ["pbNewtab"],
+ content: {
+ infoBody: "fluent:about-private-browsing-info-description-simplified",
+ infoEnabled: true,
+ infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg",
+ infoLinkText: "fluent:about-private-browsing-learn-more-link",
+ infoTitle: "",
+ infoTitleEnabled: false,
+ promoEnabled: true,
+ promoType: "FOCUS",
+ promoHeader: "fluent:about-private-browsing-focus-promo-header-c",
+ promoImageLarge: "chrome://browser/content/assets/focus-promo.png",
+ promoLinkText: "fluent:about-private-browsing-focus-promo-cta",
+ promoLinkType: "button",
+ promoSectionStyle: "below-search",
+ promoTitle: "fluent:about-private-browsing-focus-promo-text-c",
+ promoTitleEnabled: true,
+ promoButton: {
+ action: {
+ type: "SHOW_SPOTLIGHT",
+ data: {
+ content: {
+ id: "FOCUS_PROMO",
+ template: "multistage",
+ modal: "tab",
+ backdrop: "transparent",
+ screens: [
+ {
+ id: "DEFAULT_MODAL_UI",
+ content: {
+ logo: {
+ imageURL:
+ "chrome://browser/content/assets/focus-logo.svg",
+ height: "48px",
+ },
+ title: {
+ string_id: "spotlight-focus-promo-title",
+ },
+ subtitle: {
+ string_id: "spotlight-focus-promo-subtitle",
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ ios: {
+ action: {
+ data: {
+ args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fus%2Fapp%2Ffirefox-focus-privacy-browser%2Fid1055677337",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ android: {
+ action: {
+ data: {
+ args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.focus",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ tiles: {
+ type: "mobile_downloads",
+ data: {
+ QR_code: {
+ image_url:
+ "chrome://browser/content/assets/focus-qr-code.svg",
+ alt_text: {
+ string_id: "spotlight-focus-promo-qr-code",
+ },
+ },
+ marketplace_buttons: ["ios", "android"],
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ priority: 2,
+ frequency: {
+ custom: [
+ {
+ cap: 3,
+ period: 604800000, // Max 3 per week
+ },
+ ],
+ lifetime: 12,
+ },
+ // Exclude the next 2 messages: 1) Klar for en 2) Klar for de
+ targeting:
+ "!(region in [ 'DE', 'AT', 'CH'] && localeLanguageCode == 'en') && localeLanguageCode != 'de'",
+ },
+ {
+ id: "PB_NEWTAB_KLAR_PROMO",
+ type: "default",
+ template: "pb_newtab",
+ groups: ["pbNewtab"],
+ content: {
+ infoBody: "fluent:about-private-browsing-info-description-simplified",
+ infoEnabled: true,
+ infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg",
+ infoLinkText: "fluent:about-private-browsing-learn-more-link",
+ infoTitle: "",
+ infoTitleEnabled: false,
+ promoEnabled: true,
+ promoType: "FOCUS",
+ promoHeader: "fluent:about-private-browsing-focus-promo-header-c",
+ promoImageLarge: "chrome://browser/content/assets/focus-promo.png",
+ promoLinkText: "Download Firefox Klar",
+ promoLinkType: "button",
+ promoSectionStyle: "below-search",
+ promoTitle:
+ "Firefox Klar clears your history every time while blocking ads and trackers.",
+ promoTitleEnabled: true,
+ promoButton: {
+ action: {
+ type: "SHOW_SPOTLIGHT",
+ data: {
+ content: {
+ id: "KLAR_PROMO",
+ template: "multistage",
+ modal: "tab",
+ backdrop: "transparent",
+ screens: [
+ {
+ id: "DEFAULT_MODAL_UI",
+ order: 0,
+ content: {
+ logo: {
+ imageURL:
+ "chrome://browser/content/assets/focus-logo.svg",
+ height: "48px",
+ },
+ title: "Get Firefox Klar",
+ subtitle: {
+ string_id: "spotlight-focus-promo-subtitle",
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ ios: {
+ action: {
+ data: {
+ args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fde%2Fapp%2Fklar-by-firefox%2Fid1073435754",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ android: {
+ action: {
+ data: {
+ args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.klar",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ tiles: {
+ type: "mobile_downloads",
+ data: {
+ QR_code: {
+ image_url:
+ "chrome://browser/content/assets/klar-qr-code.svg",
+ alt_text: "Scan the QR code to get Firefox Klar",
+ },
+ marketplace_buttons: ["ios", "android"],
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ priority: 2,
+ frequency: {
+ custom: [
+ {
+ cap: 3,
+ period: 604800000, // Max 3 per week
+ },
+ ],
+ lifetime: 12,
+ },
+ targeting: "region in [ 'DE', 'AT', 'CH'] && localeLanguageCode == 'en'",
+ },
+ {
+ id: "PB_NEWTAB_KLAR_PROMO_DE",
+ type: "default",
+ template: "pb_newtab",
+ groups: ["pbNewtab"],
+ content: {
+ infoBody: "fluent:about-private-browsing-info-description-simplified",
+ infoEnabled: true,
+ infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg",
+ infoLinkText: "fluent:about-private-browsing-learn-more-link",
+ infoTitle: "",
+ infoTitleEnabled: false,
+ promoEnabled: true,
+ promoType: "FOCUS",
+ promoHeader: "fluent:about-private-browsing-focus-promo-header-c",
+ promoImageLarge: "chrome://browser/content/assets/focus-promo.png",
+ promoLinkText: "fluent:about-private-browsing-focus-promo-cta",
+ promoLinkType: "button",
+ promoSectionStyle: "below-search",
+ promoTitle: "fluent:about-private-browsing-focus-promo-text-c",
+ promoTitleEnabled: true,
+ promoButton: {
+ action: {
+ type: "SHOW_SPOTLIGHT",
+ data: {
+ content: {
+ id: "FOCUS_PROMO",
+ template: "multistage",
+ modal: "tab",
+ backdrop: "transparent",
+ screens: [
+ {
+ id: "DEFAULT_MODAL_UI",
+ content: {
+ logo: {
+ imageURL:
+ "chrome://browser/content/assets/focus-logo.svg",
+ height: "48px",
+ },
+ title: {
+ string_id: "spotlight-focus-promo-title",
+ },
+ subtitle: {
+ string_id: "spotlight-focus-promo-subtitle",
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ ios: {
+ action: {
+ data: {
+ args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fde%2Fapp%2Fklar-by-firefox%2Fid1073435754",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ android: {
+ action: {
+ data: {
+ args: "https://app.adjust.com/a8bxj8j?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.klar",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ tiles: {
+ type: "mobile_downloads",
+ data: {
+ QR_code: {
+ image_url:
+ "chrome://browser/content/assets/klar-qr-code.svg",
+ alt_text: {
+ string_id: "spotlight-focus-promo-qr-code",
+ },
+ },
+ marketplace_buttons: ["ios", "android"],
+ },
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ priority: 2,
+ frequency: {
+ custom: [
+ {
+ cap: 3,
+ period: 604800000, // Max 3 per week
+ },
+ ],
+ lifetime: 12,
+ },
+ targeting: "localeLanguageCode == 'de'",
+ },
+ {
+ id: "PB_NEWTAB_PIN_PROMO",
+ template: "pb_newtab",
+ type: "default",
+ groups: ["pbNewtab"],
+ content: {
+ infoBody: "fluent:about-private-browsing-info-description-simplified",
+ infoEnabled: true,
+ infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg",
+ infoLinkText: "fluent:about-private-browsing-learn-more-link",
+ infoTitle: "",
+ infoTitleEnabled: false,
+ promoEnabled: true,
+ promoType: "PIN",
+ promoHeader: "fluent:about-private-browsing-pin-promo-header",
+ promoImageLarge:
+ "chrome://browser/content/assets/private-promo-asset.svg",
+ promoLinkText: "fluent:about-private-browsing-pin-promo-link-text",
+ promoLinkType: "button",
+ promoSectionStyle: "below-search",
+ promoTitle: "fluent:about-private-browsing-pin-promo-title",
+ promoTitleEnabled: true,
+ promoButton: {
+ action: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "browser.privateWindowSeparation.enabled",
+ value: true,
+ },
+ },
+ },
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ data: {
+ privatePin: true,
+ },
+ },
+ {
+ type: "BLOCK_MESSAGE",
+ data: {
+ id: "PB_NEWTAB_PIN_PROMO",
+ },
+ },
+ {
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "privatebrowsing", where: "current" },
+ },
+ ],
+ },
+ },
+ },
+ },
+ priority: 3,
+ frequency: {
+ custom: [
+ {
+ cap: 3,
+ period: 604800000, // Max 3 per week
+ },
+ ],
+ lifetime: 12,
+ },
+ targeting: "!inMr2022Holdback && doesAppNeedPrivatePin",
+ },
+ {
+ id: "PB_NEWTAB_COOKIE_BANNERS_PROMO",
+ template: "pb_newtab",
+ type: "default",
+ groups: ["pbNewtab"],
+ content: {
+ infoBody: "fluent:about-private-browsing-info-description-simplified",
+ infoEnabled: true,
+ infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg",
+ infoLinkText: "fluent:about-private-browsing-learn-more-link",
+ infoTitle: "",
+ infoTitleEnabled: false,
+ promoEnabled: true,
+ promoType: "COOKIE_BANNERS",
+ promoHeader: "fluent:about-private-browsing-cookie-banners-promo-heading",
+ promoImageLarge:
+ "chrome://browser/content/assets/cookie-banners-begone.svg",
+ promoLinkText: "fluent:about-private-browsing-learn-more-link",
+ promoLinkType: "link",
+ promoSectionStyle: "below-search",
+ promoTitle: "fluent:about-private-browsing-cookie-banners-promo-body",
+ promoTitleEnabled: true,
+ promoButton: {
+ action: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "OPEN_URL",
+ data: {
+ args: "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/cookie-banner-reduction",
+ where: "tabshifted",
+ },
+ },
+ {
+ type: "BLOCK_MESSAGE",
+ data: {
+ id: "PB_NEWTAB_COOKIE_BANNERS_PROMO",
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ priority: 4,
+ frequency: {
+ custom: [
+ {
+ cap: 3,
+ period: 604800000, // Max 3 per week
+ },
+ ],
+ lifetime: 12,
+ },
+ targeting: `'cookiebanners.service.mode.privateBrowsing'|preferenceValue != 0 || 'cookiebanners.service.mode'|preferenceValue != 0`,
+ },
+ {
+ id: "INFOBAR_LAUNCH_ON_LOGIN",
+ groups: ["cfr"],
+ template: "infobar",
+ content: {
+ type: "global",
+ text: {
+ string_id: "launch-on-login-infobar-message",
+ },
+ buttons: [
+ {
+ label: {
+ string_id: "launch-on-login-learnmore",
+ },
+ supportPage: "make-firefox-automatically-open-when-you-start",
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: { string_id: "launch-on-login-infobar-reject-button" },
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: { string_id: "launch-on-login-infobar-confirm-button" },
+ primary: true,
+ action: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt",
+ value: true,
+ },
+ },
+ },
+ {
+ type: "CONFIRM_LAUNCH_ON_LOGIN",
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ frequency: {
+ lifetime: 1,
+ },
+ trigger: { id: "defaultBrowserCheck" },
+ targeting: `source == 'newtab'
+ && 'browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt'|preferenceValue == false
+ && 'browser.startup.windowsLaunchOnLogin.enabled'|preferenceValue == true && isDefaultBrowser && !activeNotifications
+ && !launchOnLoginEnabled`,
+ },
+ {
+ id: "INFOBAR_LAUNCH_ON_LOGIN_FINAL",
+ groups: ["cfr"],
+ template: "infobar",
+ content: {
+ type: "global",
+ text: {
+ string_id: "launch-on-login-infobar-final-message",
+ },
+ buttons: [
+ {
+ label: {
+ string_id: "launch-on-login-learnmore",
+ },
+ supportPage: "make-firefox-automatically-open-when-you-start",
+ action: {
+ type: "CANCEL",
+ },
+ },
+ {
+ label: { string_id: "launch-on-login-infobar-final-reject-button" },
+ action: {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt",
+ value: true,
+ },
+ },
+ },
+ },
+ {
+ label: { string_id: "launch-on-login-infobar-confirm-button" },
+ primary: true,
+ action: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt",
+ value: true,
+ },
+ },
+ },
+ {
+ type: "CONFIRM_LAUNCH_ON_LOGIN",
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ frequency: {
+ lifetime: 1,
+ },
+ trigger: { id: "defaultBrowserCheck" },
+ targeting: `source == 'newtab'
+ && 'browser.startup.windowsLaunchOnLogin.disableLaunchOnLoginPrompt'|preferenceValue == false
+ && 'browser.startup.windowsLaunchOnLogin.enabled'|preferenceValue == true && isDefaultBrowser && !activeNotifications
+ && messageImpressions.INFOBAR_LAUNCH_ON_LOGIN[messageImpressions.INFOBAR_LAUNCH_ON_LOGIN | length - 1]
+ && messageImpressions.INFOBAR_LAUNCH_ON_LOGIN[messageImpressions.INFOBAR_LAUNCH_ON_LOGIN | length - 1] <
+ currentDate|date - ${FOURTEEN_DAYS_IN_MS}
+ && !launchOnLoginEnabled`,
+ },
+ {
+ id: "FOX_DOODLE_SET_DEFAULT",
+ template: "spotlight",
+ groups: ["eco"],
+ skip_in_tests: "it fails unrelated tests",
+ content: {
+ backdrop: "transparent",
+ id: "FOX_DOODLE_SET_DEFAULT",
+ screens: [
+ {
+ id: "FOX_DOODLE_SET_DEFAULT_SCREEN",
+ content: {
+ logo: {
+ height: "125px",
+ imageURL:
+ "chrome://activity-stream/content/data/content/assets/fox-doodle-waving.gif",
+ reducedMotionImageURL:
+ "chrome://activity-stream/content/data/content/assets/fox-doodle-waving-static.png",
+ },
+ title: {
+ fontSize: "22px",
+ fontWeight: 590,
+ letterSpacing: 0,
+ paddingInline: "24px",
+ paddingBlock: "4px 0",
+ string_id: "fox-doodle-pin-headline",
+ },
+ subtitle: {
+ fontSize: "15px",
+ letterSpacing: 0,
+ lineHeight: "1.4",
+ marginBlock: "8px 16px",
+ paddingInline: "24px",
+ string_id: "fox-doodle-pin-body",
+ },
+ primary_button: {
+ action: {
+ navigate: true,
+ type: "SET_DEFAULT_BROWSER",
+ },
+ label: {
+ paddingBlock: "0",
+ paddingInline: "16px",
+ marginBlock: "4px 0",
+ string_id: "fox-doodle-pin-primary",
+ },
+ },
+ secondary_button: {
+ action: {
+ navigate: true,
+ },
+ label: {
+ marginBlock: "0 -20px",
+ string_id: "fox-doodle-pin-secondary",
+ },
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ template: "multistage",
+ transitions: true,
+ },
+ frequency: {
+ lifetime: 2,
+ },
+ targeting:
+ "source == 'startup' && !isMajorUpgrade && !activeNotifications && !isDefaultBrowser && !willShowDefaultPrompt && (currentDate|date - profileAgeCreated|date) / 86400000 >= 28 && userPrefs.cfrFeatures == true",
+ trigger: {
+ id: "defaultBrowserCheck",
+ },
+ },
+ {
+ id: "TAIL_FOX_SET_DEFAULT",
+ template: "spotlight",
+ groups: ["eco"],
+ skip_in_tests: "it fails unrelated tests",
+ content: {
+ backdrop: "transparent",
+ id: "TAIL_FOX_SET_DEFAULT_CONTENT",
+ screens: [
+ {
+ id: "TAIL_FOX_SET_DEFAULT_SCREEN",
+ content: {
+ logo: {
+ height: "140px",
+ imageURL:
+ "chrome://activity-stream/content/data/content/assets/fox-doodle-tail.png",
+ reducedMotionImageURL:
+ "chrome://activity-stream/content/data/content/assets/fox-doodle-tail.png",
+ },
+ title: {
+ fontSize: "22px",
+ fontWeight: 590,
+ letterSpacing: 0,
+ paddingInline: "24px",
+ paddingBlock: "4px 0",
+ string_id: "tail-fox-spotlight-title",
+ },
+ subtitle: {
+ fontSize: "15px",
+ letterSpacing: 0,
+ lineHeight: "1.4",
+ marginBlock: "8px 16px",
+ paddingInline: "24px",
+ string_id: "tail-fox-spotlight-subtitle",
+ },
+ primary_button: {
+ action: {
+ navigate: true,
+ type: "SET_DEFAULT_BROWSER",
+ },
+ label: {
+ paddingBlock: "0",
+ paddingInline: "16px",
+ marginBlock: "4px 0",
+ string_id: "tail-fox-spotlight-primary-button",
+ },
+ },
+ secondary_button: {
+ action: {
+ navigate: true,
+ },
+ label: {
+ marginBlock: "0 -20px",
+ string_id: "tail-fox-spotlight-secondary-button",
+ },
+ },
+ dismiss_button: {
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ template: "multistage",
+ transitions: true,
+ },
+ frequency: {
+ lifetime: 1,
+ },
+ targeting:
+ "source == 'startup' && !isMajorUpgrade && !activeNotifications && !isDefaultBrowser && !willShowDefaultPrompt && (currentDate|date - profileAgeCreated|date) / 86400000 <= 28 && (currentDate|date - profileAgeCreated|date) / 86400000 >= 7 && userPrefs.cfrFeatures == true",
+ trigger: {
+ id: "defaultBrowserCheck",
+ },
+ },
+];
+
+// Eventually, move Feature Callout messages to their own provider
+const ONBOARDING_MESSAGES = () =>
+ BASE_MESSAGES().concat(FeatureCalloutMessages.getMessages());
+
+export const OnboardingMessageProvider = {
+ async getExtraAttributes() {
+ const [header, button_label] = await L10N.formatMessages([
+ { id: "onboarding-welcome-header" },
+ { id: "onboarding-start-browsing-button-label" },
+ ]);
+ return { header: header.value, button_label: button_label.value };
+ },
+ async getMessages() {
+ const messages = await this.translateMessages(await ONBOARDING_MESSAGES());
+ return messages;
+ },
+ async getUntranslatedMessages() {
+ // This is helpful for jsonSchema testing - since we are localizing in the provider
+ const messages = await ONBOARDING_MESSAGES();
+ return messages;
+ },
+ async translateMessages(messages) {
+ let translatedMessages = [];
+ for (const msg of messages) {
+ let translatedMessage = { ...msg };
+
+ // If the message has no content, do not attempt to translate it
+ if (!translatedMessage.content) {
+ translatedMessages.push(translatedMessage);
+ continue;
+ }
+
+ // Translate any secondary buttons separately
+ if (msg.content.secondary_button) {
+ const [secondary_button_string] = await L10N.formatMessages([
+ { id: msg.content.secondary_button.label.string_id },
+ ]);
+ translatedMessage.content.secondary_button.label =
+ secondary_button_string.value;
+ }
+ if (msg.content.header) {
+ const [header_string] = await L10N.formatMessages([
+ { id: msg.content.header.string_id },
+ ]);
+ translatedMessage.content.header = header_string.value;
+ }
+ translatedMessages.push(translatedMessage);
+ }
+ return translatedMessages;
+ },
+ async _doesAppNeedPin(privateBrowsing = false) {
+ const needPin = await lazy.ShellService.doesAppNeedPin(privateBrowsing);
+ return needPin;
+ },
+ async _doesAppNeedDefault() {
+ let checkDefault = Services.prefs.getBoolPref(
+ "browser.shell.checkDefaultBrowser",
+ false
+ );
+ let isDefault = await lazy.ShellService.isDefaultBrowser();
+ return checkDefault && !isDefault;
+ },
+ _shouldShowPrivacySegmentationScreen() {
+ // Fall back to pref: browser.privacySegmentation.preferences.show
+ return lazy.NimbusFeatures.majorRelease2022.getVariable(
+ "feltPrivacyShowPreferencesSection"
+ );
+ },
+ _doesHomepageNeedReset() {
+ return (
+ Services.prefs.prefHasUserValue(HOMEPAGE_PREF) ||
+ Services.prefs.prefHasUserValue(NEWTAB_PREF)
+ );
+ },
+
+ async getUpgradeMessage() {
+ let message = (await OnboardingMessageProvider.getMessages()).find(
+ ({ id }) => id === "FX_MR_106_UPGRADE"
+ );
+
+ let { content } = message;
+ // Helper to find screens and remove them where applicable.
+ function removeScreens(check) {
+ const { screens } = content;
+ for (let i = 0; i < screens?.length; i++) {
+ if (check(screens[i])) {
+ screens.splice(i--, 1);
+ }
+ }
+ }
+
+ // Helper to prepare mobile download screen content
+ function prepareMobileDownload() {
+ let mobileContent = content.screens.find(
+ screen => screen.id === "UPGRADE_MOBILE_DOWNLOAD"
+ )?.content;
+
+ if (!mobileContent) {
+ return;
+ }
+ if (!lazy.BrowserUtils.sendToDeviceEmailsSupported()) {
+ // If send to device emails are not supported for a user's locale,
+ // remove the send to device link and update the screen text
+ delete mobileContent.cta_paragraph.action;
+ mobileContent.cta_paragraph.text = {
+ string_id: "mr2022-onboarding-no-mobile-download-cta-text",
+ };
+ }
+ // Update CN specific QRCode url
+ if (AppConstants.isChinaRepack()) {
+ mobileContent.hero_image.url = `${mobileContent.hero_image.url.slice(
+ 0,
+ mobileContent.hero_image.url.indexOf(".svg")
+ )}-cn.svg`;
+ }
+ }
+
+ let pinScreen = content.screens?.find(
+ screen => screen.id === "UPGRADE_PIN_FIREFOX"
+ );
+ const needPin = await this._doesAppNeedPin();
+ const needDefault = await this._doesAppNeedDefault();
+ const needPrivatePin =
+ !lazy.hidePrivatePin && (await this._doesAppNeedPin(true));
+ const showSegmentation = this._shouldShowPrivacySegmentationScreen();
+
+ //If a user has Firefox as default remove import screen
+ if (!needDefault) {
+ removeScreens(screen =>
+ screen.id?.startsWith("UPGRADE_IMPORT_SETTINGS_EMBEDDED")
+ );
+ }
+
+ // If already pinned, convert "pin" screen to "welcome" with desired action.
+ let removeDefault = !needDefault;
+ // If user doesn't need pin, update screen to set "default" or "get started" configuration
+ if (!needPin && pinScreen) {
+ // don't need to show the checkbox
+ delete pinScreen.content.checkbox;
+
+ removeDefault = true;
+ let primary = pinScreen.content.primary_button;
+ if (needDefault) {
+ pinScreen.id = "UPGRADE_ONLY_DEFAULT";
+ pinScreen.content.subtitle = {
+ string_id: "mr2022-onboarding-existing-set-default-only-subtitle",
+ };
+ primary.label.string_id =
+ "mr2022-onboarding-set-default-primary-button-label";
+
+ // The "pin" screen will now handle "default" so remove other "default."
+ primary.action.type = "SET_DEFAULT_BROWSER";
+ } else {
+ pinScreen.id = "UPGRADE_GET_STARTED";
+ pinScreen.content.subtitle = {
+ string_id: "mr2022-onboarding-get-started-primary-subtitle",
+ };
+ primary.label = {
+ string_id: "mr2022-onboarding-get-started-primary-button-label",
+ };
+ delete primary.action.type;
+ }
+ }
+
+ // If a user has Firefox private pinned remove pin private window screen
+ // We also remove standalone pin private window screen if a user doesn't have
+ // Firefox pinned in which case the option is shown as checkbox with UPGRADE_PIN_FIREFOX screen
+ if (!needPrivatePin || needPin) {
+ removeScreens(screen =>
+ screen.id?.startsWith("UPGRADE_PIN_PRIVATE_WINDOW")
+ );
+ }
+
+ if (!showSegmentation) {
+ removeScreens(screen =>
+ screen.id?.startsWith("UPGRADE_DATA_RECOMMENDATION")
+ );
+ }
+
+ //If privatePin, remove checkbox from pinscreen
+ if (!needPrivatePin) {
+ delete content.screens?.find(
+ screen => screen.id === "UPGRADE_PIN_FIREFOX"
+ )?.content?.checkbox;
+ }
+
+ if (removeDefault) {
+ removeScreens(screen => screen.id?.startsWith("UPGRADE_SET_DEFAULT"));
+ }
+
+ // Remove mobile download screen if user has sync enabled
+ if (lazy.usesFirefoxSync && lazy.mobileDevices > 0) {
+ removeScreens(screen => screen.id === "UPGRADE_MOBILE_DOWNLOAD");
+ } else {
+ prepareMobileDownload();
+ }
+
+ return message;
+ },
+};
diff --git a/browser/components/asrouter/modules/PageEventManager.sys.mjs b/browser/components/asrouter/modules/PageEventManager.sys.mjs
new file mode 100644
index 0000000000..44f1293385
--- /dev/null
+++ b/browser/components/asrouter/modules/PageEventManager.sys.mjs
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Methods for setting up and tearing down page event listeners. These are used
+ * to dismiss Feature Callouts when the callout's anchor element is clicked.
+ */
+export class PageEventManager {
+ /**
+ * A set of parameters defining a page event listener.
+ * @typedef {Object} PageEventListenerParams
+ * @property {String} type Event type string e.g. `click`
+ * @property {String} [selectors] Target selector, e.g. `tag.class, #id[attr]`
+ * @property {PageEventListenerOptions} [options] addEventListener options
+ *
+ * @typedef {Object} PageEventListenerOptions
+ * @property {Boolean} [capture] Use event capturing phase?
+ * @property {Boolean} [once] Remove listener after first event?
+ * @property {Boolean} [preventDefault] Inverted value for `passive` option
+ * @property {Number} [interval] Used only for `timeout` and `interval` event
+ * types. These don't set up real event listeners, but instead invoke the
+ * action on a timer.
+ *
+ * @typedef {Object} PageEventListener
+ * @property {Function} callback Function to call when event is triggered
+ * @property {AbortController} controller Handle for aborting the listener
+ *
+ * @typedef {Object} PageEvent
+ * @property {String} type Event type string e.g. `click`
+ * @property {Element} [target] Event target
+ */
+
+ /**
+ * Maps event listener params to their PageEventListeners, so they can be
+ * called and cancelled.
+ * @type {Map<PageEventListenerParams, PageEventListener>}
+ */
+ _listeners = new Map();
+
+ /**
+ * @param {Window} win Window containing the document to listen to
+ */
+ constructor(win) {
+ this.win = win;
+ this.doc = win.document;
+ }
+
+ /**
+ * Adds a page event listener.
+ * @param {PageEventListenerParams} params
+ * @param {Function} callback Function to call when event is triggered
+ */
+ on(params, callback) {
+ if (this._listeners.has(params)) {
+ return;
+ }
+ const { type, selectors, options = {} } = params;
+ const listener = { callback };
+ if (selectors) {
+ const controller = new AbortController();
+ const opt = {
+ capture: !!options.capture,
+ passive: !options.preventDefault,
+ signal: controller.signal,
+ };
+ const targets = this.doc.querySelectorAll(selectors);
+ for (const target of targets) {
+ target.addEventListener(type, callback, opt);
+ }
+ listener.controller = controller;
+ } else if (["timeout", "interval"].includes(type) && options.interval) {
+ let interval;
+ const abort = () => this.win.clearInterval(interval);
+ const onInterval = () => {
+ callback({ type, target: type });
+ if (type === "timeout") {
+ abort();
+ }
+ };
+ interval = this.win.setInterval(onInterval, options.interval);
+ listener.callback = onInterval;
+ listener.controller = { abort };
+ }
+ this._listeners.set(params, listener);
+ }
+
+ /**
+ * Removes a page event listener.
+ * @param {PageEventListenerParams} params
+ */
+ off(params) {
+ const listener = this._listeners.get(params);
+ if (!listener) {
+ return;
+ }
+ listener.controller?.abort();
+ this._listeners.delete(params);
+ }
+
+ /**
+ * Adds a page event listener that is removed after the first event.
+ * @param {PageEventListenerParams} params
+ * @param {Function} callback Function to call when event is triggered
+ */
+ once(params, callback) {
+ const wrappedCallback = (...args) => {
+ this.off(params);
+ callback(...args);
+ };
+ this.on(params, wrappedCallback);
+ }
+
+ /**
+ * Removes all page event listeners.
+ */
+ clear() {
+ for (const listener of this._listeners.values()) {
+ listener.controller?.abort();
+ }
+ this._listeners.clear();
+ }
+
+ /**
+ * Calls matching page event listeners. A way to dispatch a "fake" event.
+ * @param {PageEvent} event
+ */
+ emit(event) {
+ for (const [params, listener] of this._listeners) {
+ if (params.type === event.type) {
+ listener.callback(event);
+ }
+ }
+ }
+}
diff --git a/browser/components/asrouter/modules/PanelTestProvider.sys.mjs b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs
new file mode 100644
index 0000000000..7a7ff1e1fc
--- /dev/null
+++ b/browser/components/asrouter/modules/PanelTestProvider.sys.mjs
@@ -0,0 +1,771 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const TWO_DAYS = 2 * 24 * 3600 * 1000;
+
+const MESSAGES = () => [
+ {
+ id: "WNP_THANK_YOU",
+ template: "update_action",
+ content: {
+ action: {
+ id: "moments-wnp",
+ data: {
+ url: "https://www.mozilla.org/%LOCALE%/etc/firefox/retention/thank-you-a/",
+ expireDelta: TWO_DAYS,
+ },
+ },
+ },
+ trigger: { id: "momentsUpdate" },
+ },
+ {
+ id: "WHATS_NEW_FINGERPRINTER_COUNTER_ALT",
+ template: "whatsnew_panel_message",
+ order: 6,
+ content: {
+ bucket_id: "WHATS_NEW_72",
+ published_date: 1574776601000,
+ title: "Title",
+ icon_url:
+ "chrome://activity-stream/content/data/content/assets/protection-report-icon.png",
+ icon_alt: { string_id: "cfr-badge-reader-label-newfeature" },
+ body: "Message body",
+ link_text: "Click here",
+ cta_url: "about:blank",
+ cta_type: "OPEN_PROTECTION_REPORT",
+ },
+ targeting: `firefoxVersion >= 72`,
+ trigger: { id: "whatsNewPanelOpened" },
+ },
+ {
+ id: "WHATS_NEW_70_1",
+ template: "whatsnew_panel_message",
+ order: 3,
+ content: {
+ bucket_id: "WHATS_NEW_70_1",
+ published_date: 1560969794394,
+ title: "Protection Is Our Focus",
+ icon_url:
+ "chrome://activity-stream/content/data/content/assets/whatsnew-send-icon.png",
+ icon_alt: "Firefox Send Logo",
+ body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
+ cta_url: "https://blog.mozilla.org/",
+ cta_type: "OPEN_URL",
+ },
+ targeting: `firefoxVersion > 69`,
+ trigger: { id: "whatsNewPanelOpened" },
+ },
+ {
+ id: "WHATS_NEW_70_2",
+ template: "whatsnew_panel_message",
+ order: 1,
+ content: {
+ bucket_id: "WHATS_NEW_70_1",
+ published_date: 1560969794394,
+ title: "Another thing new in Firefox 70",
+ body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
+ link_text: "Learn more on our blog",
+ cta_url: "https://blog.mozilla.org/",
+ cta_type: "OPEN_URL",
+ },
+ targeting: `firefoxVersion > 69`,
+ trigger: { id: "whatsNewPanelOpened" },
+ },
+ {
+ id: "WHATS_NEW_SEARCH_SHORTCUTS_84",
+ template: "whatsnew_panel_message",
+ order: 2,
+ content: {
+ bucket_id: "WHATS_NEW_SEARCH_SHORTCUTS_84",
+ published_date: 1560969794394,
+ title: "Title",
+ icon_url: "chrome://global/skin/icons/check.svg",
+ icon_alt: "",
+ body: "Message content",
+ cta_url:
+ "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/search-shortcuts",
+ cta_type: "OPEN_URL",
+ link_text: "Click here",
+ },
+ targeting: "firefoxVersion >= 84",
+ trigger: {
+ id: "whatsNewPanelOpened",
+ },
+ },
+ {
+ id: "WHATS_NEW_PIONEER_82",
+ template: "whatsnew_panel_message",
+ order: 1,
+ content: {
+ bucket_id: "WHATS_NEW_PIONEER_82",
+ published_date: 1603152000000,
+ title: "Put your data to work for a better internet",
+ body: "Contribute your data to Mozilla's Pioneer program to help researchers understand pressing technology issues like misinformation, data privacy, and ethical AI.",
+ cta_url: "about:blank",
+ cta_where: "tab",
+ cta_type: "OPEN_ABOUT_PAGE",
+ link_text: "Join Pioneer",
+ },
+ targeting: "firefoxVersion >= 82",
+ trigger: {
+ id: "whatsNewPanelOpened",
+ },
+ },
+ {
+ id: "WHATS_NEW_MEDIA_SESSION_82",
+ template: "whatsnew_panel_message",
+ order: 3,
+ content: {
+ bucket_id: "WHATS_NEW_MEDIA_SESSION_82",
+ published_date: 1603152000000,
+ title: "Title",
+ body: "Message content",
+ cta_url:
+ "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/media-keyboard-control",
+ cta_type: "OPEN_URL",
+ link_text: "Click here",
+ },
+ targeting: "firefoxVersion >= 82",
+ trigger: {
+ id: "whatsNewPanelOpened",
+ },
+ },
+ {
+ id: "WHATS_NEW_69_1",
+ template: "whatsnew_panel_message",
+ order: 1,
+ content: {
+ bucket_id: "WHATS_NEW_69_1",
+ published_date: 1557346235089,
+ title: "Something new in Firefox 69",
+ body: "The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
+ link_text: "Learn more on our blog",
+ cta_url: "https://blog.mozilla.org/",
+ cta_type: "OPEN_URL",
+ },
+ targeting: `firefoxVersion > 68`,
+ trigger: { id: "whatsNewPanelOpened" },
+ },
+ {
+ id: "PERSONALIZED_CFR_MESSAGE",
+ template: "cfr_doorhanger",
+ groups: ["cfr"],
+ content: {
+ layout: "icon_and_message",
+ category: "cfrFeatures",
+ bucket_id: "PERSONALIZED_CFR_MESSAGE",
+ notification_text: "Personalized CFR Recommendation",
+ heading_text: { string_id: "cfr-doorhanger-bookmark-fxa-header" },
+ info_icon: {
+ label: {
+ attributes: {
+ tooltiptext: { string_id: "cfr-doorhanger-fxa-close-btn-tooltip" },
+ },
+ },
+ sumo_path: "https://example.com",
+ },
+ text: { string_id: "cfr-doorhanger-bookmark-fxa-body" },
+ icon: "chrome://branding/content/icon64.png",
+ icon_class: "cfr-doorhanger-large-icon",
+ persistent_doorhanger: true,
+ buttons: {
+ primary: {
+ label: { string_id: "cfr-doorhanger-milestone-ok-button" },
+ action: {
+ type: "OPEN_URL",
+ data: {
+ args: "https://send.firefox.com/login/?utm_source=activity-stream&entrypoint=activity-stream-cfr-pdf",
+ where: "tabshifted",
+ },
+ },
+ },
+ secondary: [
+ {
+ label: { string_id: "cfr-doorhanger-extension-cancel-button" },
+ action: { type: "CANCEL" },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-never-show-recommendation",
+ },
+ },
+ {
+ label: {
+ string_id: "cfr-doorhanger-extension-manage-settings-button",
+ },
+ action: {
+ type: "OPEN_PREFERENCES_PAGE",
+ data: { category: "general-cfrfeatures" },
+ },
+ },
+ ],
+ },
+ },
+ targeting: "scores.PERSONALIZED_CFR_MESSAGE.score > scoreThreshold",
+ trigger: {
+ id: "openURL",
+ patterns: ["*://*/*.pdf"],
+ },
+ },
+ {
+ id: "MULTISTAGE_SPOTLIGHT_MESSAGE",
+ groups: ["panel-test-provider"],
+ template: "spotlight",
+ content: {
+ id: "MULTISTAGE_SPOTLIGHT_MESSAGE",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: true,
+ screens: [
+ {
+ id: "AW_PIN_FIREFOX",
+ content: {
+ has_noodles: true,
+ title: {
+ string_id: "onboarding-easy-setup-security-and-privacy-title",
+ },
+ logo: {
+ imageURL: "chrome://browser/content/callout-tab-pickup.svg",
+ darkModeImageURL:
+ "chrome://browser/content/callout-tab-pickup-dark.svg",
+ reducedMotionImageURL:
+ "chrome://activity-stream/content/data/content/assets/glyph-pin-16.svg",
+ darkModeReducedMotionImageURL:
+ "chrome://activity-stream/content/data/content/assets/firefox.svg",
+ alt: "sample alt text",
+ },
+ hero_text: {
+ string_id: "fx100-thank-you-hero-text",
+ },
+ primary_button: {
+ label: {
+ string_id: "mr2022-onboarding-pin-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "onboarding-not-now-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ dismiss_button: {
+ action: {
+ dismiss: true,
+ },
+ },
+ },
+ },
+ {
+ id: "AW_SET_DEFAULT",
+ content: {
+ has_noodles: true,
+ logo: {
+ imageURL: "chrome://browser/content/logos/vpn-promo-logo.svg",
+ height: "100px",
+ },
+ title: {
+ fontSize: "36px",
+ fontWeight: 276,
+ string_id: "mr2022-onboarding-set-default-title",
+ },
+ subtitle: {
+ string_id: "mr2022-onboarding-set-default-subtitle",
+ },
+ primary_button: {
+ label: {
+ string_id: "mr2022-onboarding-set-default-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ type: "SET_DEFAULT_BROWSER",
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "onboarding-not-now-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ {
+ id: "BACKGROUND_IMAGE",
+ content: {
+ background: "#000",
+ text_color: "light",
+ progress_bar: true,
+ logo: {
+ imageURL:
+ "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif",
+ },
+ title: "A dialog with a background",
+ subtitle:
+ "The text color is configurable and a progress bar style step indicator is used",
+ primary_button: {
+ label: "Continue",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "onboarding-not-now-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ {
+ id: "BACKGROUND_COLOR",
+ content: {
+ background: "white",
+ progress_bar: true,
+ logo: {
+ height: "200px",
+ imageURL: "",
+ },
+ title: {
+ fontSize: "36px",
+ fontWeight: 276,
+ raw: "Peace of mind.",
+ },
+ title_style: "fancy shine",
+ text_color: "dark",
+ subtitle: "Using progress bar style step indicator",
+ primary_button: {
+ label: "Continue",
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "onboarding-not-now-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+ },
+ frequency: { lifetime: 3 },
+ trigger: { id: "defaultBrowserCheck" },
+ },
+ {
+ id: "PB_FOCUS_PROMO",
+ groups: ["panel-test-provider"],
+ template: "spotlight",
+ content: {
+ template: "multistage",
+ backdrop: "transparent",
+ screens: [
+ {
+ id: "PBM_FIREFOX_FOCUS",
+ order: 0,
+ content: {
+ logo: {
+ imageURL: "chrome://browser/content/assets/focus-logo.svg",
+ height: "48px",
+ },
+ title: {
+ string_id: "spotlight-focus-promo-title",
+ },
+ subtitle: {
+ string_id: "spotlight-focus-promo-subtitle",
+ },
+ dismiss_button: {
+ action: {
+ dismiss: true,
+ },
+ },
+ ios: {
+ action: {
+ data: {
+ args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fapps.apple.com%2Fus%2Fapp%2Ffirefox-focus-privacy-browser%2Fid1055677337",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ android: {
+ action: {
+ data: {
+ args: "https://app.adjust.com/167k4ih?campaign=firefox-desktop&adgroup=pb&creative=focus-omc172&redirect=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dorg.mozilla.focus",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ email_link: {
+ action: {
+ data: {
+ args: "https://mozilla.org",
+ where: "tabshifted",
+ },
+ type: "OPEN_URL",
+ navigate: true,
+ },
+ },
+ tiles: {
+ type: "mobile_downloads",
+ data: {
+ QR_code: {
+ image_url:
+ "chrome://browser/content/assets/focus-qr-code.svg",
+ alt_text: {
+ string_id: "spotlight-focus-promo-qr-code",
+ },
+ },
+ email: {
+ link_text: "Email yourself a link",
+ },
+ marketplace_buttons: ["ios", "android"],
+ },
+ },
+ },
+ },
+ ],
+ },
+ trigger: { id: "defaultBrowserCheck" },
+ },
+ {
+ id: "PB_NEWTAB_VPN_PROMO",
+ template: "pb_newtab",
+ content: {
+ promoEnabled: true,
+ promoType: "VPN",
+ infoEnabled: true,
+ infoBody: "fluent:about-private-browsing-info-description-private-window",
+ infoLinkText: "fluent:about-private-browsing-learn-more-link",
+ infoTitleEnabled: false,
+ promoLinkType: "button",
+ promoLinkText: "fluent:about-private-browsing-prominent-cta",
+ promoSectionStyle: "below-search",
+ promoHeader: "fluent:about-private-browsing-get-privacy",
+ promoTitle: "fluent:about-private-browsing-hide-activity-1",
+ promoTitleEnabled: true,
+ promoImageLarge: "chrome://browser/content/assets/moz-vpn.svg",
+ promoButton: {
+ action: {
+ type: "OPEN_URL",
+ data: {
+ args: "https://vpn.mozilla.org/",
+ },
+ },
+ },
+ },
+ groups: ["panel-test-provider"],
+ targeting: "region != 'CN' && !hasActiveEnterprisePolicies",
+ frequency: { lifetime: 3 },
+ },
+ {
+ id: "PB_PIN_PROMO",
+ template: "pb_newtab",
+ groups: ["pbNewtab"],
+ content: {
+ infoBody: "fluent:about-private-browsing-info-description-simplified",
+ infoEnabled: true,
+ infoIcon: "chrome://global/skin/icons/indicator-private-browsing.svg",
+ infoLinkText: "fluent:about-private-browsing-learn-more-link",
+ infoTitle: "",
+ infoTitleEnabled: false,
+ promoEnabled: true,
+ promoType: "PIN",
+ promoHeader: "Private browsing freedom in one click",
+ promoImageLarge:
+ "chrome://browser/content/assets/private-promo-asset.svg",
+ promoLinkText: "Pin To Taskbar",
+ promoLinkType: "button",
+ promoSectionStyle: "below-search",
+ promoTitle:
+ "No saved cookies or history, right from your desktop. Browse like no one’s watching.",
+ promoTitleEnabled: true,
+ promoButton: {
+ action: {
+ type: "MULTI_ACTION",
+ data: {
+ actions: [
+ {
+ type: "SET_PREF",
+ data: {
+ pref: {
+ name: "browser.privateWindowSeparation.enabled",
+ value: true,
+ },
+ },
+ },
+ {
+ type: "PIN_FIREFOX_TO_TASKBAR",
+ },
+ {
+ type: "BLOCK_MESSAGE",
+ data: {
+ id: "PB_PIN_PROMO",
+ },
+ },
+ {
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "privatebrowsing", where: "current" },
+ },
+ ],
+ },
+ },
+ },
+ },
+ priority: 3,
+ frequency: {
+ custom: [
+ {
+ cap: 3,
+ period: 604800000, // Max 3 per week
+ },
+ ],
+ lifetime: 12,
+ },
+ targeting:
+ "region != 'CN' && !hasActiveEnterprisePolicies && doesAppNeedPin",
+ },
+ {
+ id: "TEST_TOAST_NOTIFICATION1",
+ weight: 100,
+ template: "toast_notification",
+ content: {
+ title: {
+ string_id: "cfr-doorhanger-bookmark-fxa-header",
+ },
+ body: "Body",
+ image_url:
+ "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif",
+ launch_url: "https://mozilla.org",
+ requireInteraction: true,
+ actions: [
+ {
+ action: "dismiss",
+ title: "Dismiss",
+ windowsSystemActivationType: true,
+ },
+ {
+ action: "snooze",
+ title: "Snooze",
+ windowsSystemActivationType: true,
+ },
+ { action: "callback", title: "Callback" },
+ ],
+ tag: "test_toast_notification",
+ },
+ groups: ["panel-test-provider"],
+ targeting: "!hasActiveEnterprisePolicies",
+ trigger: { id: "backgroundTaskMessage" },
+ frequency: { lifetime: 3 },
+ },
+ {
+ id: "TEST_TOAST_NOTIFICATION2",
+ weight: 100,
+ template: "toast_notification",
+ content: {
+ title: "Launch action on toast click and on action button click",
+ body: "Body",
+ image_url:
+ "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/a3c640c8-7594-4bb2-bc18-8b4744f3aaf2.gif",
+ launch_action: {
+ type: "OPEN_URL",
+ data: { args: "https://mozilla.org", where: "window" },
+ },
+ requireInteraction: true,
+ actions: [
+ {
+ action: "dismiss",
+ title: "Dismiss",
+ windowsSystemActivationType: true,
+ },
+ {
+ action: "snooze",
+ title: "Snooze",
+ windowsSystemActivationType: true,
+ },
+ {
+ action: "private",
+ title: "Private Window",
+ launch_action: { type: "OPEN_PRIVATE_BROWSER_WINDOW" },
+ },
+ ],
+ tag: "test_toast_notification",
+ },
+ groups: ["panel-test-provider"],
+ targeting: "!hasActiveEnterprisePolicies",
+ trigger: { id: "backgroundTaskMessage" },
+ frequency: { lifetime: 3 },
+ },
+ {
+ id: "MR2022_BACKGROUND_UPDATE_TOAST_NOTIFICATION",
+ weight: 100,
+ template: "toast_notification",
+ content: {
+ title: {
+ string_id: "mr2022-background-update-toast-title",
+ },
+ body: {
+ string_id: "mr2022-background-update-toast-text",
+ },
+ image_url:
+ "https://firefox-settings-attachments.cdn.mozilla.net/main-workspace/ms-images/673d2808-e5d8-41b9-957e-f60d53233b97.png",
+ requireInteraction: true,
+ actions: [
+ {
+ action: "open",
+ title: {
+ string_id: "mr2022-background-update-toast-primary-button-label",
+ },
+ },
+ {
+ action: "snooze",
+ windowsSystemActivationType: true,
+ title: {
+ string_id: "mr2022-background-update-toast-secondary-button-label",
+ },
+ },
+ ],
+ tag: "mr2022_background_update",
+ },
+ groups: ["panel-test-provider"],
+ targeting: "!hasActiveEnterprisePolicies",
+ trigger: { id: "backgroundTaskMessage" },
+ frequency: { lifetime: 3 },
+ },
+ {
+ id: "IMPORT_SETTINGS_EMBEDDED",
+ groups: ["panel-test-provider"],
+ template: "spotlight",
+ content: {
+ template: "multistage",
+ backdrop: "transparent",
+ screens: [
+ {
+ id: "IMPORT_SETTINGS_EMBEDDED",
+ content: {
+ logo: {},
+ tiles: { type: "migration-wizard" },
+ progress_bar: true,
+ migrate_start: {
+ action: {},
+ },
+ migrate_close: {
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "mr2022-onboarding-secondary-skip-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ has_arrow_icon: true,
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ id: "TEST_FEATURE_TOUR",
+ template: "feature_callout",
+ groups: [],
+ content: {
+ id: "TEST_FEATURE_TOUR",
+ template: "multistage",
+ backdrop: "transparent",
+ transitions: false,
+ disableHistoryUpdates: true,
+ screens: [
+ {
+ id: "FEATURE_CALLOUT_1",
+ anchors: [
+ {
+ selector: "#PanelUI-menu-button",
+ panel_position: {
+ anchor_attachment: "bottomcenter",
+ callout_attachment: "topright",
+ },
+ },
+ ],
+ content: {
+ position: "callout",
+ title: { raw: "Panel Feature Callout" },
+ subtitle: { raw: "Hello!" },
+ secondary_button: {
+ label: { raw: "Advance" },
+ action: { navigate: true },
+ },
+ submenu_button: {
+ submenu: [
+ {
+ type: "action",
+ label: { raw: "Item 1" },
+ action: { navigate: true },
+ id: "item1",
+ },
+ {
+ type: "action",
+ label: { raw: "Item 2" },
+ action: { navigate: true },
+ id: "item2",
+ },
+ {
+ type: "menu",
+ label: { raw: "Menu 1" },
+ submenu: [
+ {
+ type: "action",
+ label: { raw: "Item 3" },
+ action: { navigate: true },
+ id: "item3",
+ },
+ {
+ type: "action",
+ label: { raw: "Item 4" },
+ action: { navigate: true },
+ id: "item4",
+ },
+ ],
+ id: "menu1",
+ },
+ ],
+ attached_to: "secondary_button",
+ },
+ dismiss_button: {
+ action: { dismiss: true },
+ },
+ },
+ },
+ ],
+ },
+ },
+];
+
+export const PanelTestProvider = {
+ getMessages() {
+ return Promise.resolve(
+ MESSAGES().map(message => ({
+ ...message,
+ targeting: `providerCohorts.panel_local_testing == "SHOW_TEST"`,
+ }))
+ );
+ },
+};
diff --git a/browser/components/asrouter/modules/RemoteL10n.sys.mjs b/browser/components/asrouter/modules/RemoteL10n.sys.mjs
new file mode 100644
index 0000000000..1df10fbd72
--- /dev/null
+++ b/browser/components/asrouter/modules/RemoteL10n.sys.mjs
@@ -0,0 +1,249 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * The downloaded Fluent file is located in this sub-directory of the local
+ * profile directory.
+ */
+const USE_REMOTE_L10N_PREF =
+ "browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
+
+/**
+ * All supported locales for remote l10n
+ *
+ * This is used by ASRouter.sys.mjs to check if the locale is supported before
+ * issuing the request for remote fluent files to RemoteSettings.
+ *
+ * Note:
+ * * this is generated based on "browser/locales/all-locales" as l10n doesn't
+ * provide an API to fetch that list
+ *
+ * * this list doesn't include "en-US", though "en-US" is well supported and
+ * `_RemoteL10n.isLocaleSupported()` will handle it properly
+ */
+const ALL_LOCALES = new Set([
+ "ach",
+ "af",
+ "an",
+ "ar",
+ "ast",
+ "az",
+ "be",
+ "bg",
+ "bn",
+ "bo",
+ "br",
+ "brx",
+ "bs",
+ "ca",
+ "ca-valencia",
+ "cak",
+ "ckb",
+ "cs",
+ "cy",
+ "da",
+ "de",
+ "dsb",
+ "el",
+ "en-CA",
+ "en-GB",
+ "eo",
+ "es-AR",
+ "es-CL",
+ "es-ES",
+ "es-MX",
+ "et",
+ "eu",
+ "fa",
+ "ff",
+ "fi",
+ "fr",
+ "fy-NL",
+ "ga-IE",
+ "gd",
+ "gl",
+ "gn",
+ "gu-IN",
+ "he",
+ "hi-IN",
+ "hr",
+ "hsb",
+ "hu",
+ "hy-AM",
+ "hye",
+ "ia",
+ "id",
+ "is",
+ "it",
+ "ja",
+ "ja-JP-mac",
+ "ka",
+ "kab",
+ "kk",
+ "km",
+ "kn",
+ "ko",
+ "lij",
+ "lo",
+ "lt",
+ "ltg",
+ "lv",
+ "meh",
+ "mk",
+ "mr",
+ "ms",
+ "my",
+ "nb-NO",
+ "ne-NP",
+ "nl",
+ "nn-NO",
+ "oc",
+ "pa-IN",
+ "pl",
+ "pt-BR",
+ "pt-PT",
+ "rm",
+ "ro",
+ "ru",
+ "scn",
+ "si",
+ "sk",
+ "sl",
+ "son",
+ "sq",
+ "sr",
+ "sv-SE",
+ "szl",
+ "ta",
+ "te",
+ "th",
+ "tl",
+ "tr",
+ "trs",
+ "uk",
+ "ur",
+ "uz",
+ "vi",
+ "wo",
+ "xh",
+ "zh-CN",
+ "zh-TW",
+]);
+
+export class _RemoteL10n {
+ constructor() {
+ this._l10n = null;
+ }
+
+ createElement(doc, elem, options = {}) {
+ let node;
+ if (options.content && options.content.string_id) {
+ node = doc.createElement("remote-text");
+ } else {
+ node = doc.createElementNS("http://www.w3.org/1999/xhtml", elem);
+ }
+ if (options.classList) {
+ node.classList.add(options.classList);
+ }
+ this.setString(node, options);
+
+ return node;
+ }
+
+ // If `string_id` is present it means we are relying on fluent for translations.
+ // Otherwise, we have a vanilla string.
+ setString(el, { content, attributes = {} }) {
+ if (content && content.string_id) {
+ for (let [fluentId, value] of Object.entries(attributes)) {
+ el.setAttribute(`fluent-variable-${fluentId}`, value);
+ }
+ el.setAttribute("fluent-remote-id", content.string_id);
+ } else {
+ el.textContent = content;
+ }
+ }
+
+ /**
+ * Creates a new DOMLocalization instance with the Fluent file from Remote Settings.
+ *
+ * Note: it will use the local Fluent file in any of following cases:
+ * * the remote Fluent file is not available
+ * * it was told to use the local Fluent file
+ */
+ _createDOML10n() {
+ /* istanbul ignore next */
+ let useRemoteL10n = Services.prefs.getBoolPref(USE_REMOTE_L10N_PREF, true);
+ if (useRemoteL10n && !L10nRegistry.getInstance().hasSource("cfr")) {
+ const appLocale = Services.locale.appLocaleAsBCP47;
+ const l10nFluentDir = PathUtils.join(
+ Services.dirsvc.get("ProfLD", Ci.nsIFile).path,
+ "settings",
+ "main",
+ "ms-language-packs"
+ );
+ let cfrIndexedFileSource = new L10nFileSource(
+ "cfr",
+ "app",
+ [appLocale],
+ `file://${l10nFluentDir}/`,
+ {
+ addResourceOptions: {
+ allowOverrides: true,
+ },
+ },
+ [`file://${l10nFluentDir}/browser/newtab/asrouter.ftl`]
+ );
+ L10nRegistry.getInstance().registerSources([cfrIndexedFileSource]);
+ } else if (!useRemoteL10n && L10nRegistry.getInstance().hasSource("cfr")) {
+ L10nRegistry.getInstance().removeSources(["cfr"]);
+ }
+
+ return new DOMLocalization(
+ [
+ "branding/brand.ftl",
+ "browser/defaultBrowserNotification.ftl",
+ "browser/newtab/asrouter.ftl",
+ "toolkit/branding/accounts.ftl",
+ "toolkit/branding/brandings.ftl",
+ ],
+ false
+ );
+ }
+
+ get l10n() {
+ if (!this._l10n) {
+ this._l10n = this._createDOML10n();
+ }
+ return this._l10n;
+ }
+
+ reloadL10n() {
+ this._l10n = null;
+ }
+
+ isLocaleSupported(locale) {
+ return locale === "en-US" || ALL_LOCALES.has(locale);
+ }
+
+ /**
+ * Format given `localizableText`.
+ *
+ * Format `localizableText` if it is an object using any `string_id` field,
+ * otherwise return `localizableText` unmodified.
+ *
+ * @param {object|string} `localizableText` to format.
+ * @return {string} formatted text.
+ */
+ async formatLocalizableText(localizableText) {
+ if (typeof localizableText !== "string") {
+ // It's more useful to get an error than passing through an object without
+ // a `string_id` field.
+ let value = await this.l10n.formatValue(localizableText.string_id);
+ return value;
+ }
+ return localizableText;
+ }
+}
+
+export const RemoteL10n = new _RemoteL10n();
diff --git a/browser/components/asrouter/modules/Spotlight.sys.mjs b/browser/components/asrouter/modules/Spotlight.sys.mjs
new file mode 100644
index 0000000000..65453a4397
--- /dev/null
+++ b/browser/components/asrouter/modules/Spotlight.sys.mjs
@@ -0,0 +1,78 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AboutWelcomeTelemetry:
+ "resource:///modules/aboutwelcome/AboutWelcomeTelemetry.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "AWTelemetry",
+ () => new lazy.AboutWelcomeTelemetry()
+);
+
+export const Spotlight = {
+ sendUserEventTelemetry(event, message, dispatch) {
+ const ping = {
+ message_id: message.content.id,
+ event,
+ };
+ dispatch({
+ type: "SPOTLIGHT_TELEMETRY",
+ data: { action: "spotlight_user_event", ...ping },
+ });
+ },
+
+ defaultDispatch(message) {
+ if (message.type === "SPOTLIGHT_TELEMETRY") {
+ const { message_id, event } = message.data;
+ lazy.AWTelemetry.sendTelemetry({ message_id, event });
+ }
+ },
+
+ /**
+ * Shows spotlight tab or window modal specific to the given browser
+ * @param browser The browser for spotlight display
+ * @param message Message containing content to show
+ * @param dispatchCFRAction A function to dispatch resulting actions
+ * @return boolean value capturing if spotlight was displayed
+ */
+ async showSpotlightDialog(browser, message, dispatch = this.defaultDispatch) {
+ const win = browser?.ownerGlobal;
+ if (!win || win.gDialogBox.isOpen) {
+ return false;
+ }
+ const spotlight_url = "chrome://browser/content/spotlight.html";
+
+ const dispatchCFRAction =
+ // This also blocks CFR impressions, which is fine for current use cases.
+ message.content?.metrics === "block" ? () => {} : dispatch;
+
+ // This handles `IMPRESSION` events used by ASRouter for frequency caps.
+ // AboutWelcome handles `IMPRESSION` events for telemetry.
+ this.sendUserEventTelemetry("IMPRESSION", message, dispatchCFRAction);
+ dispatchCFRAction({ type: "IMPRESSION", data: message });
+
+ if (message.content?.modal === "tab") {
+ let { closedPromise } = win.gBrowser.getTabDialogBox(browser).open(
+ spotlight_url,
+ {
+ features: "resizable=no",
+ allowDuplicateDialogs: false,
+ },
+ message.content
+ );
+ await closedPromise;
+ } else {
+ await win.gDialogBox.open(spotlight_url, message.content);
+ }
+
+ // If dismissed report telemetry and exit
+ this.sendUserEventTelemetry("DISMISS", message, dispatchCFRAction);
+ return true;
+ },
+};
diff --git a/browser/components/asrouter/modules/ToastNotification.sys.mjs b/browser/components/asrouter/modules/ToastNotification.sys.mjs
new file mode 100644
index 0000000000..136225cf61
--- /dev/null
+++ b/browser/components/asrouter/modules/ToastNotification.sys.mjs
@@ -0,0 +1,138 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetters(lazy, {
+ AlertsService: ["@mozilla.org/alerts-service;1", "nsIAlertsService"],
+});
+
+export const ToastNotification = {
+ // Allow testing to stub the alerts service.
+ get AlertsService() {
+ return lazy.AlertsService;
+ },
+
+ sendUserEventTelemetry(event, message, dispatch) {
+ const ping = {
+ message_id: message.id,
+ event,
+ };
+ dispatch({
+ type: "TOAST_NOTIFICATION_TELEMETRY",
+ data: { action: "toast_notification_user_event", ...ping },
+ });
+ },
+
+ /**
+ * Show a toast notification.
+ * @param message Message containing content to show.
+ * @param dispatch A function to dispatch resulting actions.
+ * @return boolean value capturing if toast notification was displayed.
+ */
+ async showToastNotification(message, dispatch) {
+ let { content } = message;
+ let title = await lazy.RemoteL10n.formatLocalizableText(content.title);
+ let body = await lazy.RemoteL10n.formatLocalizableText(content.body);
+
+ // The only link between background task message experiment and user
+ // re-engagement via the notification is the associated "tag". Said tag is
+ // usually controlled by the message content, but for message experiments,
+ // we want to avoid a missing tag and to ensure a deterministic tag for
+ // easier analysis, including across branches.
+ let { tag } = content;
+
+ let experimentMetadata =
+ lazy.ExperimentAPI.getExperimentMetaData({
+ featureId: "backgroundTaskMessage",
+ }) || {};
+
+ if (
+ experimentMetadata?.active &&
+ experimentMetadata?.slug &&
+ experimentMetadata?.branch?.slug
+ ) {
+ // Like `my-experiment:my-branch`.
+ tag = `${experimentMetadata?.slug}:${experimentMetadata?.branch?.slug}`;
+ }
+
+ // There are two events named `IMPRESSION` the first one refers to telemetry
+ // while the other refers to ASRouter impressions used for the frequency cap
+ this.sendUserEventTelemetry("IMPRESSION", message, dispatch);
+ dispatch({ type: "IMPRESSION", data: message });
+
+ let alert = Cc["@mozilla.org/alert-notification;1"].createInstance(
+ Ci.nsIAlertNotification
+ );
+ let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ alert.init(
+ tag,
+ content.image_url
+ ? Services.urlFormatter.formatURL(content.image_url)
+ : content.image_url,
+ title,
+ body,
+ true /* aTextClickable */,
+ content.data,
+ null /* aDir */,
+ null /* aLang */,
+ null /* aData */,
+ systemPrincipal,
+ null /* aInPrivateBrowsing */,
+ content.requireInteraction
+ );
+
+ if (content.actions) {
+ let actions = Cu.cloneInto(content.actions, {});
+ for (let action of actions) {
+ if (action.title) {
+ action.title = await lazy.RemoteL10n.formatLocalizableText(
+ action.title
+ );
+ }
+ if (action.launch_action) {
+ action.opaqueRelaunchData = JSON.stringify(action.launch_action);
+ delete action.launch_action;
+ }
+ }
+ alert.actions = actions;
+ }
+
+ // Populate `opaqueRelaunchData`, prefering `launch_action` if given,
+ // falling back to `launch_url` if given.
+ let relaunchAction = content.launch_action;
+ if (!relaunchAction && content.launch_url) {
+ relaunchAction = {
+ type: "OPEN_URL",
+ data: {
+ args: content.launch_url,
+ where: "tab",
+ },
+ };
+ }
+ if (relaunchAction) {
+ alert.opaqueRelaunchData = JSON.stringify(relaunchAction);
+ }
+
+ let shownPromise = Promise.withResolvers();
+ let obs = (subject, topic, data) => {
+ if (topic === "alertshow") {
+ shownPromise.resolve();
+ }
+ };
+
+ this.AlertsService.showAlert(alert, obs);
+
+ await shownPromise;
+
+ return true;
+ },
+};
diff --git a/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs b/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs
new file mode 100644
index 0000000000..7832ae9456
--- /dev/null
+++ b/browser/components/asrouter/modules/ToolbarBadgeHub.sys.mjs
@@ -0,0 +1,308 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ requestIdleCallback: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ ToolbarPanelHub: "resource:///modules/asrouter/ToolbarPanelHub.sys.mjs",
+});
+
+let notificationsByWindow = new WeakMap();
+
+export class _ToolbarBadgeHub {
+ constructor() {
+ this.id = "toolbar-badge-hub";
+ this.state = {};
+ this.prefs = {
+ WHATSNEW_TOOLBAR_PANEL: "browser.messaging-system.whatsNewPanel.enabled",
+ };
+ this.removeAllNotifications = this.removeAllNotifications.bind(this);
+ this.removeToolbarNotification = this.removeToolbarNotification.bind(this);
+ this.addToolbarNotification = this.addToolbarNotification.bind(this);
+ this.registerBadgeToAllWindows = this.registerBadgeToAllWindows.bind(this);
+ this._sendPing = this._sendPing.bind(this);
+ this.sendUserEventTelemetry = this.sendUserEventTelemetry.bind(this);
+
+ this._handleMessageRequest = null;
+ this._addImpression = null;
+ this._blockMessageById = null;
+ this._sendTelemetry = null;
+ this._initialized = false;
+ }
+
+ async init(
+ waitForInitialized,
+ {
+ handleMessageRequest,
+ addImpression,
+ blockMessageById,
+ unblockMessageById,
+ sendTelemetry,
+ }
+ ) {
+ if (this._initialized) {
+ return;
+ }
+
+ this._initialized = true;
+ this._handleMessageRequest = handleMessageRequest;
+ this._blockMessageById = blockMessageById;
+ this._unblockMessageById = unblockMessageById;
+ this._addImpression = addImpression;
+ this._sendTelemetry = sendTelemetry;
+ // Need to wait for ASRouter to initialize before trying to fetch messages
+ await waitForInitialized;
+ this.messageRequest({
+ triggerId: "toolbarBadgeUpdate",
+ template: "toolbar_badge",
+ });
+ // Listen for pref changes that could trigger new badges
+ Services.prefs.addObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this);
+ }
+
+ observe(aSubject, aTopic, aPrefName) {
+ switch (aPrefName) {
+ case this.prefs.WHATSNEW_TOOLBAR_PANEL:
+ this.messageRequest({
+ triggerId: "toolbarBadgeUpdate",
+ template: "toolbar_badge",
+ });
+ break;
+ }
+ }
+
+ maybeInsertFTL(win) {
+ win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
+ }
+
+ executeAction({ id, data, message_id }) {
+ switch (id) {
+ case "show-whatsnew-button":
+ lazy.ToolbarPanelHub.enableToolbarButton();
+ lazy.ToolbarPanelHub.enableAppmenuButton();
+ break;
+ }
+ }
+
+ _clearBadgeTimeout() {
+ if (this.state.showBadgeTimeoutId) {
+ lazy.clearTimeout(this.state.showBadgeTimeoutId);
+ }
+ }
+
+ removeAllNotifications(event) {
+ if (event) {
+ // ignore right clicks
+ if (
+ (event.type === "mousedown" || event.type === "click") &&
+ event.button !== 0
+ ) {
+ return;
+ }
+ // ignore keyboard access that is not one of the usual accessor keys
+ if (
+ event.type === "keypress" &&
+ event.key !== " " &&
+ event.key !== "Enter"
+ ) {
+ return;
+ }
+
+ event.target.removeEventListener(
+ "mousedown",
+ this.removeAllNotifications
+ );
+ event.target.removeEventListener("keypress", this.removeAllNotifications);
+ // If we have an event it means the user interacted with the badge
+ // we should send telemetry
+ if (this.state.notification) {
+ this.sendUserEventTelemetry("CLICK", this.state.notification);
+ }
+ }
+ // Will call uninit on every window
+ lazy.EveryWindow.unregisterCallback(this.id);
+ if (this.state.notification) {
+ this._blockMessageById(this.state.notification.id);
+ }
+ this._clearBadgeTimeout();
+ this.state = {};
+ }
+
+ removeToolbarNotification(toolbarButton) {
+ // Remove it from the element that displays the badge
+ toolbarButton
+ .querySelector(".toolbarbutton-badge")
+ .classList.remove("feature-callout");
+ toolbarButton.removeAttribute("badged");
+ // Remove id used for for aria-label badge description
+ const notificationDescription = toolbarButton.querySelector(
+ "#toolbarbutton-notification-description"
+ );
+ if (notificationDescription) {
+ notificationDescription.remove();
+ toolbarButton.removeAttribute("aria-labelledby");
+ toolbarButton.removeAttribute("aria-describedby");
+ }
+ }
+
+ addToolbarNotification(win, message) {
+ const document = win.browser.ownerDocument;
+ if (message.content.action) {
+ this.executeAction({ ...message.content.action, message_id: message.id });
+ }
+ let toolbarbutton = document.getElementById(message.content.target);
+ if (toolbarbutton) {
+ const badge = toolbarbutton.querySelector(".toolbarbutton-badge");
+ badge.classList.add("feature-callout");
+ toolbarbutton.setAttribute("badged", true);
+ // If we have additional aria-label information for the notification
+ // we add this content to the hidden `toolbarbutton-text` node.
+ // We then use `aria-labelledby` to link this description to the button
+ // that received the notification badge.
+ if (message.content.badgeDescription) {
+ // Insert strings as soon as we know we're showing them
+ this.maybeInsertFTL(win);
+ toolbarbutton.setAttribute(
+ "aria-labelledby",
+ `toolbarbutton-notification-description ${message.content.target}`
+ );
+ // Because tooltiptext is different to the label, it gets duplicated as
+ // the description. Setting `describedby` to the same value as
+ // `labelledby` will be detected by the a11y code and the description
+ // will be removed.
+ toolbarbutton.setAttribute(
+ "aria-describedby",
+ `toolbarbutton-notification-description ${message.content.target}`
+ );
+ const descriptionEl = document.createElement("span");
+ descriptionEl.setAttribute(
+ "id",
+ "toolbarbutton-notification-description"
+ );
+ descriptionEl.hidden = true;
+ document.l10n.setAttributes(
+ descriptionEl,
+ message.content.badgeDescription.string_id
+ );
+ toolbarbutton.appendChild(descriptionEl);
+ }
+ // `mousedown` event required because of the `onmousedown` defined on
+ // the button that prevents `click` events from firing
+ toolbarbutton.addEventListener("mousedown", this.removeAllNotifications);
+ // `keypress` event required for keyboard accessibility
+ toolbarbutton.addEventListener("keypress", this.removeAllNotifications);
+ this.state = { notification: { id: message.id } };
+
+ // Impression should be added when the badge becomes visible
+ this._addImpression(message);
+ // Send a telemetry ping when adding the notification badge
+ this.sendUserEventTelemetry("IMPRESSION", message);
+
+ return toolbarbutton;
+ }
+
+ return null;
+ }
+
+ registerBadgeToAllWindows(message) {
+ if (message.template === "update_action") {
+ this.executeAction({ ...message.content.action, message_id: message.id });
+ // No badge to set only an action to execute
+ return;
+ }
+
+ lazy.EveryWindow.registerCallback(
+ this.id,
+ win => {
+ if (notificationsByWindow.has(win)) {
+ // nothing to do
+ return;
+ }
+ const el = this.addToolbarNotification(win, message);
+ notificationsByWindow.set(win, el);
+ },
+ win => {
+ const el = notificationsByWindow.get(win);
+ if (el) {
+ this.removeToolbarNotification(el);
+ }
+ notificationsByWindow.delete(win);
+ }
+ );
+ }
+
+ registerBadgeNotificationListener(message, options = {}) {
+ // We need to clear any existing notifications and only show
+ // the one set by devtools
+ if (options.force) {
+ this.removeAllNotifications();
+ // When debugging immediately show the badge
+ this.registerBadgeToAllWindows(message);
+ return;
+ }
+
+ if (message.content.delay) {
+ this.state.showBadgeTimeoutId = lazy.setTimeout(() => {
+ lazy.requestIdleCallback(() => this.registerBadgeToAllWindows(message));
+ }, message.content.delay);
+ } else {
+ this.registerBadgeToAllWindows(message);
+ }
+ }
+
+ async messageRequest({ triggerId, template }) {
+ const telemetryObject = { triggerId };
+ TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
+ const message = await this._handleMessageRequest({
+ triggerId,
+ template,
+ });
+ TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
+ if (message) {
+ this.registerBadgeNotificationListener(message);
+ }
+ }
+
+ _sendPing(ping) {
+ this._sendTelemetry({
+ type: "TOOLBAR_BADGE_TELEMETRY",
+ data: { action: "badge_user_event", ...ping },
+ });
+ }
+
+ sendUserEventTelemetry(event, message) {
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
+ // Only send pings for non private browsing windows
+ if (
+ win &&
+ !lazy.PrivateBrowsingUtils.isBrowserPrivate(
+ win.ownerGlobal.gBrowser.selectedBrowser
+ )
+ ) {
+ this._sendPing({
+ message_id: message.id,
+ event,
+ });
+ }
+ }
+
+ uninit() {
+ this._clearBadgeTimeout();
+ this.state = {};
+ this._initialized = false;
+ notificationsByWindow = new WeakMap();
+ Services.prefs.removeObserver(this.prefs.WHATSNEW_TOOLBAR_PANEL, this);
+ }
+}
+
+/**
+ * ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate
+ * message requests and render messages.
+ */
+export const ToolbarBadgeHub = new _ToolbarBadgeHub();
diff --git a/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs b/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs
new file mode 100644
index 0000000000..519bca8a89
--- /dev/null
+++ b/browser/components/asrouter/modules/ToolbarPanelHub.sys.mjs
@@ -0,0 +1,544 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const lazy = {};
+
+// We use importESModule here instead of static import so that
+// the Karma test environment won't choke on this module. This
+// is because the Karma test environment already stubs out
+// XPCOMUtils. That environment overrides importESModule to be a no-op
+// (which can't be done for a static import statement).
+
+// eslint-disable-next-line mozilla/use-static-import
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ SpecialMessageActions:
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
+ RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "TrackingDBService",
+ "@mozilla.org/tracking-db-service;1",
+ "nsITrackingDBService"
+);
+
+const idToTextMap = new Map([
+ [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"],
+ [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"],
+ [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"],
+ [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"],
+ [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"],
+]);
+
+const WHATSNEW_ENABLED_PREF = "browser.messaging-system.whatsNewPanel.enabled";
+const PROTECTIONS_PANEL_INFOMSG_PREF =
+ "browser.protections_panel.infoMessage.seen";
+
+const TOOLBAR_BUTTON_ID = "whats-new-menu-button";
+const APPMENU_BUTTON_ID = "appMenu-whatsnew-button";
+
+const BUTTON_STRING_ID = "cfr-whatsnew-button";
+const WHATS_NEW_PANEL_SELECTOR = "PanelUI-whatsNew-message-container";
+
+export class _ToolbarPanelHub {
+ constructor() {
+ this.triggerId = "whatsNewPanelOpened";
+ this._showAppmenuButton = this._showAppmenuButton.bind(this);
+ this._hideAppmenuButton = this._hideAppmenuButton.bind(this);
+ this._showToolbarButton = this._showToolbarButton.bind(this);
+ this._hideToolbarButton = this._hideToolbarButton.bind(this);
+
+ this.state = {};
+ this._initialized = false;
+ }
+
+ async init(waitForInitialized, { getMessages, sendTelemetry }) {
+ if (this._initialized) {
+ return;
+ }
+
+ this._initialized = true;
+ this._getMessages = getMessages;
+ this._sendTelemetry = sendTelemetry;
+ // Wait for ASRouter messages to become available in order to know
+ // if we can show the What's New panel
+ await waitForInitialized;
+ // Enable the application menu button so that the user can access
+ // the panel outside of the toolbar button
+ await this.enableAppmenuButton();
+
+ this.state = {
+ protectionPanelMessageSeen: Services.prefs.getBoolPref(
+ PROTECTIONS_PANEL_INFOMSG_PREF,
+ false
+ ),
+ };
+ }
+
+ uninit() {
+ this._initialized = false;
+ lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID);
+ lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID);
+ }
+
+ get messages() {
+ return this._getMessages({
+ template: "whatsnew_panel_message",
+ triggerId: "whatsNewPanelOpened",
+ returnAll: true,
+ });
+ }
+
+ toggleWhatsNewPref(event) {
+ // Checkbox onclick handler gets called before the checkbox state gets toggled,
+ // so we have to call it with the opposite value.
+ let newValue = !event.target.checked;
+ Services.prefs.setBoolPref(WHATSNEW_ENABLED_PREF, newValue);
+
+ this.sendUserEventTelemetry(
+ event.target.ownerGlobal,
+ "WNP_PREF_TOGGLE",
+ // Message id is not applicable in this case, the notification state
+ // is not related to a particular message
+ { id: "n/a" },
+ { value: { prefValue: newValue } }
+ );
+ }
+
+ maybeInsertFTL(win) {
+ win.MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
+ win.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl");
+ win.MozXULElement.insertFTLIfNeeded("toolkit/branding/accounts.ftl");
+ }
+
+ maybeLoadCustomElement(win) {
+ if (!win.customElements.get("remote-text")) {
+ Services.scriptloader.loadSubScript(
+ "resource://activity-stream/data/custom-elements/paragraph.js",
+ win
+ );
+ }
+ }
+
+ // Turns on the Appmenu (hamburger menu) button for all open windows and future windows.
+ async enableAppmenuButton() {
+ if ((await this.messages).length) {
+ lazy.EveryWindow.registerCallback(
+ APPMENU_BUTTON_ID,
+ this._showAppmenuButton,
+ this._hideAppmenuButton
+ );
+ }
+ }
+
+ // Removes the button from the Appmenu.
+ // Only used in tests.
+ disableAppmenuButton() {
+ lazy.EveryWindow.unregisterCallback(APPMENU_BUTTON_ID);
+ }
+
+ // Turns on the Toolbar button for all open windows and future windows.
+ async enableToolbarButton() {
+ if ((await this.messages).length) {
+ lazy.EveryWindow.registerCallback(
+ TOOLBAR_BUTTON_ID,
+ this._showToolbarButton,
+ this._hideToolbarButton
+ );
+ }
+ }
+
+ // When the panel is hidden we want to run some cleanup
+ _onPanelHidden(win) {
+ const panelContainer = win.document.getElementById(
+ "customizationui-widget-panel"
+ );
+ // When the panel is hidden we want to remove any toolbar buttons that
+ // might have been added as an entry point to the panel
+ const removeToolbarButton = () => {
+ lazy.EveryWindow.unregisterCallback(TOOLBAR_BUTTON_ID);
+ };
+ if (!panelContainer) {
+ return;
+ }
+ panelContainer.addEventListener("popuphidden", removeToolbarButton, {
+ once: true,
+ });
+ }
+
+ // Newer messages first and use `order` field to decide between messages
+ // with the same timestamp
+ _sortWhatsNewMessages(m1, m2) {
+ // Sort by published_date in descending order.
+ if (m1.content.published_date === m2.content.published_date) {
+ // Ascending order
+ return m1.order - m2.order;
+ }
+ if (m1.content.published_date > m2.content.published_date) {
+ return -1;
+ }
+ return 1;
+ }
+
+ // Render what's new messages into the panel.
+ async renderMessages(win, doc, containerId, options = {}) {
+ // Set the checked status of the footer checkbox
+ let value = Services.prefs.getBoolPref(WHATSNEW_ENABLED_PREF);
+ let checkbox = win.document.getElementById("panelMenu-toggleWhatsNew");
+
+ checkbox.checked = value;
+
+ this.maybeLoadCustomElement(win);
+ const messages =
+ (options.force && options.messages) ||
+ (await this.messages).sort(this._sortWhatsNewMessages);
+ const container = lazy.PanelMultiView.getViewNode(doc, containerId);
+
+ if (messages) {
+ // Targeting attribute state might have changed making new messages
+ // available and old messages invalid, we need to refresh
+ this.removeMessages(win, containerId);
+ let previousDate = 0;
+ // Get and store any variable part of the message content
+ this.state.contentArguments = await this._contentArguments();
+ for (let message of messages) {
+ container.appendChild(
+ this._createMessageElements(win, doc, message, previousDate)
+ );
+ previousDate = message.content.published_date;
+ }
+ }
+
+ this._onPanelHidden(win);
+
+ // Panel impressions are not associated with one particular message
+ // but with a set of messages. We concatenate message ids and send them
+ // back for every impression.
+ const eventId = {
+ id: messages
+ .map(({ id }) => id)
+ .sort()
+ .join(","),
+ };
+ // Check `mainview` attribute to determine if the panel is shown as a
+ // subview (inside the application menu) or as a toolbar dropdown.
+ // https://searchfox.org/mozilla-central/rev/07f7390618692fa4f2a674a96b9b677df3a13450/browser/components/customizableui/PanelMultiView.jsm#1268
+ const mainview = win.PanelUI.whatsNewPanel.hasAttribute("mainview");
+ this.sendUserEventTelemetry(win, "IMPRESSION", eventId, {
+ value: { view: mainview ? "toolbar_dropdown" : "application_menu" },
+ });
+ }
+
+ removeMessages(win, containerId) {
+ const doc = win.document;
+ const messageNodes = lazy.PanelMultiView.getViewNode(
+ doc,
+ containerId
+ ).querySelectorAll(".whatsNew-message");
+ for (const messageNode of messageNodes) {
+ messageNode.remove();
+ }
+ }
+
+ /**
+ * Dispatch the action defined in the message and user telemetry event.
+ */
+ _dispatchUserAction(win, message) {
+ let url;
+ try {
+ // Set platform specific path variables for SUMO articles
+ url = Services.urlFormatter.formatURL(message.content.cta_url);
+ } catch (e) {
+ console.error(e);
+ url = message.content.cta_url;
+ }
+ lazy.SpecialMessageActions.handleAction(
+ {
+ type: message.content.cta_type,
+ data: {
+ args: url,
+ where: message.content.cta_where || "tabshifted",
+ },
+ },
+ win.browser
+ );
+
+ this.sendUserEventTelemetry(win, "CLICK", message);
+ }
+
+ /**
+ * Attach event listener to dispatch message defined action.
+ */
+ _attachCommandListener(win, element, message) {
+ // Add event listener for `mouseup` not to overlap with the
+ // `mousedown` & `click` events dispatched from PanelMultiView.sys.mjs
+ // https://searchfox.org/mozilla-central/rev/7531325c8660cfa61bf71725f83501028178cbb9/browser/components/customizableui/PanelMultiView.jsm#1830-1837
+ element.addEventListener("mouseup", () => {
+ this._dispatchUserAction(win, message);
+ });
+ element.addEventListener("keyup", e => {
+ if (e.key === "Enter" || e.key === " ") {
+ this._dispatchUserAction(win, message);
+ }
+ });
+ }
+
+ _createMessageElements(win, doc, message, previousDate) {
+ const { content } = message;
+ const messageEl = lazy.RemoteL10n.createElement(doc, "div");
+ messageEl.classList.add("whatsNew-message");
+
+ // Only render date if it is different from the one rendered before.
+ if (content.published_date !== previousDate) {
+ messageEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "p", {
+ classList: "whatsNew-message-date",
+ content: new Date(content.published_date).toLocaleDateString(
+ "default",
+ {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ }
+ ),
+ })
+ );
+ }
+
+ const wrapperEl = lazy.RemoteL10n.createElement(doc, "div");
+ wrapperEl.doCommand = () => this._dispatchUserAction(win, message);
+ wrapperEl.classList.add("whatsNew-message-body");
+ messageEl.appendChild(wrapperEl);
+
+ if (content.icon_url) {
+ wrapperEl.classList.add("has-icon");
+ const iconEl = lazy.RemoteL10n.createElement(doc, "img");
+ iconEl.src = content.icon_url;
+ iconEl.classList.add("whatsNew-message-icon");
+ if (content.icon_alt && content.icon_alt.string_id) {
+ doc.l10n.setAttributes(iconEl, content.icon_alt.string_id);
+ } else {
+ iconEl.setAttribute("alt", content.icon_alt);
+ }
+ wrapperEl.appendChild(iconEl);
+ }
+
+ wrapperEl.appendChild(this._createMessageContent(win, doc, content));
+
+ if (content.link_text) {
+ const anchorEl = lazy.RemoteL10n.createElement(doc, "a", {
+ classList: "text-link",
+ content: content.link_text,
+ });
+ anchorEl.doCommand = () => this._dispatchUserAction(win, message);
+ wrapperEl.appendChild(anchorEl);
+ }
+
+ // Attach event listener on entire message container
+ this._attachCommandListener(win, messageEl, message);
+
+ return messageEl;
+ }
+
+ /**
+ * Return message title (optional subtitle) and body
+ */
+ _createMessageContent(win, doc, content) {
+ const wrapperEl = new win.DocumentFragment();
+
+ wrapperEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "h2", {
+ classList: "whatsNew-message-title",
+ content: content.title,
+ attributes: this.state.contentArguments,
+ })
+ );
+
+ wrapperEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "p", {
+ content: content.body,
+ classList: "whatsNew-message-content",
+ attributes: this.state.contentArguments,
+ })
+ );
+
+ return wrapperEl;
+ }
+
+ _createHeroElement(win, doc, message) {
+ this.maybeLoadCustomElement(win);
+
+ const messageEl = lazy.RemoteL10n.createElement(doc, "div");
+ messageEl.setAttribute("id", "protections-popup-message");
+ messageEl.classList.add("whatsNew-hero-message");
+ const wrapperEl = lazy.RemoteL10n.createElement(doc, "div");
+ wrapperEl.classList.add("whatsNew-message-body");
+ messageEl.appendChild(wrapperEl);
+
+ wrapperEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "h2", {
+ classList: "whatsNew-message-title",
+ content: message.content.title,
+ })
+ );
+ wrapperEl.appendChild(
+ lazy.RemoteL10n.createElement(doc, "p", {
+ classList: "protections-popup-content",
+ content: message.content.body,
+ })
+ );
+
+ if (message.content.link_text) {
+ let linkEl = lazy.RemoteL10n.createElement(doc, "a", {
+ classList: "text-link",
+ content: message.content.link_text,
+ });
+ linkEl.disabled = true;
+ wrapperEl.appendChild(linkEl);
+ this._attachCommandListener(win, linkEl, message);
+ } else {
+ this._attachCommandListener(win, wrapperEl, message);
+ }
+
+ return messageEl;
+ }
+
+ async _contentArguments() {
+ const { defaultEngine } = Services.search;
+ // Between now and 6 weeks ago
+ const dateTo = new Date();
+ const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000);
+ const eventsByDate = await lazy.TrackingDBService.getEventsByDateRange(
+ dateFrom,
+ dateTo
+ );
+ // Make sure we set all types of possible values to 0 because they might
+ // be referenced by fluent strings
+ let totalEvents = { blockedCount: 0 };
+ for (let blockedType of idToTextMap.values()) {
+ totalEvents[blockedType] = 0;
+ }
+ // Count all events in the past 6 weeks. Returns an object with:
+ // `blockedCount` total number of blocked resources
+ // {tracker|cookie|social...} breakdown by event type as defined by `idToTextMap`
+ totalEvents = eventsByDate.reduce((acc, day) => {
+ const type = day.getResultByName("type");
+ const count = day.getResultByName("count");
+ acc[idToTextMap.get(type)] = (acc[idToTextMap.get(type)] || 0) + count;
+ acc.blockedCount += count;
+ return acc;
+ }, totalEvents);
+ return {
+ // Keys need to match variable names used in asrouter.ftl
+ // `earliestDate` will be either 6 weeks ago or when tracking recording
+ // started. Whichever is more recent.
+ earliestDate: Math.max(
+ new Date(await lazy.TrackingDBService.getEarliestRecordedDate()),
+ dateFrom
+ ),
+ ...totalEvents,
+ // Passing in `undefined` as string for the Fluent variable name
+ // in order to match and select the message that does not require
+ // the variable.
+ searchEngineName: defaultEngine ? defaultEngine.name : "undefined",
+ };
+ }
+
+ async _showAppmenuButton(win) {
+ this.maybeInsertFTL(win);
+ await this._showElement(
+ win.browser.ownerDocument,
+ APPMENU_BUTTON_ID,
+ BUTTON_STRING_ID
+ );
+ }
+
+ _hideAppmenuButton(win, windowClosed) {
+ // No need to do something if the window is going away
+ if (!windowClosed) {
+ this._hideElement(win.browser.ownerDocument, APPMENU_BUTTON_ID);
+ }
+ }
+
+ _showToolbarButton(win) {
+ const document = win.browser.ownerDocument;
+ this.maybeInsertFTL(win);
+ return this._showElement(document, TOOLBAR_BUTTON_ID, BUTTON_STRING_ID);
+ }
+
+ _hideToolbarButton(win) {
+ this._hideElement(win.browser.ownerDocument, TOOLBAR_BUTTON_ID);
+ }
+
+ _showElement(document, id, string_id) {
+ const el = lazy.PanelMultiView.getViewNode(document, id);
+ document.l10n.setAttributes(el, string_id);
+ el.hidden = false;
+ }
+
+ _hideElement(document, id) {
+ const el = lazy.PanelMultiView.getViewNode(document, id);
+ if (el) {
+ el.hidden = true;
+ }
+ }
+
+ _sendPing(ping) {
+ this._sendTelemetry({
+ type: "TOOLBAR_PANEL_TELEMETRY",
+ data: { action: "whats-new-panel_user_event", ...ping },
+ });
+ }
+
+ sendUserEventTelemetry(win, event, message, options = {}) {
+ // Only send pings for non private browsing windows
+ if (
+ win &&
+ !lazy.PrivateBrowsingUtils.isBrowserPrivate(
+ win.ownerGlobal.gBrowser.selectedBrowser
+ )
+ ) {
+ this._sendPing({
+ message_id: message.id,
+ event,
+ event_context: options.value,
+ });
+ }
+ }
+
+ /**
+ * @param {object} [browser] MessageChannel target argument as a response to a
+ * user action. No message is shown if undefined.
+ * @param {object[]} messages Messages selected from devtools page
+ */
+ forceShowMessage(browser, messages) {
+ if (!browser) {
+ return;
+ }
+ const win = browser.ownerGlobal;
+ const doc = browser.ownerDocument;
+ this.removeMessages(win, WHATS_NEW_PANEL_SELECTOR);
+ this.renderMessages(win, doc, WHATS_NEW_PANEL_SELECTOR, {
+ force: true,
+ messages: Array.isArray(messages) ? messages : [messages],
+ });
+ win.PanelUI.panel.addEventListener("popuphidden", event =>
+ this.removeMessages(event.target.ownerGlobal, WHATS_NEW_PANEL_SELECTOR)
+ );
+ }
+}
+
+/**
+ * ToolbarPanelHub - singleton instance of _ToolbarPanelHub that can initiate
+ * message requests and render messages.
+ */
+export const ToolbarPanelHub = new _ToolbarPanelHub();