tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

ASRouter.sys.mjs (83880B)


      1 /* vim: set ts=2 sw=2 sts=2 et tw=80: */
      2 /* This Source Code Form is subject to the terms of the Mozilla Public
      3 * License, v. 2.0. If a copy of the MPL was not distributed with this
      4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      5 
      6 // We use importESModule here instead of static import so that
      7 // the Karma test environment won't choke on this module. This
      8 // is because the Karma test environment already stubs out
      9 // XPCOMUtils, AppConstants and RemoteSettings, and overrides
     10 // importESModule to be a no-op (which can't be done for a static import
     11 // statement).
     12 
     13 // eslint-disable-next-line mozilla/use-static-import
     14 const { XPCOMUtils } = ChromeUtils.importESModule(
     15  "resource://gre/modules/XPCOMUtils.sys.mjs"
     16 );
     17 
     18 // eslint-disable-next-line mozilla/use-static-import
     19 const { AppConstants } = ChromeUtils.importESModule(
     20  "resource://gre/modules/AppConstants.sys.mjs"
     21 );
     22 
     23 // eslint-disable-next-line mozilla/use-static-import
     24 const { RemoteSettings } = ChromeUtils.importESModule(
     25  "resource://services-settings/remote-settings.sys.mjs"
     26 );
     27 
     28 const lazy = {};
     29 
     30 ChromeUtils.defineESModuleGetters(lazy, {
     31  MESSAGE_TYPE_HASH: "resource:///modules/asrouter/ActorConstants.mjs",
     32  ASRouterPreferences:
     33    "resource:///modules/asrouter/ASRouterPreferences.sys.mjs",
     34  ASRouterTargeting: "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
     35  ASRouterTriggerListeners:
     36    "resource:///modules/asrouter/ASRouterTriggerListeners.sys.mjs",
     37  AttributionCode:
     38    "moz-src:///browser/components/attribution/AttributionCode.sys.mjs",
     39  BookmarksBarButton: "resource:///modules/asrouter/BookmarksBarButton.sys.mjs",
     40  UnstoredDownloader: "resource://services-settings/Attachments.sys.mjs",
     41  ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
     42  FeatureCalloutBroker:
     43    "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs",
     44  InfoBar: "resource:///modules/asrouter/InfoBar.sys.mjs",
     45  KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs",
     46  MacAttribution:
     47    "moz-src:///browser/components/attribution/MacAttribution.sys.mjs",
     48  MenuMessage: "resource:///modules/asrouter/MenuMessage.sys.mjs",
     49  MomentsPageHub: "resource:///modules/asrouter/MomentsPageHub.sys.mjs",
     50  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
     51  PanelTestProvider: "resource:///modules/asrouter/PanelTestProvider.sys.mjs",
     52  RemoteL10n: "resource:///modules/asrouter/RemoteL10n.sys.mjs",
     53  SpecialMessageActions:
     54    "resource://messaging-system/lib/SpecialMessageActions.sys.mjs",
     55  TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
     56  TARGETING_PREFERENCES:
     57    "resource:///modules/asrouter/ASRouterPreferences.sys.mjs",
     58  Utils: "resource://services-settings/Utils.sys.mjs",
     59  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     60  Spotlight: "resource:///modules/asrouter/Spotlight.sys.mjs",
     61  ToastNotification: "resource:///modules/asrouter/ToastNotification.sys.mjs",
     62  ToolbarBadgeHub: "resource:///modules/asrouter/ToolbarBadgeHub.sys.mjs",
     63  AIWindow:
     64    "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs",
     65 });
     66 
     67 XPCOMUtils.defineLazyPreferenceGetter(
     68  lazy,
     69  "messagingProfileId",
     70  "messaging-system.profile.messagingProfileId",
     71  ""
     72 );
     73 
     74 XPCOMUtils.defineLazyPreferenceGetter(
     75  lazy,
     76  "disableSingleProfileMessaging",
     77  "messaging-system.profile.singleProfileMessaging.disable",
     78  false
     79 );
     80 
     81 XPCOMUtils.defineLazyServiceGetters(lazy, {
     82  BrowserHandler: ["@mozilla.org/browser/clh;1", Ci.nsIBrowserHandler],
     83 });
     84 import { MESSAGING_EXPERIMENTS_DEFAULT_FEATURES } from "resource:///modules/asrouter/MessagingExperimentConstants.sys.mjs";
     85 import { CFRMessageProvider } from "resource:///modules/asrouter/CFRMessageProvider.sys.mjs";
     86 import { OnboardingMessageProvider } from "resource:///modules/asrouter/OnboardingMessageProvider.sys.mjs";
     87 import { CFRPageActions } from "resource:///modules/asrouter/CFRPageActions.sys.mjs";
     88 
     89 // List of hosts for endpoints that serve router messages.
     90 // Key is allowed host, value is a name for the endpoint host.
     91 const DEFAULT_ALLOWLIST_HOSTS = {
     92  "activity-stream-icons.services.mozilla.com": "production",
     93 };
     94 // Max possible impressions cap for any message
     95 const MAX_MESSAGE_LIFETIME_CAP = 100;
     96 const SIX_MONTHS_MS = (60 * 60 * 24 * 365 * 1000) / 2; // six months in milliseconds
     97 
     98 const LOCAL_MESSAGE_PROVIDERS = {
     99  OnboardingMessageProvider,
    100  CFRMessageProvider,
    101 };
    102 const STARTPAGE_VERSION = "6";
    103 
    104 // Remote Settings
    105 const RS_MAIN_BUCKET = "main";
    106 const RS_COLLECTION_L10N = "ms-language-packs"; // "ms" stands for Messaging System
    107 const RS_PROVIDERS_WITH_L10N = ["cfr"];
    108 const RS_FLUENT_VERSION = "v1";
    109 const RS_FLUENT_RECORD_PREFIX = `cfr-${RS_FLUENT_VERSION}`;
    110 const RS_DOWNLOAD_MAX_RETRIES = 2;
    111 // This is the list of providers for which we want to cache the targeting
    112 // expression result and reuse between calls. Cache duration is defined in
    113 // ASRouterTargeting where evaluation takes place.
    114 const JEXL_PROVIDER_CACHE = new Set();
    115 
    116 // To observe the app locale change notification.
    117 const TOPIC_INTL_LOCALE_CHANGED = "intl:app-locales-changed";
    118 const TOPIC_EXPERIMENT_ENROLLMENT_CHANGED = "nimbus:enrollments-updated";
    119 // To observe the pref that controls if ASRouter should use the remote Fluent files for l10n.
    120 const USE_REMOTE_L10N_PREF =
    121  "browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
    122 
    123 const MULTIPROFILE_DATA_UPDATED = "sps-profiles-updated";
    124 
    125 // Reach for the pbNewtab feature will be added in bug 1755401
    126 const NO_REACH_EVENT_GROUPS = ["pbNewtab"];
    127 
    128 // Profile scope values to show a message with multi-profile feature
    129 const PROFILE_MESSAGE_SCOPE = {
    130  NONE: "",
    131  SINGLE: "single",
    132  SHARED: "shared",
    133 };
    134 
    135 export const MessageLoaderUtils = {
    136  STARTPAGE_VERSION,
    137  REMOTE_LOADER_CACHE_KEY: "RemoteLoaderCache",
    138  _errors: [],
    139 
    140  reportError(e) {
    141    console.error(e);
    142    this._errors.push({
    143      timestamp: new Date(),
    144      error: { message: e.toString(), stack: e.stack },
    145    });
    146  },
    147 
    148  get errors() {
    149    const errors = this._errors;
    150    this._errors = [];
    151    return errors;
    152  },
    153 
    154  /**
    155   * _localLoader - Loads messages for a local provider (i.e. one that lives in mozilla central)
    156   *
    157   * @param {obj} provider An AS router provider
    158   * @param {Array} provider.messages An array of messages
    159   * @returns {Array} the array of messages
    160   */
    161  _localLoader(provider) {
    162    return provider.messages;
    163  },
    164 
    165  async _remoteLoaderCache(storage) {
    166    let allCached;
    167    try {
    168      allCached =
    169        (await storage.get(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY)) || {};
    170    } catch (e) {
    171      // istanbul ignore next
    172      MessageLoaderUtils.reportError(e);
    173      // istanbul ignore next
    174      allCached = {};
    175    }
    176    return allCached;
    177  },
    178 
    179  /**
    180   * _remoteLoader - Loads messages for a remote provider
    181   *
    182   * @param {obj} provider An AS router provider
    183   * @param {string} provider.url An endpoint that returns an array of messages as JSON
    184   * @param {obj} options.storage A storage object with get() and set() methods for caching.
    185   * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
    186   */
    187  async _remoteLoader(provider, options) {
    188    let remoteMessages = [];
    189    if (provider.url) {
    190      const allCached = await MessageLoaderUtils._remoteLoaderCache(
    191        options.storage
    192      );
    193      const cached = allCached[provider.id];
    194      let etag;
    195 
    196      if (
    197        cached &&
    198        cached.url === provider.url &&
    199        cached.version === STARTPAGE_VERSION
    200      ) {
    201        const { lastFetched, messages } = cached;
    202        if (
    203          !MessageLoaderUtils.shouldProviderUpdate({
    204            ...provider,
    205            lastUpdated: lastFetched,
    206          })
    207        ) {
    208          // Cached messages haven't expired, return early.
    209          return messages;
    210        }
    211        etag = cached.etag;
    212        remoteMessages = messages;
    213      }
    214 
    215      let headers = new Headers();
    216      if (etag) {
    217        headers.set("If-None-Match", etag);
    218      }
    219 
    220      let response;
    221      try {
    222        response = await fetch(provider.url, {
    223          headers,
    224          credentials: "omit",
    225        });
    226      } catch (e) {
    227        MessageLoaderUtils.reportError(e);
    228      }
    229      if (
    230        response &&
    231        response.ok &&
    232        response.status >= 200 &&
    233        response.status < 400
    234      ) {
    235        let jsonResponse;
    236        try {
    237          jsonResponse = await response.json();
    238        } catch (e) {
    239          MessageLoaderUtils.reportError(e);
    240          return remoteMessages;
    241        }
    242        if (jsonResponse && jsonResponse.messages) {
    243          remoteMessages = jsonResponse.messages.map(msg => ({
    244            ...msg,
    245            provider_url: provider.url,
    246          }));
    247 
    248          // Cache the results if this isn't a preview URL.
    249          if (provider.updateCycleInMs > 0) {
    250            etag = response.headers.get("ETag");
    251            const cacheInfo = {
    252              messages: remoteMessages,
    253              etag,
    254              lastFetched: Date.now(),
    255              version: STARTPAGE_VERSION,
    256            };
    257 
    258            options.storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, {
    259              ...allCached,
    260              [provider.id]: cacheInfo,
    261            });
    262          }
    263        } else {
    264          MessageLoaderUtils.reportError(
    265            `No messages returned from ${provider.url}.`
    266          );
    267        }
    268      } else if (response) {
    269        MessageLoaderUtils.reportError(
    270          `Invalid response status ${response.status} from ${provider.url}.`
    271        );
    272      }
    273    }
    274    return remoteMessages;
    275  },
    276 
    277  /**
    278   * _remoteSettingsLoader - Loads messages for a RemoteSettings provider
    279   *
    280   * Note:
    281   * 1). The "cfr" provider requires the Fluent file for l10n, so there is
    282   * another file downloading phase for those two providers after their messages
    283   * are successfully fetched from Remote Settings. Currently, they share the same
    284   * attachment of the record "${RS_FLUENT_RECORD_PREFIX}-${locale}" in the
    285   * "ms-language-packs" collection. E.g. for "en-US" with version "v1",
    286   * the Fluent file is attched to the record with ID "cfr-v1-en-US".
    287   *
    288   * 2). To prevent duplicate downloads, we verify that the local file matches
    289   * the attachment on the Remote Settings record.
    290   *
    291   * @param {object} provider An AS router provider
    292   * @param {string} provider.id The id of the provider
    293   * @param {string} provider.collection Remote Settings collection name
    294   * @param {object} options
    295   * @param {function} options.dispatchCFRAction Action handler function
    296   * @returns {Promise<object[]>} Resolves with an array of messages, or an
    297   *                              empty array if none could be fetched
    298   */
    299  async _remoteSettingsLoader(provider, options) {
    300    let messages = [];
    301    if (provider.collection) {
    302      try {
    303        messages = await MessageLoaderUtils._getRemoteSettingsMessages(
    304          provider.collection
    305        );
    306        if (!messages.length) {
    307          MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
    308            "ASR_RS_NO_MESSAGES",
    309            provider.id,
    310            options.dispatchCFRAction
    311          );
    312        } else if (
    313          RS_PROVIDERS_WITH_L10N.includes(provider.id) &&
    314          lazy.RemoteL10n.isLocaleSupported(MessageLoaderUtils.locale)
    315        ) {
    316          const recordId = `${RS_FLUENT_RECORD_PREFIX}-${MessageLoaderUtils.locale}`;
    317          const kinto = new lazy.KintoHttpClient(lazy.Utils.SERVER_URL);
    318          const record = await kinto
    319            .bucket(RS_MAIN_BUCKET)
    320            .collection(RS_COLLECTION_L10N)
    321            .getRecord(recordId);
    322          if (record && record.data) {
    323            // Check that the file on disk is the same as the one on the server.
    324            // If the file is the same, we don't need to download it again.
    325            const localFile = lazy.RemoteL10n.cfrFluentFilePath;
    326            const { size: remoteSize } = record.data.attachment;
    327            if (
    328              !(await IOUtils.exists(localFile)) ||
    329              (await IOUtils.stat(localFile)).size !== remoteSize
    330            ) {
    331              // Here we are using the UnstoredDownloader to download the attachment
    332              // because we don't want to store it in the (default) IndexedDB cache.
    333              const downloader = new lazy.UnstoredDownloader(
    334                RS_MAIN_BUCKET,
    335                RS_COLLECTION_L10N
    336              );
    337              // Await here in order to capture the exceptions for reporting.
    338              const { buffer } = await downloader.download(record.data, {
    339                retries: RS_DOWNLOAD_MAX_RETRIES,
    340              });
    341              // Write on disk.
    342              await IOUtils.write(localFile, new Uint8Array(buffer), {
    343                tmpPath: `${localFile}.tmp`,
    344              });
    345            }
    346            lazy.RemoteL10n.reloadL10n();
    347          } else {
    348            MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
    349              "ASR_RS_NO_MESSAGES",
    350              RS_COLLECTION_L10N,
    351              options.dispatchCFRAction
    352            );
    353          }
    354        }
    355      } catch (e) {
    356        MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
    357          "ASR_RS_ERROR",
    358          provider.id,
    359          options.dispatchCFRAction
    360        );
    361        MessageLoaderUtils.reportError(e);
    362      }
    363    }
    364    return messages;
    365  },
    366 
    367  /**
    368   * Fetch messages from a given collection in Remote Settings.
    369   *
    370   * @param {string} collection The remote settings collection identifier
    371   * @returns {Promise<object[]>} Resolves with an array of messages
    372   */
    373  _getRemoteSettingsMessages(collection) {
    374    return RemoteSettings(collection).get();
    375  },
    376 
    377  /**
    378   * Return messages from active Nimbus experiments and rollouts.
    379   *
    380   * @param {object} provider A messaging experiments provider.
    381   * @param {string[]?} provider.featureIds
    382   *                    An optional array of Nimbus feature IDs to check for
    383   *                    enrollments. If not provided, we will fall back to the
    384   *                    set of default features. Otherwise, if provided and
    385   *                    empty, we will not ingest messages from any features.
    386   *
    387   * @return {object[]} The list of messages from active enrollments, as well as
    388   *                    the messages defined in unenrolled branches so that they
    389   *                    reach events can be recorded (if we record reach events
    390   *                    for that feature).
    391   */
    392  async _experimentsAPILoader(provider) {
    393    // Allow tests to override the set of featureIds
    394    const featureIds = Array.isArray(provider.featureIds)
    395      ? provider.featureIds
    396      : MESSAGING_EXPERIMENTS_DEFAULT_FEATURES;
    397    let experiments = [];
    398    for (const featureId of featureIds) {
    399      const featureAPI = lazy.NimbusFeatures[featureId];
    400      const enrollmentData = featureAPI.getEnrollmentMetadata();
    401 
    402      // We are not enrolled in any experiment or rollout for this feature, so
    403      // we can skip the feature.
    404      if (!enrollmentData) {
    405        continue;
    406      }
    407 
    408      const featureValue = featureAPI.getAllVariables();
    409 
    410      // If the value is a multi-message config, add each message in the
    411      // messages array. Cache the Nimbus feature ID on each message, because
    412      // there is not a 1-1 correspondance between templates and features.
    413      // This is used when recording expose events (see |sendTriggerMessage|).
    414      const messages =
    415        featureValue?.template === "multi" &&
    416        Array.isArray(featureValue.messages)
    417          ? featureValue.messages
    418          : [featureValue];
    419      for (const message of messages) {
    420        if (message?.id) {
    421          message._nimbusFeature = featureId;
    422          experiments.push(message);
    423        }
    424      }
    425 
    426      // Add Reach messages from unenrolled sibling branches, provided we are
    427      // recording Reach events for this feature. If we are in a rollout, we do
    428      // not have sibling branches.
    429      if (
    430        NO_REACH_EVENT_GROUPS.includes(featureId) ||
    431        !MESSAGING_EXPERIMENTS_DEFAULT_FEATURES.includes(featureId) ||
    432        enrollmentData.isRollout
    433      ) {
    434        continue;
    435      }
    436 
    437      // Check other sibling branches for triggers, add them to the return array
    438      // if found any. The `forReachEvent` label is used to identify those
    439      // branches so that they would only be used to record the Reach event.
    440      const branches =
    441        (await lazy.ExperimentAPI.getAllBranches(enrollmentData.slug)) || [];
    442      for (const branch of branches) {
    443        let branchValue = branch[featureId].value;
    444        if (!branchValue || branch.slug === enrollmentData.branch) {
    445          continue;
    446        }
    447        const branchMessages =
    448          branchValue?.template === "multi" &&
    449          Array.isArray(branchValue.messages)
    450            ? branchValue.messages
    451            : [branchValue];
    452        for (const message of branchMessages) {
    453          if (!message?.trigger) {
    454            continue;
    455          }
    456          experiments.push({
    457            forReachEvent: { sent: false, group: featureId },
    458            experimentSlug: enrollmentData.slug,
    459            branchSlug: branch.slug,
    460            ...message,
    461          });
    462        }
    463      }
    464    }
    465 
    466    return experiments;
    467  },
    468 
    469  _handleRemoteSettingsUndesiredEvent(event, providerId, dispatchCFRAction) {
    470    dispatchCFRAction?.({
    471      type: lazy.MESSAGE_TYPE_HASH.AS_ROUTER_TELEMETRY_USER_EVENT,
    472      data: {
    473        action: "asrouter_undesired_event",
    474        message_id: "n/a",
    475        event,
    476        event_context: providerId,
    477      },
    478    });
    479  },
    480 
    481  /**
    482   * _getMessageLoader - return the right loading function given the provider's type
    483   *
    484   * @param {obj} provider An AS Router provider
    485   * @returns {func} A loading function
    486   */
    487  _getMessageLoader(provider) {
    488    switch (provider.type) {
    489      case "remote":
    490        return this._remoteLoader;
    491      case "remote-settings":
    492        return this._remoteSettingsLoader;
    493      case "remote-experiments":
    494        return this._experimentsAPILoader;
    495      case "local":
    496      default:
    497        return this._localLoader;
    498    }
    499  },
    500 
    501  /**
    502   * shouldProviderUpdate - Given the current time, should a provider update its messages?
    503   *
    504   * @param {any} provider An AS Router provider
    505   * @param {int} provider.updateCycleInMs The number of milliseconds we should wait between updates
    506   * @param {Date} provider.lastUpdated If the provider has been updated, the time the last update occurred
    507   * @param {Date} currentTime The time we should check against. (defaults to Date.now())
    508   * @returns {bool} Should an update happen?
    509   */
    510  shouldProviderUpdate(provider, currentTime = Date.now()) {
    511    return (
    512      !(provider.lastUpdated >= 0) ||
    513      currentTime - provider.lastUpdated > provider.updateCycleInMs
    514    );
    515  },
    516 
    517  async _loadDataForProvider(provider, options) {
    518    const loader = this._getMessageLoader(provider);
    519    let messages = await loader(provider, options);
    520    // istanbul ignore if
    521    if (!messages) {
    522      messages = [];
    523      MessageLoaderUtils.reportError(
    524        new Error(
    525          `Tried to load messages for ${provider.id} but the result was not an Array.`
    526        )
    527      );
    528    }
    529 
    530    return { messages };
    531  },
    532 
    533  /**
    534   * loadMessagesForProvider - Load messages for a provider, given the provider's type.
    535   *
    536   * @param {obj} provider An AS Router provider
    537   * @param {string} provider.type An AS Router provider type (defaults to "local")
    538   * @param {obj} options.storage A storage object with get() and set() methods for caching.
    539   * @param {func} options.dispatchCFRAction dispatch an action the main AS Store
    540   * @returns {obj} Returns an object with .messages (an array of messages) and .lastUpdated (the time the messages were updated)
    541   */
    542  async loadMessagesForProvider(provider, options) {
    543    let { messages } = await this._loadDataForProvider(provider, options);
    544    // Filter out messages we temporarily want to exclude
    545    if (provider.exclude && provider.exclude.length) {
    546      messages = messages.filter(
    547        message => !provider.exclude.includes(message.id)
    548      );
    549    }
    550    const lastUpdated = Date.now();
    551    return {
    552      messages: messages
    553        .map(messageData => {
    554          const message = {
    555            weight: 100,
    556            ...messageData,
    557            groups: messageData.groups || [],
    558            provider: provider.id,
    559          };
    560 
    561          // Render local messages with experiment l10n structure if devtools
    562          // are enabled. This is not a production feature, since local messages
    563          // do not use experiment localization, and experimental messages are
    564          // translated in ExperimentAPI.sys.mjs. This is useful for development
    565          // to allow quickly testing experimental messages without needing to
    566          // manually convert all the $l10n objects to strings. We lock this
    567          // behind the devtools because it requires recursively processing
    568          // every message at least once, for a small performance hit.
    569          if (
    570            provider.type === "local" &&
    571            lazy.ASRouterPreferences.devtoolsEnabled
    572          ) {
    573            try {
    574              return this._delocalizeValues(message);
    575            } catch (e) {
    576              lazy.ASRouterPreferences.console.error(
    577                `Failed to delocalize message ${message.id}:`,
    578                e.message,
    579                e.cause
    580              );
    581            }
    582          }
    583 
    584          return message;
    585        })
    586        .filter(message => message.weight > 0),
    587      lastUpdated,
    588      errors: MessageLoaderUtils.errors,
    589    };
    590  },
    591 
    592  /**
    593   * For a given input (e.g. a message or a property), search for $l10n
    594   * properties and flatten them to just their `text` property. This is done so
    595   * that a message set up for experiment localization can be tested locally.
    596   * Without this, the messaging surface would not be able to read the message
    597   * because all the localized copy would be in $l10n objects. Normally, these
    598   * objects are translated by ExperimentFeature.substituteLocalizations. Rather
    599   * than returning $l10n.text, it would return localizations[$l10n.id] for the
    600   * active language. Localizations are included in the recipe, not in the
    601   * message, so we can't actually translate the message. But every $l10n object
    602   * should have a `text` property with the original English copy. So you can
    603   * copy a message straight from the recipe into a local message provider, and
    604   * it should render the English version with no issues.
    605   *
    606   * @param {object} values An object to delocalize
    607   * @returns {object} The object, stripped of any $l10n objects
    608   */
    609  _delocalizeValues(values) {
    610    if (typeof values !== "object" || values === null) {
    611      return values;
    612    }
    613 
    614    if (Array.isArray(values)) {
    615      return values.map(value => this._delocalizeValues(value));
    616    }
    617 
    618    const substituted = Object.assign({}, values);
    619    for (const [key, value] of Object.entries(values)) {
    620      if (key === "$l10n") {
    621        if (typeof value === "object" && value !== null) {
    622          if (value?.text) {
    623            return value.text;
    624          }
    625          throw new Error(`Expected $l10n to have a text property, but got`, {
    626            cause: value,
    627          });
    628        }
    629        throw new Error(`Expected $l10n to be an object, but got`, {
    630          cause: value,
    631        });
    632      }
    633      substituted[key] = this._delocalizeValues(value);
    634    }
    635    return substituted;
    636  },
    637 
    638  /**
    639   * cleanupCache - Removes cached data of removed providers.
    640   *
    641   * @param {Array} providers A list of activer AS Router providers
    642   */
    643  async cleanupCache(providers, storage) {
    644    const ids = providers.filter(p => p.type === "remote").map(p => p.id);
    645    const cache = await MessageLoaderUtils._remoteLoaderCache(storage);
    646    let dirty = false;
    647    for (let id in cache) {
    648      if (!ids.includes(id)) {
    649        delete cache[id];
    650        dirty = true;
    651      }
    652    }
    653    if (dirty) {
    654      await storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, cache);
    655    }
    656  },
    657 
    658  /**
    659   * The locale to use for RemoteL10n.
    660   *
    661   * This may map the app's actual locale into something that RemoteL10n
    662   * supports.
    663   */
    664  get locale() {
    665    const localeMap = {
    666      "ja-JP-macos": "ja-JP-mac",
    667 
    668      // While it's not a valid locale, "und" is commonly observed on
    669      // Linux platforms. Per l10n team, it's reasonable to fallback to
    670      // "en-US", therefore, we should allow the fetch for it.
    671      und: "en-US",
    672    };
    673 
    674    const locale = Services.locale.appLocaleAsBCP47;
    675    return localeMap[locale] ?? locale;
    676  },
    677 };
    678 
    679 /**
    680 * @class _ASRouter - Keeps track of all messages, UI surfaces, and
    681 * handles blocking, rotation, etc. Inspecting ASRouter.state will
    682 * tell you what the current displayed message is in all UI surfaces.
    683 *
    684 * Note: This is written as a constructor rather than just a plain object
    685 * so that it can be more easily unit tested.
    686 */
    687 export class _ASRouter {
    688  constructor(localProviders = LOCAL_MESSAGE_PROVIDERS) {
    689    this.initialized = false;
    690    this.clearChildMessages = null;
    691    this.clearChildProviders = null;
    692    this.updateAdminState = null;
    693    this.sendTelemetry = null;
    694    this.dispatchCFRAction = null;
    695    this._storage = null;
    696    this._resetInitialization();
    697    this._state = {
    698      providers: [],
    699      messageBlockList: [],
    700      multiProfileMessageBlocklist: [],
    701      messageImpressions: {},
    702      screenImpressions: {},
    703      messages: [],
    704      groups: [],
    705      errors: [],
    706      localeInUse: Services.locale.appLocaleAsBCP47,
    707    };
    708    this._experimentChangedListeners = new Map();
    709    this._triggerHandler = this._triggerHandler.bind(this);
    710    this._localProviders = localProviders;
    711    this.blockMessageById = this.blockMessageById.bind(this);
    712    this.unblockMessageById = this.unblockMessageById.bind(this);
    713    this.handleMessageRequest = this.handleMessageRequest.bind(this);
    714    this.addImpression = this.addImpression.bind(this);
    715    this.addScreenImpression = this.addScreenImpression.bind(this);
    716    this._handleTargetingError = this._handleTargetingError.bind(this);
    717    this.onPrefChange = this.onPrefChange.bind(this);
    718    this._onLocaleChanged = this._onLocaleChanged.bind(this);
    719    this.isUnblockedMessage = this.isUnblockedMessage.bind(this);
    720    this.unblockAll = this.unblockAll.bind(this);
    721    this._onExperimentEnrollmentsUpdated =
    722      this._onExperimentEnrollmentsUpdated.bind(this);
    723    this.forcePBWindow = this.forcePBWindow.bind(this);
    724    this._updateMultiprofileData = this._updateMultiprofileData.bind(this);
    725    this.messagesEnabledInAutomation = [];
    726  }
    727 
    728  async onPrefChange(prefName) {
    729    if (lazy.TARGETING_PREFERENCES.includes(prefName)) {
    730      let invalidMessages = [];
    731      // Notify all tabs of messages that have become invalid after pref change
    732      const context = this._getMessagesContext();
    733      const targetingContext = new lazy.TargetingContext(context);
    734 
    735      for (const msg of this.state.messages.filter(this.isUnblockedMessage)) {
    736        if (!msg.targeting) {
    737          continue;
    738        }
    739        const isMatch = await targetingContext.evalWithDefault(msg.targeting);
    740        if (!isMatch) {
    741          invalidMessages.push(msg.id);
    742        }
    743      }
    744      this.clearChildMessages(invalidMessages);
    745    } else {
    746      // Update message providers and fetch new messages on pref change
    747      this._loadLocalProviders();
    748      let invalidProviders = await this._updateMessageProviders();
    749      if (invalidProviders.length) {
    750        this.clearChildProviders(invalidProviders);
    751      }
    752      await this.loadMessagesFromAllProviders();
    753      // Any change in user prefs can disable or enable groups
    754      await this.setState(state => ({
    755        groups: state.groups.map(this._checkGroupEnabled),
    756      }));
    757    }
    758  }
    759 
    760  // Fetch and decode the message provider pref JSON, and update the message providers
    761  async _updateMessageProviders() {
    762    lazy.ASRouterPreferences.console.debug("entering updateMessageProviders");
    763 
    764    const previousProviders = this.state.providers;
    765    const providers = await Promise.all(
    766      [
    767        // If we have added a `preview` provider, hold onto it
    768        ...previousProviders.filter(p => p.id === "preview"),
    769        // The provider should be enabled and not have a user preference set to false
    770        ...lazy.ASRouterPreferences.providers.filter(
    771          p =>
    772            p.enabled &&
    773            lazy.ASRouterPreferences.getUserPreference(p.id) !== false
    774        ),
    775      ].map(async _provider => {
    776        // make a copy so we don't modify the source of the pref
    777        const provider = { ..._provider };
    778 
    779        if (provider.type === "local" && !provider.messages) {
    780          // Get the messages from the local message provider
    781          const localProvider = this._localProviders[provider.localProvider];
    782          provider.messages = [];
    783          if (localProvider) {
    784            provider.messages = await localProvider.getMessages();
    785          }
    786        }
    787        if (provider.type === "remote" && provider.url) {
    788          provider.url = provider.url.replace(
    789            /%STARTPAGE_VERSION%/g,
    790            STARTPAGE_VERSION
    791          );
    792          provider.url = Services.urlFormatter.formatURL(provider.url);
    793        }
    794        if (provider.id === "messaging-experiments") {
    795          // By default, the messaging-experiments provider lacks a featureIds
    796          // property, so fall back to the list of default features.
    797          if (!provider.featureIds) {
    798            provider.featureIds = MESSAGING_EXPERIMENTS_DEFAULT_FEATURES;
    799          }
    800        }
    801        // Reset provider update timestamp to force message refresh
    802        provider.lastUpdated = undefined;
    803        return provider;
    804      })
    805    );
    806 
    807    const providerIDs = providers.map(p => p.id);
    808    let invalidProviders = [];
    809 
    810    // Clear old messages for providers that are no longer enabled
    811    for (const prevProvider of previousProviders) {
    812      if (!providerIDs.includes(prevProvider.id)) {
    813        invalidProviders.push(prevProvider.id);
    814      }
    815    }
    816 
    817    return this.setState(prevState => ({
    818      providers,
    819      // Clear any messages from removed providers
    820      messages: [
    821        ...prevState.messages.filter(message =>
    822          providerIDs.includes(message.provider)
    823        ),
    824      ],
    825    })).then(() => invalidProviders);
    826  }
    827 
    828  get state() {
    829    return this._state;
    830  }
    831 
    832  set state(value) {
    833    throw new Error(
    834      "Do not modify this.state directy. Instead, call this.setState(newState)"
    835    );
    836  }
    837 
    838  /**
    839   * Adds the following to the instance:
    840   *  .initialized {bool}            Has AS Router been initialized?
    841   *  .waitForInitialized {Promise}  A promise that resolves when initializion is complete
    842   *  ._finishInitializing {func}    A function that, when called, resolves the .waitForInitialized
    843   *                                 promise and sets .initialized to true.
    844   *
    845   * @memberof _ASRouter
    846   */
    847  _resetInitialization() {
    848    this.initialized = false;
    849    this.initializing = false;
    850    this.waitForInitialized = new Promise(resolve => {
    851      this._finishInitializing = () => {
    852        this.initialized = true;
    853        this.initializing = false;
    854        resolve();
    855      };
    856    });
    857  }
    858 
    859  /**
    860   * Check all provided groups are enabled.
    861   *
    862   * @param groups Set of groups to verify
    863   * @returns bool
    864   */
    865  hasGroupsEnabled(groups = []) {
    866    return this.state.groups
    867      .filter(({ id }) => groups.includes(id))
    868      .every(({ enabled }) => enabled);
    869  }
    870 
    871  /**
    872   * Verify that the provider block the message through the `exclude` field
    873   *
    874   * @param message Message to verify
    875   * @returns bool
    876   */
    877  isExcludedByProvider(message) {
    878    const provider = this.state.providers.find(p => p.id === message.provider);
    879    if (!provider) {
    880      return true;
    881    }
    882    if (provider.exclude) {
    883      return provider.exclude.includes(message.id);
    884    }
    885    return false;
    886  }
    887 
    888  /**
    889   * Takes a group and sets the correct `enabled` state based on message config
    890   * and user preferences
    891   *
    892   * @param {GroupConfig} group
    893   * @returns {GroupConfig}
    894   */
    895  _checkGroupEnabled(group) {
    896    return {
    897      ...group,
    898      enabled:
    899        group.enabled &&
    900        // And if defined user preferences are true. If multiple prefs are
    901        // defined then at least one has to be enabled.
    902        (Array.isArray(group.userPreferences)
    903          ? group.userPreferences.some(pref =>
    904              lazy.ASRouterPreferences.getUserPreference(pref)
    905            )
    906          : true),
    907    };
    908  }
    909 
    910  /**
    911   * Fetch all message groups and update Router.state.groups.
    912   * There are two cases to consider:
    913   * 1. The provider needs to update as determined by the update cycle
    914   * 2. Some pref change occured which could invalidate one of the existing
    915   *    groups.
    916   */
    917  async loadAllMessageGroups() {
    918    const provider = this.state.providers.find(
    919      p =>
    920        p.id === "message-groups" && MessageLoaderUtils.shouldProviderUpdate(p)
    921    );
    922    let remoteMessages = null;
    923    if (provider) {
    924      const { messages } = await MessageLoaderUtils._loadDataForProvider(
    925        provider,
    926        {
    927          storage: this._storage,
    928          dispatchCFRAction: this.dispatchCFRAction,
    929        }
    930      );
    931      remoteMessages = messages;
    932    }
    933    await this.setState(state => ({
    934      // If fetching remote messages fails we default to existing state.groups.
    935      groups: (remoteMessages || state.groups).map(this._checkGroupEnabled),
    936    }));
    937  }
    938 
    939  /**
    940   * Loads messages from all providers if they require updates. Checks the
    941   * .lastUpdated field on each provider to see if updates are needed
    942   *
    943   * @param toUpdate  An optional list of providers to update. This overrides
    944   *                  the checks to determine which providers to update.
    945   * @memberof _ASRouter
    946   */
    947  async loadMessagesFromAllProviders(toUpdate = undefined) {
    948    const needsUpdate = Array.isArray(toUpdate)
    949      ? toUpdate
    950      : this.state.providers.filter(provider =>
    951          MessageLoaderUtils.shouldProviderUpdate(provider)
    952        );
    953    lazy.ASRouterPreferences.console.debug(
    954      "entering loadMessagesFromAllProviders"
    955    );
    956 
    957    await this.loadAllMessageGroups();
    958    // Don't do extra work if we don't need any updates
    959    if (needsUpdate.length) {
    960      let newState = { messages: [], providers: [] };
    961      for (const provider of this.state.providers) {
    962        if (provider.id === "message-groups") {
    963          // Message groups are handled separately by loadAllMessageGroups
    964          continue;
    965        }
    966        if (needsUpdate.includes(provider)) {
    967          const { messages, lastUpdated, errors } =
    968            await MessageLoaderUtils.loadMessagesForProvider(provider, {
    969              storage: this._storage,
    970              dispatchCFRAction: this.dispatchCFRAction,
    971            });
    972          newState.providers.push({ ...provider, lastUpdated, errors });
    973          newState.messages = [...newState.messages, ...messages];
    974        } else {
    975          // Skip updating this provider's messages if no update is required
    976          let messages = this.state.messages.filter(
    977            msg => msg.provider === provider.id
    978          );
    979          newState.providers.push(provider);
    980          newState.messages = [...newState.messages, ...messages];
    981        }
    982      }
    983 
    984      // Some messages have triggers that require us to initalise trigger listeners
    985      const unseenListeners = new Set(lazy.ASRouterTriggerListeners.keys());
    986      for (const message of newState.messages) {
    987        const { trigger } = message;
    988        if (
    989          trigger &&
    990          lazy.ASRouterTriggerListeners.has(trigger.id) &&
    991          !this._shouldSkipForAutomation(message)
    992        ) {
    993          lazy.ASRouterTriggerListeners.get(trigger.id).init(
    994            this._triggerHandler,
    995            trigger.params,
    996            trigger.patterns,
    997            trigger.regexPatterns
    998          );
    999          unseenListeners.delete(trigger.id);
   1000        }
   1001      }
   1002      // We don't need these listeners, but they may have previously been
   1003      // initialised, so uninitialise them
   1004      for (const triggerID of unseenListeners) {
   1005        lazy.ASRouterTriggerListeners.get(triggerID).uninit();
   1006      }
   1007 
   1008      await this.setState(newState);
   1009      await this.cleanupImpressions();
   1010    }
   1011 
   1012    await this._fireMessagesLoadedTrigger();
   1013 
   1014    return this.state;
   1015  }
   1016 
   1017  async _fireMessagesLoadedTrigger() {
   1018    const win = Services.wm.getMostRecentBrowserWindow() ?? null;
   1019    const browser = win?.gBrowser?.selectedBrowser ?? null;
   1020    // pass skipLoadingMessages to avoid infinite recursion. pass browser and
   1021    // window into context so messages that may need a window or browser can
   1022    // target accordingly.
   1023    await this.sendTriggerMessage(
   1024      {
   1025        id: "messagesLoaded",
   1026        browser,
   1027        context: { browser, browserWindow: win },
   1028      },
   1029      true
   1030    );
   1031  }
   1032 
   1033  async _maybeUpdateL10nAttachment() {
   1034    const { localeInUse } = this.state.localeInUse;
   1035    const newLocale = Services.locale.appLocaleAsBCP47;
   1036    if (newLocale !== localeInUse) {
   1037      const providers = [...this.state.providers];
   1038      let needsUpdate = false;
   1039      providers.forEach(provider => {
   1040        if (RS_PROVIDERS_WITH_L10N.includes(provider.id)) {
   1041          // Force to refresh the messages as well as the attachment.
   1042          provider.lastUpdated = undefined;
   1043          needsUpdate = true;
   1044        }
   1045      });
   1046      if (needsUpdate) {
   1047        await this.setState({
   1048          localeInUse: newLocale,
   1049          providers,
   1050        });
   1051        await this.loadMessagesFromAllProviders();
   1052      }
   1053    }
   1054    return this.state;
   1055  }
   1056 
   1057  async _onLocaleChanged() {
   1058    await this._maybeUpdateL10nAttachment();
   1059  }
   1060 
   1061  observe(aSubject, aTopic, aPrefName) {
   1062    switch (aPrefName) {
   1063      case USE_REMOTE_L10N_PREF:
   1064        CFRPageActions.reloadL10n();
   1065        break;
   1066    }
   1067  }
   1068 
   1069  toWaitForInitFunc(func) {
   1070    return (...args) => this.waitForInitialized.then(() => func(...args));
   1071  }
   1072 
   1073  /**
   1074   * init - Initializes the MessageRouter.
   1075   *
   1076   * @param {obj} parameters parameters to initialize ASRouter
   1077   * @memberof _ASRouter
   1078   */
   1079  async init({
   1080    storage,
   1081    sendTelemetry,
   1082    clearChildMessages,
   1083    clearChildProviders,
   1084    updateAdminState,
   1085    dispatchCFRAction,
   1086  }) {
   1087    if (this.initializing || this.initialized) {
   1088      return null;
   1089    }
   1090    this.initializing = true;
   1091    this._storage = storage;
   1092    this.ALLOWLIST_HOSTS = this._loadAllowHosts();
   1093    this.clearChildMessages = this.toWaitForInitFunc(clearChildMessages);
   1094    this.clearChildProviders = this.toWaitForInitFunc(clearChildProviders);
   1095    // NOTE: This is only necessary to sync devtools when devtools is active.
   1096    this.updateAdminState = this.toWaitForInitFunc(updateAdminState);
   1097    this.sendTelemetry = sendTelemetry;
   1098    this.dispatchCFRAction = this.toWaitForInitFunc(dispatchCFRAction);
   1099 
   1100    lazy.ASRouterPreferences.init();
   1101    lazy.ASRouterPreferences.addListener(this.onPrefChange);
   1102    lazy.ToolbarBadgeHub.init(this.waitForInitialized, {
   1103      handleMessageRequest: this.handleMessageRequest,
   1104      addImpression: this.addImpression,
   1105      blockMessageById: this.blockMessageById,
   1106      unblockMessageById: this.unblockMessageById,
   1107      sendTelemetry: this.sendTelemetry,
   1108    });
   1109    lazy.MomentsPageHub.init(this.waitForInitialized, {
   1110      handleMessageRequest: this.handleMessageRequest,
   1111      addImpression: this.addImpression,
   1112      blockMessageById: this.blockMessageById,
   1113      sendTelemetry: this.sendTelemetry,
   1114    });
   1115 
   1116    this._loadLocalProviders();
   1117 
   1118    const messageBlockList =
   1119      (await this._storage.get("messageBlockList")) || [];
   1120    const messageImpressions =
   1121      (await this._storage.get("messageImpressions")) || {};
   1122    const groupImpressions =
   1123      (await this._storage.get("groupImpressions")) || {};
   1124    const screenImpressions =
   1125      (await this._storage.get("screenImpressions")) || {};
   1126    const previousSessionEnd =
   1127      (await this._storage.get("previousSessionEnd")) || 0;
   1128 
   1129    let multiProfileMessageImpressions = {};
   1130    let multiProfileMessageBlocklist = [];
   1131 
   1132    if (
   1133      lazy.ASRouterTargeting.Environment.canCreateSelectableProfiles ||
   1134      lazy.ASRouterTargeting.Environment.hasSelectableProfiles
   1135    ) {
   1136      multiProfileMessageImpressions =
   1137        (await this._storage.getSharedMessageImpressions()) || {};
   1138      multiProfileMessageBlocklist =
   1139        (await this._storage.getSharedMessageBlocklist()) || [];
   1140    }
   1141 
   1142    await this.setState({
   1143      messageBlockList,
   1144      groupImpressions,
   1145      messageImpressions,
   1146      screenImpressions,
   1147      multiProfileMessageImpressions,
   1148      multiProfileMessageBlocklist,
   1149      previousSessionEnd,
   1150      ...(lazy.ASRouterPreferences.specialConditions || {}),
   1151      initialized: false,
   1152    });
   1153    await this._updateMessageProviders();
   1154    await this.loadMessagesFromAllProviders();
   1155    await MessageLoaderUtils.cleanupCache(this.state.providers, storage);
   1156 
   1157    lazy.SpecialMessageActions.blockMessageById = this.blockMessageById;
   1158    Services.obs.addObserver(this._onLocaleChanged, TOPIC_INTL_LOCALE_CHANGED);
   1159    Services.obs.addObserver(
   1160      this._onExperimentEnrollmentsUpdated,
   1161      TOPIC_EXPERIMENT_ENROLLMENT_CHANGED
   1162    );
   1163    Services.obs.addObserver(
   1164      this._updateMultiprofileData,
   1165      MULTIPROFILE_DATA_UPDATED
   1166    );
   1167    Services.prefs.addObserver(USE_REMOTE_L10N_PREF, this);
   1168    // sets .initialized to true and resolves .waitForInitialized promise
   1169    this._finishInitializing();
   1170    return this.state;
   1171  }
   1172 
   1173  uninit() {
   1174    this._storage.set("previousSessionEnd", Date.now());
   1175 
   1176    this.clearChildMessages = null;
   1177    this.clearChildProviders = null;
   1178    this.updateAdminState = null;
   1179    this.sendTelemetry = null;
   1180    this.dispatchCFRAction = null;
   1181 
   1182    lazy.ASRouterPreferences.removeListener(this.onPrefChange);
   1183    lazy.ASRouterPreferences.uninit();
   1184    lazy.ToolbarBadgeHub.uninit();
   1185    lazy.MomentsPageHub.uninit();
   1186 
   1187    // Uninitialise all trigger listeners
   1188    for (const listener of lazy.ASRouterTriggerListeners.values()) {
   1189      listener.uninit();
   1190    }
   1191    Services.obs.removeObserver(
   1192      this._onLocaleChanged,
   1193      TOPIC_INTL_LOCALE_CHANGED
   1194    );
   1195    Services.obs.removeObserver(
   1196      this._onExperimentEnrollmentsUpdated,
   1197      TOPIC_EXPERIMENT_ENROLLMENT_CHANGED
   1198    );
   1199    Services.obs.removeObserver(
   1200      this._updateMultiprofileData,
   1201      MULTIPROFILE_DATA_UPDATED
   1202    );
   1203    Services.prefs.removeObserver(USE_REMOTE_L10N_PREF, this);
   1204    // If we added any CFR recommendations, they need to be removed
   1205    CFRPageActions.clearRecommendations();
   1206    this._resetInitialization();
   1207  }
   1208 
   1209  setState(callbackOrObj) {
   1210    lazy.ASRouterPreferences.console.debug(
   1211      "in setState, callbackOrObj = ",
   1212      callbackOrObj
   1213    );
   1214    lazy.ASRouterPreferences.console.trace();
   1215    const newState =
   1216      typeof callbackOrObj === "function"
   1217        ? callbackOrObj(this.state)
   1218        : callbackOrObj;
   1219    this._state = {
   1220      ...this.state,
   1221      ...newState,
   1222    };
   1223    if (lazy.ASRouterPreferences.devtoolsEnabled) {
   1224      return this.updateTargetingParameters().then(state => {
   1225        this.updateAdminState(state);
   1226        return state;
   1227      });
   1228    }
   1229    return Promise.resolve(this.state);
   1230  }
   1231 
   1232  updateTargetingParameters() {
   1233    return this.getTargetingParameters(
   1234      lazy.ASRouterTargeting.Environment,
   1235      this._getMessagesContext()
   1236    ).then(targetingParameters => ({
   1237      ...this.state,
   1238      providerPrefs: lazy.ASRouterPreferences.providers,
   1239      userPrefs: lazy.ASRouterPreferences.getAllUserPreferences(),
   1240      targetingParameters,
   1241      errors: this.errors,
   1242      devtoolsEnabled: lazy.ASRouterPreferences.devtoolsEnabled,
   1243    }));
   1244  }
   1245 
   1246  getMessageById(id) {
   1247    return this.state.messages.find(message => message.id === id);
   1248  }
   1249 
   1250  _loadLocalProviders() {
   1251    // If we're in ASR debug mode add the local test providers
   1252    if (lazy.ASRouterPreferences.devtoolsEnabled) {
   1253      this._localProviders = {
   1254        ...this._localProviders,
   1255        PanelTestProvider: lazy.PanelTestProvider,
   1256      };
   1257    }
   1258  }
   1259 
   1260  async _updateMultiprofileData(aSubject, aTopic, aSource) {
   1261    // Return early if sharedDb update event source is from the local profile
   1262    if (aSource === "local" && aTopic === MULTIPROFILE_DATA_UPDATED) {
   1263      return;
   1264    }
   1265    // wait to ensure storage has been initialized before accessing _storage
   1266    if (!this.initialized) {
   1267      await this.waitForInitialized;
   1268    }
   1269    const multiProfileMessageImpressions =
   1270      (await this._storage.getSharedMessageImpressions()) || {};
   1271    const multiProfileMessageBlocklist =
   1272      (await this._storage.getSharedMessageBlocklist()) || [];
   1273 
   1274    this.setState({
   1275      multiProfileMessageImpressions,
   1276      multiProfileMessageBlocklist,
   1277    });
   1278  }
   1279 
   1280  /**
   1281   * Used by ASRouter Admin returns all ASRouterTargeting.Environment
   1282   * and ASRouter._getMessagesContext parameters and values
   1283   */
   1284  async getTargetingParameters(environment, localContext) {
   1285    // Resolve objects that may contain promises.
   1286    async function resolve(object) {
   1287      if (typeof object === "object" && object !== null) {
   1288        if (Array.isArray(object)) {
   1289          return Promise.all(object.map(async item => resolve(await item)));
   1290        }
   1291 
   1292        if (object instanceof Date) {
   1293          return object;
   1294        }
   1295 
   1296        const target = {};
   1297        const promises = Object.entries(object).map(async ([key, value]) => {
   1298          try {
   1299            let resolvedValue = await resolve(await value);
   1300            return [key, resolvedValue];
   1301          } catch (error) {
   1302            lazy.ASRouterPreferences.console.debug(
   1303              `getTargetingParameters: Error resolving ${key}: `,
   1304              error
   1305            );
   1306            throw error;
   1307          }
   1308        });
   1309        for (const { status, value } of await Promise.allSettled(promises)) {
   1310          if (status === "fulfilled") {
   1311            const [key, resolvedValue] = value;
   1312            target[key] = resolvedValue;
   1313          }
   1314        }
   1315        return target;
   1316      }
   1317 
   1318      return object;
   1319    }
   1320 
   1321    const targetingParameters = {
   1322      ...(await resolve(environment)),
   1323      ...(await resolve(localContext)),
   1324    };
   1325 
   1326    return targetingParameters;
   1327  }
   1328 
   1329  _handleTargetingError(error, message) {
   1330    console.error(error);
   1331    this.dispatchCFRAction?.({
   1332      type: lazy.MESSAGE_TYPE_HASH.AS_ROUTER_TELEMETRY_USER_EVENT,
   1333      data: {
   1334        action: "asrouter_undesired_event",
   1335        message_id: message.id,
   1336        event: "TARGETING_EXPRESSION_ERROR",
   1337        event_context: {},
   1338      },
   1339    });
   1340  }
   1341 
   1342  // Return an object containing targeting parameters used to select messages
   1343  _getMessagesContext() {
   1344    const { messageImpressions, previousSessionEnd, screenImpressions } =
   1345      this.state;
   1346 
   1347    return {
   1348      get messageImpressions() {
   1349        return messageImpressions;
   1350      },
   1351      get previousSessionEnd() {
   1352        return previousSessionEnd;
   1353      },
   1354      get screenImpressions() {
   1355        return screenImpressions;
   1356      },
   1357    };
   1358  }
   1359 
   1360  async evaluateExpression({ expression, context }) {
   1361    const targetingContext = new lazy.TargetingContext(context);
   1362    let evaluationStatus;
   1363    try {
   1364      evaluationStatus = {
   1365        result: await targetingContext.evalWithDefault(expression),
   1366        success: true,
   1367      };
   1368    } catch (e) {
   1369      evaluationStatus = { result: e.message, success: false };
   1370    }
   1371    return Promise.resolve({ evaluationStatus });
   1372  }
   1373 
   1374  unblockAll() {
   1375    return this.setState({ messageBlockList: [] });
   1376  }
   1377 
   1378  hasValidProfileScope(message) {
   1379    // Return early if a message doesn't need profile scope check
   1380    if (
   1381      !message.profileScope ||
   1382      message.profileScope === PROFILE_MESSAGE_SCOPE.NONE
   1383    ) {
   1384      return true;
   1385    }
   1386    const { state } = this;
   1387    // For single profile scope filter out message which is in
   1388    // profileMessageImpression and not in indexedDb message impressions
   1389    // that means message is seen by a user in one of the profiles
   1390    if (
   1391      message.profileScope === PROFILE_MESSAGE_SCOPE.SINGLE &&
   1392      message.id in state.multiProfileMessageImpressions &&
   1393      !(message.id in state.messageImpressions)
   1394    ) {
   1395      return false;
   1396    }
   1397    return true;
   1398  }
   1399 
   1400  isUnblockedMessage(message) {
   1401    const { state } = this;
   1402    return (
   1403      !state.messageBlockList.includes(message.id) &&
   1404      !state.multiProfileMessageBlocklist.includes(message.id) &&
   1405      (!message.campaign ||
   1406        !state.messageBlockList.includes(message.campaign)) &&
   1407      this.hasGroupsEnabled(message.groups) &&
   1408      !this.isExcludedByProvider(message)
   1409    );
   1410  }
   1411 
   1412  // Work out if a message can be shown based on its and its provider's frequency caps.
   1413  isBelowFrequencyCaps(message) {
   1414    const { messageImpressions, groupImpressions } = this.state;
   1415    const impressionsForMessage = messageImpressions[message.id];
   1416 
   1417    const _belowItemFrequencyCap = this._isBelowItemFrequencyCap(
   1418      message,
   1419      impressionsForMessage,
   1420      MAX_MESSAGE_LIFETIME_CAP
   1421    );
   1422    if (!_belowItemFrequencyCap) {
   1423      lazy.ASRouterPreferences.console.debug(
   1424        `isBelowFrequencyCaps: capped by item: `,
   1425        message,
   1426        "impressions =",
   1427        impressionsForMessage
   1428      );
   1429    }
   1430 
   1431    const _belowGroupFrequencyCaps = message.groups.every(messageGroup => {
   1432      const belowThisGroupCap = this._isBelowItemFrequencyCap(
   1433        this.state.groups.find(({ id }) => id === messageGroup),
   1434        groupImpressions[messageGroup]
   1435      );
   1436 
   1437      if (!belowThisGroupCap) {
   1438        lazy.ASRouterPreferences.console.debug(
   1439          `isBelowFrequencyCaps: ${message.id} capped by group ${messageGroup}`
   1440        );
   1441      } else {
   1442        lazy.ASRouterPreferences.console.debug(
   1443          `isBelowFrequencyCaps: ${message.id} allowed by group ${messageGroup}, groupImpressions = `,
   1444          groupImpressions
   1445        );
   1446      }
   1447 
   1448      return belowThisGroupCap;
   1449    });
   1450 
   1451    return _belowItemFrequencyCap && _belowGroupFrequencyCaps;
   1452  }
   1453 
   1454  // Helper for isBelowFrecencyCaps - work out if the frequency cap for the given
   1455  //                                  item has been exceeded or not
   1456  _isBelowItemFrequencyCap(item, impressions, maxLifetimeCap = Infinity) {
   1457    if (item && item.frequency && impressions && impressions.length) {
   1458      if (
   1459        item.frequency.lifetime &&
   1460        impressions.length >= Math.min(item.frequency.lifetime, maxLifetimeCap)
   1461      ) {
   1462        lazy.ASRouterPreferences.console.debug(
   1463          `${item.id} capped by lifetime (${item.frequency.lifetime})`
   1464        );
   1465 
   1466        return false;
   1467      }
   1468      if (item.frequency.custom) {
   1469        const now = Date.now();
   1470        for (const setting of item.frequency.custom) {
   1471          let { period } = setting;
   1472          const impressionsInPeriod = impressions.filter(t => now - t < period);
   1473          if (impressionsInPeriod.length >= setting.cap) {
   1474            lazy.ASRouterPreferences.console.debug(
   1475              `${item.id} capped by impressions (${impressionsInPeriod.length}) in period (${period}) >= ${setting.cap}`
   1476            );
   1477            return false;
   1478          }
   1479        }
   1480      }
   1481    }
   1482    return true;
   1483  }
   1484 
   1485  _shouldSkipForAutomation(message) {
   1486    return (
   1487      message.skip_in_tests &&
   1488      // `this.messagesEnabledInAutomation` should be stubbed in tests
   1489      !this.messagesEnabledInAutomation?.includes(message.id) &&
   1490      (Cu.isInAutomation ||
   1491        Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") ||
   1492        Services.env.get("MOZ_AUTOMATION"))
   1493    );
   1494  }
   1495 
   1496  _findProvider(providerID) {
   1497    return this._localProviders[
   1498      this.state.providers.find(i => i.id === providerID).localProvider
   1499    ];
   1500  }
   1501 
   1502  routeCFRMessage(message, browser, trigger, force = false) {
   1503    if (!message) {
   1504      return { message: {} };
   1505    }
   1506 
   1507    switch (message.template) {
   1508      case "cfr_doorhanger":
   1509      case "milestone_message":
   1510        if (force) {
   1511          CFRPageActions.forceRecommendation(
   1512            browser,
   1513            message,
   1514            this.dispatchCFRAction
   1515          );
   1516        } else {
   1517          CFRPageActions.addRecommendation(
   1518            browser,
   1519            trigger.param && trigger.param.host,
   1520            message,
   1521            this.dispatchCFRAction
   1522          );
   1523        }
   1524        break;
   1525      case "cfr_urlbar_chiclet":
   1526        if (force) {
   1527          CFRPageActions.forceRecommendation(
   1528            browser,
   1529            message,
   1530            this.dispatchCFRAction
   1531          );
   1532        } else {
   1533          CFRPageActions.addRecommendation(
   1534            browser,
   1535            null,
   1536            message,
   1537            this.dispatchCFRAction
   1538          );
   1539        }
   1540        break;
   1541      case "toolbar_badge":
   1542        lazy.ToolbarBadgeHub.registerBadgeNotificationListener(message, {
   1543          force,
   1544        });
   1545        break;
   1546      case "update_action":
   1547        lazy.MomentsPageHub.executeAction(message);
   1548        break;
   1549      case "infobar":
   1550        lazy.InfoBar.showInfoBarMessage(
   1551          browser,
   1552          message,
   1553          this.dispatchCFRAction
   1554        );
   1555        break;
   1556      case "spotlight":
   1557        lazy.Spotlight.showSpotlightDialog(
   1558          browser,
   1559          message,
   1560          this.dispatchCFRAction
   1561        );
   1562        break;
   1563      case "feature_callout":
   1564        // featureCalloutCheck only comes from within FeatureCallout, where it
   1565        // is used to request a matching message. It is not a real trigger.
   1566        // pdfJsFeatureCalloutCheck is used for PDF.js feature callouts, which
   1567        // are managed by the trigger listener itself.
   1568        switch (trigger.id) {
   1569          case "featureCalloutCheck":
   1570          case "pdfJsFeatureCalloutCheck":
   1571          case "newtabFeatureCalloutCheck":
   1572            break;
   1573          default:
   1574            lazy.FeatureCalloutBroker.showFeatureCallout(browser, message);
   1575        }
   1576        break;
   1577      case "toast_notification":
   1578        lazy.ToastNotification.showToastNotification(
   1579          message,
   1580          this.dispatchCFRAction
   1581        );
   1582        break;
   1583      case "bookmarks_bar_button":
   1584        lazy.BookmarksBarButton.showBookmarksBarButton(browser, message);
   1585        break;
   1586      case "menu_message":
   1587        lazy.MenuMessage.showMenuMessage(browser, message, trigger, force);
   1588        break;
   1589      case "newtab_message": {
   1590        let targetBrowser = force ? null : browser;
   1591        let messageWithBrowser = {
   1592          targetBrowser,
   1593          message,
   1594          dispatch: this.dispatchCFRAction,
   1595        };
   1596        Services.obs.notifyObservers(messageWithBrowser, "newtab-message");
   1597        break;
   1598      }
   1599    }
   1600 
   1601    return { message };
   1602  }
   1603 
   1604  async addScreenImpression(screen) {
   1605    // wait to ensure storage has been intialized before setting
   1606    // screenImpression
   1607    if (!this.initialized) {
   1608      await this.waitForInitialized;
   1609    }
   1610 
   1611    lazy.ASRouterPreferences.console.debug(
   1612      `entering addScreenImpression for ${screen.id}`
   1613    );
   1614 
   1615    const time = Date.now();
   1616 
   1617    let screenImpressions = { ...this.state.screenImpressions };
   1618    screenImpressions[screen.id] = time;
   1619 
   1620    this.setState({ screenImpressions });
   1621    lazy.ASRouterPreferences.console.debug(
   1622      screen.id,
   1623      `screen impression added, screenImpressions[screen.id]: `,
   1624      screenImpressions[screen.id]
   1625    );
   1626    this._storage.set("screenImpressions", screenImpressions);
   1627  }
   1628 
   1629  addImpression(message) {
   1630    lazy.ASRouterPreferences.console.debug(
   1631      `entering addImpression for ${message.id}`
   1632    );
   1633 
   1634    const groupsWithFrequency = this.state.groups?.filter(
   1635      ({ frequency, id }) => frequency && message.groups?.includes(id)
   1636    );
   1637    // We only need to store impressions for messages that have frequency, or
   1638    // that have providers that have frequency
   1639    if (message.frequency || groupsWithFrequency.length) {
   1640      const time = Date.now();
   1641      return this.setState(state => {
   1642        const messageImpressions = this._addImpressionForItem(
   1643          state.messageImpressions,
   1644          message,
   1645          "messageImpressions",
   1646          time
   1647        );
   1648        // Initialize this with state.groupImpressions, and then assign the
   1649        // newly-updated copy to it during each iteration so that
   1650        // all the changes get captured and either returned or passed into the
   1651        // _addImpressionsForItem call on the next iteration.
   1652        let { groupImpressions } = state;
   1653        for (const group of groupsWithFrequency) {
   1654          groupImpressions = this._addImpressionForItem(
   1655            groupImpressions,
   1656            group,
   1657            "groupImpressions",
   1658            time
   1659          );
   1660        }
   1661 
   1662        let { multiProfileMessageImpressions } = state;
   1663 
   1664        if (
   1665          message.profileScope === PROFILE_MESSAGE_SCOPE.SINGLE &&
   1666          lazy.ASRouterTargeting.Environment.canCreateSelectableProfiles
   1667        ) {
   1668          multiProfileMessageImpressions = this._addImpressionForItem(
   1669            state.multiProfileMessageImpressions,
   1670            message,
   1671            "multiProfileMessageImpressions",
   1672            time
   1673          );
   1674        }
   1675 
   1676        return {
   1677          messageImpressions,
   1678          groupImpressions,
   1679          multiProfileMessageImpressions,
   1680        };
   1681      });
   1682    }
   1683    return Promise.resolve();
   1684  }
   1685 
   1686  // Helper for addImpression - calculate the updated impressions object for the given
   1687  //                            item, then store it and return it
   1688  _addImpressionForItem(currentImpressions, item, impressionsString, time) {
   1689    // The destructuring here is to avoid mutating passed parameters
   1690    // (see https://redux.js.org/recipes/structuring-reducers/prerequisite-concepts#immutable-data-management)
   1691    const impressions = { ...currentImpressions };
   1692    if (item.frequency) {
   1693      impressions[item.id] = [...(impressions[item.id] ?? []), time];
   1694 
   1695      lazy.ASRouterPreferences.console.debug(
   1696        item.id,
   1697        "impression added, impressions[item.id]: ",
   1698        impressions[item.id]
   1699      );
   1700 
   1701      if (impressionsString === "multiProfileMessageImpressions") {
   1702        // Update shared db impressions for a message
   1703        this._storage.setSharedMessageImpressions(
   1704          item.id,
   1705          impressions[item.id]
   1706        );
   1707      } else {
   1708        this._storage.set(impressionsString, impressions);
   1709      }
   1710    }
   1711    return impressions;
   1712  }
   1713 
   1714  /**
   1715   * getLongestPeriod
   1716   *
   1717   * @param {obj} item Either an ASRouter message or an ASRouter provider
   1718   * @returns {int|null} if the item has custom frequency caps, the longest period found in the list of caps.
   1719                         if the item has no custom frequency caps, null
   1720   * @memberof _ASRouter
   1721   */
   1722  getLongestPeriod(item) {
   1723    if (!item.frequency || !item.frequency.custom) {
   1724      return null;
   1725    }
   1726    return item.frequency.custom.sort((a, b) => b.period - a.period)[0].period;
   1727  }
   1728 
   1729  /**
   1730   * cleanupImpressions - this function cleans up obsolete impressions whenever
   1731   * messages are refreshed or fetched. It will likely need to be more sophisticated in the future,
   1732   * but the current behaviour for when both message impressions and provider impressions are
   1733   * cleared is as follows (where `item` is either `message` or `provider`):
   1734   *
   1735   * 1. If the item id for a list of item impressions no longer exists in the ASRouter state, it
   1736   *    will be cleared.
   1737   * 2. If the item has time-bound frequency caps but no lifetime cap, any item impressions older
   1738   *    than the longest time period will be cleared.
   1739   * 3. For multi-profile environments, shared message impressions are cleaned up separately and stored
   1740   *    in a shared database accessible across profiles.
   1741   */
   1742  cleanupImpressions() {
   1743    return this.setState(state => {
   1744      let multiProfileMessageImpressions = {};
   1745      if (lazy.ASRouterTargeting.Environment.canCreateSelectableProfiles) {
   1746        multiProfileMessageImpressions = this._cleanupMultiProfileImpressions(
   1747          state,
   1748          state.messages,
   1749          "multiProfileMessageImpressions"
   1750        );
   1751      }
   1752      const messageImpressions = this._cleanupImpressionsForItems(
   1753        state,
   1754        state.messages,
   1755        "messageImpressions"
   1756      );
   1757      const groupImpressions = this._cleanupImpressionsForItems(
   1758        state,
   1759        state.groups,
   1760        "groupImpressions"
   1761      );
   1762 
   1763      return {
   1764        messageImpressions,
   1765        groupImpressions,
   1766        multiProfileMessageImpressions,
   1767      };
   1768    });
   1769  }
   1770 
   1771  /**
   1772   * Helper for cleanupImpressions - calculate the updated impressions object
   1773   * for the given items, then store it and return it.
   1774   *
   1775   * @param {obj} state Reference to ASRouter internal state
   1776   * @param {Array} items Can be messages, providers or groups that we count impressions for
   1777   * @param {string} impressionsString Key name for entry in state where impressions are stored
   1778   */
   1779  _cleanupImpressionsForItems(state, items, impressionsString) {
   1780    const impressions = { ...state[impressionsString] };
   1781    let needsUpdate = false;
   1782    Object.keys(impressions).forEach(id => {
   1783      const [item] = items.filter(x => x.id === id);
   1784      // Don't keep impressions for items that no longer exist
   1785      if (!item || !item.frequency || !Array.isArray(impressions[id])) {
   1786        lazy.ASRouterPreferences.console.debug(
   1787          "_cleanupImpressionsForItem: removing impressions for deleted or changed item: ",
   1788          item
   1789        );
   1790        lazy.ASRouterPreferences.console.trace();
   1791        delete impressions[id];
   1792        needsUpdate = true;
   1793        return;
   1794      }
   1795      if (!impressions[id].length) {
   1796        return;
   1797      }
   1798      // If we don't want to store impressions older than the longest period
   1799      if (item.frequency.custom && !item.frequency.lifetime) {
   1800        lazy.ASRouterPreferences.console.debug(
   1801          "_cleanupImpressionsForItem: removing impressions older than longest period for item: ",
   1802          item
   1803        );
   1804        const now = Date.now();
   1805        impressions[id] = impressions[id].filter(
   1806          t => now - t < this.getLongestPeriod(item)
   1807        );
   1808        needsUpdate = true;
   1809      }
   1810    });
   1811    if (needsUpdate) {
   1812      this._storage.set(impressionsString, impressions);
   1813    }
   1814    return impressions;
   1815  }
   1816 
   1817  /**
   1818   * Helper for cleanupImpressions. This method handles cleanup of impression data in
   1819   * multi-profile environments where impression data is shared across all user profiles.
   1820   * It performs the following cleanup:
   1821   * - For deleted/invalid items: Removes impressions older than 6 months (gradual cleanup)
   1822   * - For items with custom frequency caps: Removes impressions older than the longest period
   1823   * - Handles corrupted or malformed impression data
   1824   * - Updates the shared database after each cleanup operation
   1825   *
   1826   * @param {obj} state Reference to ASRouter internal state
   1827   * @param {Array} items are messages that we count impressions for
   1828   * @param {string} impressionsString Key name for entry in state where impressions are stored
   1829   * @returns {obj} Updated impressions object with cleaned data
   1830   */
   1831  _cleanupMultiProfileImpressions(state, items, impressionsString) {
   1832    const impressions = { ...state[impressionsString] };
   1833    const now = Date.now();
   1834    Object.keys(impressions).forEach(id => {
   1835      const [item] = items.filter(x => x.id === id);
   1836      // Remove impressions older than six months for items that no longer exist
   1837      if (!item || !item.frequency || !Array.isArray(impressions[id])) {
   1838        lazy.ASRouterPreferences.console.debug(
   1839          "_cleanupMultiProfileImpressions: removing impressions older than six months for deleted or changed item: ",
   1840          item
   1841        );
   1842        lazy.ASRouterPreferences.console.trace();
   1843        impressions[id] = impressions[id].filter(t => now - t < SIX_MONTHS_MS);
   1844 
   1845        this._storage.setSharedMessageImpressions(id, impressions[id]);
   1846        return;
   1847      }
   1848      if (!impressions[id].length) {
   1849        return;
   1850      }
   1851      // We don't want to store impressions older than the longest period
   1852      if (item?.frequency?.custom && !item.frequency.lifetime) {
   1853        lazy.ASRouterPreferences.console.debug(
   1854          "_cleanupMultiProfileImpressions: removing impressions older than longest period for item: ",
   1855          item
   1856        );
   1857        impressions[id] = impressions[id].filter(
   1858          t => now - t < this.getLongestPeriod(item)
   1859        );
   1860        this._storage.setSharedMessageImpressions(id, impressions[id]);
   1861      }
   1862    });
   1863    return impressions;
   1864  }
   1865 
   1866  // Determine whether the current profile is using Selectable profiles;
   1867  // if yes, ensure we only message a single profile in the group.
   1868  shouldShowMessagesToProfile() {
   1869    // If the pref for this mitigation is disabled, skip these checks.
   1870    if (lazy.disableSingleProfileMessaging) {
   1871      return true;
   1872    }
   1873    // If multiple profiles aren't enabled or aren't being used,
   1874    // then always show messages.
   1875    if (
   1876      !lazy.ASRouterTargeting.Environment.canCreateSelectableProfiles ||
   1877      !lazy.ASRouterTargeting.Environment.hasSelectableProfiles
   1878    ) {
   1879      return true;
   1880    }
   1881    // if multiple profiles exist and messagingProfileID is set,
   1882    // then show messages when profileID matches.
   1883    return (
   1884      lazy.messagingProfileId ===
   1885      lazy.ASRouterTargeting.Environment.currentProfileId
   1886    );
   1887  }
   1888 
   1889  handleMessageRequest({
   1890    messages: candidates,
   1891    triggerId,
   1892    triggerParam,
   1893    triggerContext,
   1894    template,
   1895    provider,
   1896    ordered = false,
   1897    returnAll = false,
   1898  }) {
   1899    // If using a selectable profile, return no messages
   1900    if (!this.shouldShowMessagesToProfile()) {
   1901      lazy.ASRouterPreferences.console.debug(
   1902        "Selectable profile in use; skip loading messages"
   1903      );
   1904      return returnAll ? [] : null;
   1905    }
   1906    let shouldCache;
   1907    lazy.ASRouterPreferences.console.debug(
   1908      "in handleMessageRequest, arguments = ",
   1909      Array.from(arguments) // eslint-disable-line prefer-rest-params
   1910    );
   1911    lazy.ASRouterPreferences.console.trace();
   1912    const messages =
   1913      candidates ||
   1914      this.state.messages.filter(m => {
   1915        if (this._shouldSkipForAutomation(m)) {
   1916          lazy.ASRouterPreferences.console.debug(
   1917            m.id,
   1918            ` filtered in tests because ${m.skip_in_tests}`
   1919          );
   1920          return false;
   1921        }
   1922        if (provider && m.provider !== provider) {
   1923          lazy.ASRouterPreferences.console.debug(m.id, " filtered by provider");
   1924          return false;
   1925        }
   1926        if (template && m.template !== template) {
   1927          lazy.ASRouterPreferences.console.debug(m.id, " filtered by template");
   1928          return false;
   1929        }
   1930        if (triggerId && !m.trigger) {
   1931          lazy.ASRouterPreferences.console.debug(m.id, " filtered by trigger");
   1932          return false;
   1933        }
   1934        if (triggerId && m.trigger.id !== triggerId) {
   1935          lazy.ASRouterPreferences.console.debug(
   1936            m.id,
   1937            " filtered by triggerId"
   1938          );
   1939          return false;
   1940        }
   1941        // Show message after checking it's  profile scope.
   1942        if (!this.hasValidProfileScope(m)) {
   1943          lazy.ASRouterPreferences.console.debug(
   1944            m.id,
   1945            " filtered because of invalid multi profile scope"
   1946          );
   1947          return false;
   1948        }
   1949        if (!this.isUnblockedMessage(m)) {
   1950          lazy.ASRouterPreferences.console.debug(
   1951            m.id,
   1952            " filtered because blocked"
   1953          );
   1954          return false;
   1955        }
   1956        if (!this.isBelowFrequencyCaps(m)) {
   1957          lazy.ASRouterPreferences.console.debug(
   1958            m.id,
   1959            " filtered because capped"
   1960          );
   1961          return false;
   1962        }
   1963 
   1964        if (shouldCache !== false) {
   1965          shouldCache = JEXL_PROVIDER_CACHE.has(m.provider);
   1966        }
   1967 
   1968        return true;
   1969      });
   1970 
   1971    if (!messages.length) {
   1972      return returnAll ? messages : null;
   1973    }
   1974 
   1975    const context = this._getMessagesContext();
   1976 
   1977    // Find a message that matches the targeting context as well as the trigger context (if one is provided)
   1978    // If no trigger is provided, we should find a message WITHOUT a trigger property defined.
   1979    return lazy.ASRouterTargeting.findMatchingMessage({
   1980      messages,
   1981      trigger: triggerId && {
   1982        id: triggerId,
   1983        param: triggerParam,
   1984        context: triggerContext,
   1985      },
   1986      context,
   1987      onError: this._handleTargetingError,
   1988      ordered,
   1989      shouldCache,
   1990      returnAll,
   1991    });
   1992  }
   1993 
   1994  setMessageById({ id, ...data }, force, browser) {
   1995    return this.routeCFRMessage(this.getMessageById(id), browser, data, force);
   1996  }
   1997 
   1998  blockMessageById(idOrIds) {
   1999    lazy.ASRouterPreferences.console.debug(
   2000      "blockMessageById called, idOrIds = ",
   2001      idOrIds
   2002    );
   2003    lazy.ASRouterPreferences.console.trace();
   2004 
   2005    const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
   2006 
   2007    return this.setState(state => {
   2008      const messageBlockList = [...state.messageBlockList];
   2009      const messageImpressions = { ...state.messageImpressions };
   2010      const multiProfileMessageBlocklist = [
   2011        ...state.multiProfileMessageBlocklist,
   2012      ];
   2013      const multiProfileMessageImpressions = {
   2014        ...state.multiProfileMessageImpressions,
   2015      };
   2016 
   2017      idsToBlock.forEach(id => {
   2018        const message = state.messages.find(m => m.id === id);
   2019        const idToBlock = message && message.campaign ? message.campaign : id;
   2020        if (!messageBlockList.includes(idToBlock)) {
   2021          messageBlockList.push(idToBlock);
   2022        }
   2023        // When a message is blocked, its impressions should be cleared as well
   2024        delete messageImpressions[id];
   2025        // If selectable profiles are enabled && the message has a
   2026        // profile scope set, block it in all profiles
   2027        if (
   2028          lazy.ASRouterTargeting.Environment.canCreateSelectableProfiles &&
   2029          message.profileScope === PROFILE_MESSAGE_SCOPE.SINGLE
   2030        ) {
   2031          // Update sharedDb by adding the messageId to the MessageBlocklist
   2032          // and deleting the messageId impressions from MessageImpressions
   2033          this._storage.setSharedMessageBlocked(idToBlock);
   2034          if (!multiProfileMessageBlocklist.includes(idToBlock)) {
   2035            multiProfileMessageBlocklist.push(idToBlock);
   2036          }
   2037          // Clear profile Impression of blocked messageId
   2038          delete multiProfileMessageImpressions[idToBlock];
   2039        }
   2040      });
   2041 
   2042      this._storage.set("messageBlockList", messageBlockList);
   2043      this._storage.set("messageImpressions", messageImpressions);
   2044      return {
   2045        messageBlockList,
   2046        messageImpressions,
   2047        multiProfileMessageBlocklist,
   2048        multiProfileMessageImpressions,
   2049      };
   2050    });
   2051  }
   2052 
   2053  unblockMessageById(idOrIds) {
   2054    const idsToUnblock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
   2055 
   2056    return this.setState(state => {
   2057      const messageBlockList = [...state.messageBlockList];
   2058      const multiProfileMessageBlocklist = [
   2059        ...state.multiProfileMessageBlocklist,
   2060      ];
   2061      idsToUnblock
   2062        .map(id => state.messages.find(m => m.id === id))
   2063        // Remove all `id`s from the message block list
   2064        .forEach(message => {
   2065          const idToUnblock =
   2066            message && message.campaign ? message.campaign : message.id;
   2067          messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1);
   2068          if (
   2069            lazy.ASRouterTargeting.Environment.canCreateSelectableProfiles &&
   2070            message.profileScope === PROFILE_MESSAGE_SCOPE.SINGLE
   2071          ) {
   2072            this._storage.setSharedMessageBlocked(idToUnblock, false);
   2073            multiProfileMessageBlocklist.splice(
   2074              multiProfileMessageBlocklist.indexOf(idToUnblock),
   2075              1
   2076            );
   2077          }
   2078        });
   2079 
   2080      this._storage.set("messageBlockList", messageBlockList);
   2081      return { messageBlockList, multiProfileMessageBlocklist };
   2082    });
   2083  }
   2084 
   2085  resetGroupsState() {
   2086    const newGroupImpressions = {};
   2087    for (let { id } of this.state.groups) {
   2088      newGroupImpressions[id] = [];
   2089    }
   2090    // Update storage
   2091    this._storage.set("groupImpressions", newGroupImpressions);
   2092    return this.setState(() => ({
   2093      groupImpressions: newGroupImpressions,
   2094    }));
   2095  }
   2096 
   2097  resetMessageState() {
   2098    const newMessageImpressions = {};
   2099    for (let { id } of this.state.messages) {
   2100      newMessageImpressions[id] = [];
   2101      // Update shared storage if needed
   2102      if (lazy.ASRouterTargeting.Environment.canCreateSelectableProfiles) {
   2103        this._storage.setSharedMessageImpressions(id, null);
   2104      }
   2105    }
   2106    // Update storage
   2107    this._storage.set("messageImpressions", newMessageImpressions);
   2108    return this.setState(() => ({
   2109      messageImpressions: newMessageImpressions,
   2110    }));
   2111  }
   2112 
   2113  resetScreenImpressions() {
   2114    const newScreenImpressions = {};
   2115    this._storage.set("screenImpressions", newScreenImpressions);
   2116    return this.setState(() => ({ screenImpressions: newScreenImpressions }));
   2117  }
   2118 
   2119  /**
   2120   * Edit the ASRouter state directly. For use by the ASRouter devtools.
   2121   * Requires browser.newtabpage.activity-stream.asrouter.devtoolsEnabled
   2122   *
   2123   * @param {string} key Key of the property to edit, one of:
   2124   *   | "groupImpressions"
   2125   *   | "messageImpressions"
   2126   *   | "screenImpressions"
   2127   *   | "messageBlockList"
   2128   * @param {object|string[]} value New value to set for state[key]
   2129   * @returns {Promise<unknown>} The new value in state
   2130   */
   2131  async editState(key, value) {
   2132    if (!lazy.ASRouterPreferences.devtoolsEnabled) {
   2133      throw new Error("Editing state is only allowed in devtools mode");
   2134    }
   2135    switch (key) {
   2136      case "groupImpressions":
   2137      case "messageImpressions":
   2138      case "screenImpressions":
   2139        if (typeof value !== "object") {
   2140          throw new Error("Invalid impression data");
   2141        }
   2142        break;
   2143      case "messageBlockList":
   2144        if (!Array.isArray(value)) {
   2145          throw new Error("Invalid message block list");
   2146        }
   2147        break;
   2148      default:
   2149        throw new Error("Invalid state key");
   2150    }
   2151    const newState = await this.setState(() => {
   2152      this._storage.set(key, value);
   2153      return { [key]: value };
   2154    });
   2155    return newState[key];
   2156  }
   2157 
   2158  _validPreviewEndpoint(url) {
   2159    try {
   2160      const endpoint = new URL(url);
   2161      if (!this.ALLOWLIST_HOSTS[endpoint.host]) {
   2162        console.error(
   2163          `The preview URL host ${endpoint.host} is not in the list of allowed hosts.`
   2164        );
   2165      }
   2166      if (endpoint.protocol !== "https:") {
   2167        console.error("The URL protocol is not https.");
   2168      }
   2169      return (
   2170        endpoint.protocol === "https:" && this.ALLOWLIST_HOSTS[endpoint.host]
   2171      );
   2172    } catch (e) {
   2173      return false;
   2174    }
   2175  }
   2176 
   2177  _loadAllowHosts() {
   2178    return DEFAULT_ALLOWLIST_HOSTS;
   2179  }
   2180 
   2181  // To be passed to ASRouterTriggerListeners
   2182  _triggerHandler(browser, trigger) {
   2183    // Disable ASRouterTriggerListeners in kiosk mode.
   2184    if (lazy.BrowserHandler.kiosk) {
   2185      return Promise.resolve();
   2186    }
   2187    return this.sendTriggerMessage({ ...trigger, browser });
   2188  }
   2189 
   2190  /**
   2191   * Simple wrapper to make test mocking easier
   2192   *
   2193   * @returns {Promise} resolves when the attribution string has been set
   2194   * succesfully.
   2195   */
   2196  setAttributionString(attrStr) {
   2197    return lazy.MacAttribution.setAttributionString(attrStr);
   2198  }
   2199 
   2200  /**
   2201   * forceAttribution - this function should only be called from within about:newtab#asrouter.
   2202   * It forces the browser attribution to be set to something specified in asrouter admin
   2203   * tools, and reloads the providers in order to get messages that are dependant on this
   2204   * attribution data (see Return to AMO flow in bug 1475354 for example). Note - OSX and Windows only
   2205   *
   2206   * @param {data} Object an object containing the attribtion data that came from asrouter admin page
   2207   */
   2208  async forceAttribution(data) {
   2209    // Extract the parameters from data that will make up the referrer url
   2210    const attributionData = lazy.AttributionCode.allowedCodeKeys
   2211      .map(key => `${key}=${encodeURIComponent(data[key] || "")}`)
   2212      .join("&");
   2213    if (AppConstants.platform === "win") {
   2214      // The whole attribution data is encoded (again) for windows
   2215      await lazy.AttributionCode.writeAttributionFile(
   2216        encodeURIComponent(attributionData)
   2217      );
   2218    } else if (AppConstants.platform === "macosx") {
   2219      await this.setAttributionString(encodeURIComponent(attributionData));
   2220    }
   2221 
   2222    // Clear cache call is only possible in a testing environment
   2223    Services.env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
   2224 
   2225    // Clear and refresh Attribution, and then fetch the messages again to update
   2226    lazy.AttributionCode._clearCache();
   2227    await lazy.AttributionCode.getAttrDataAsync();
   2228    await this._updateMessageProviders();
   2229    return this.loadMessagesFromAllProviders();
   2230  }
   2231 
   2232  async sendPBNewTabMessage({ hideDefault }) {
   2233    let message = null;
   2234    const PromoInfo = {
   2235      FOCUS: { enabledPref: "browser.promo.focus.enabled" },
   2236      VPN: { enabledPref: "browser.vpn_promo.enabled" },
   2237      PIN: { enabledPref: "browser.promo.pin.enabled" },
   2238      COOKIE_BANNERS: { enabledPref: "browser.promo.cookiebanners.enabled" },
   2239    };
   2240    await this.loadMessagesFromAllProviders();
   2241 
   2242    // If message has hideDefault property set to true
   2243    // remove from state all pb_newtab messages with type default
   2244    if (hideDefault) {
   2245      await this.setState(state => ({
   2246        messages: state.messages.filter(
   2247          m => !(m.template === "pb_newtab" && m.type === "default")
   2248        ),
   2249      }));
   2250    }
   2251 
   2252    // Remove from state pb_newtab messages with PromoType disabled
   2253    await this.setState(state => ({
   2254      messages: state.messages.filter(
   2255        m =>
   2256          !(
   2257            m.template === "pb_newtab" &&
   2258            !Services.prefs.getBoolPref(
   2259              PromoInfo[m.content?.promoType]?.enabledPref,
   2260              true
   2261            )
   2262          )
   2263      ),
   2264    }));
   2265 
   2266    const timerId = Glean.messagingSystem.messageRequestTime.start();
   2267    message = await this.handleMessageRequest({
   2268      template: "pb_newtab",
   2269    });
   2270    Glean.messagingSystem.messageRequestTime.stopAndAccumulate(timerId);
   2271 
   2272    // Format urls if any are defined
   2273    ["infoLinkUrl"].forEach(key => {
   2274      if (message?.content?.[key]) {
   2275        message.content[key] = Services.urlFormatter.formatURL(
   2276          message.content[key]
   2277        );
   2278      }
   2279    });
   2280 
   2281    return { message };
   2282  }
   2283 
   2284  _recordReachEvent(message) {
   2285    lazy.ASRouterPreferences.console.log(
   2286      "In ASRouter._recordReachEvent for message: ",
   2287      message
   2288    );
   2289 
   2290    try {
   2291      const messageGroup = message.forReachEvent.group;
   2292      // Keeping parity with legacy event telemetry values that only accepted
   2293      // underscores in featureID passed to event telemetry.
   2294      // Glean expects the metric name in camelCase.
   2295      const name = messageGroup
   2296        .replace(/-/g, "_")
   2297        .split("_")
   2298        .map(word => word[0].toUpperCase() + word.slice(1))
   2299        .join("");
   2300      const extra = {
   2301        value: message.experimentSlug,
   2302        branches: message.branchSlug,
   2303      };
   2304      Glean.messagingExperiments[`reach${name}`].record(extra);
   2305    } catch (ex) {
   2306      // XXX ideally send this to telemetry, maybe along with a stack trace
   2307      lazy.ASRouterPreferences.console.error(
   2308        "Error recording reach event: ",
   2309        ex
   2310      );
   2311    }
   2312  }
   2313 
   2314  /**
   2315   * Fire a trigger, look for a matching message, and route it to the
   2316   * appropriate message handler/messaging surface.
   2317   *
   2318   * @param {object} trigger
   2319   * @param {string} trigger.id the name of the trigger, e.g. "openURL"
   2320   * @param {object} [trigger.param] an object with host, url, type, etc. keys
   2321   *   whose values are used to match against the message's trigger params
   2322   * @param {object} [trigger.context] an object with data about the source of
   2323   *   the trigger, matched against the message's targeting expression
   2324   * @param {MozBrowser} trigger.browser the browser to route messages to
   2325   * @param {boolean} [skipLoadingMessages=false] pass true to skip looking for
   2326   *   new messages. use when calling from loadMessagesFromAllProviders to avoid
   2327   *   recursion. we call this from loadMessagesFromAllProviders in order to
   2328   *   fire the messagesLoaded trigger.
   2329   * @returns {Promise<object>}
   2330   *   Resolves to an object with the routed message.
   2331   */
   2332  async sendTriggerMessage(
   2333    { browser, ...trigger },
   2334    skipLoadingMessages = false
   2335  ) {
   2336    lazy.ASRouterPreferences.console.debug("entering sendTriggerMessage");
   2337    lazy.ASRouterPreferences.console.debug("trigger.id = ", trigger.id);
   2338    if (!skipLoadingMessages) {
   2339      await this.loadMessagesFromAllProviders();
   2340    }
   2341    // Implement the global `browserIsSelected` context property.
   2342    if (trigger && browser?.constructor.name === "MozBrowser") {
   2343      if (!Object.prototype.hasOwnProperty.call(trigger, "context")) {
   2344        trigger.context = {};
   2345      }
   2346      if (typeof trigger.context === "object") {
   2347        trigger.context.isAIWindow = !!lazy.AIWindow?.isAIWindowActive?.(
   2348          browser.ownerGlobal
   2349        );
   2350        trigger.context.browserIsSelected =
   2351          trigger.context.browserIsSelected ||
   2352          browser === browser.ownerGlobal.gBrowser?.selectedBrowser;
   2353      }
   2354    }
   2355    const timerId = Glean.messagingSystem.messageRequestTime.start();
   2356    // Return all the messages so that it can record the Reach event
   2357    const messages =
   2358      (await this.handleMessageRequest({
   2359        triggerId: trigger.id,
   2360        triggerParam: trigger.param,
   2361        triggerContext: trigger.context,
   2362        returnAll: true,
   2363      })) || [];
   2364    Glean.messagingSystem.messageRequestTime.stopAndAccumulate(timerId);
   2365 
   2366    // Record the Reach event for all the messages with `forReachEvent`,
   2367    // only send the first message without forReachEvent to the target
   2368    const nonReachMessages = [];
   2369    for (const message of messages) {
   2370      if (message.forReachEvent) {
   2371        if (!message.forReachEvent.sent) {
   2372          this._recordReachEvent(message);
   2373          message.forReachEvent.sent = true;
   2374        }
   2375      } else {
   2376        lazy.ASRouterPreferences.console.debug(
   2377          "about to push a nonReachMessage: ",
   2378          message
   2379        );
   2380        nonReachMessages.push(message);
   2381      }
   2382    }
   2383 
   2384    if (nonReachMessages.length) {
   2385      let featureId = nonReachMessages[0]._nimbusFeature;
   2386      if (featureId) {
   2387        lazy.NimbusFeatures[featureId].recordExposureEvent({ once: true });
   2388      }
   2389    }
   2390 
   2391    return this.routeCFRMessage(
   2392      nonReachMessages[0] || null,
   2393      browser,
   2394      trigger,
   2395      false
   2396    );
   2397  }
   2398 
   2399  async _onExperimentEnrollmentsUpdated() {
   2400    const experimentProvider = this.state.providers.find(
   2401      p => p.id === "messaging-experiments"
   2402    );
   2403    if (!experimentProvider?.enabled) {
   2404      return;
   2405    }
   2406    await this.loadMessagesFromAllProviders([experimentProvider]);
   2407  }
   2408 
   2409  async forcePBWindow(browser, msg) {
   2410    const privateBrowserOpener = await new Promise(
   2411      (
   2412        resolveOnContentBrowserCreated // wrap this in a promise to give back the right browser
   2413      ) =>
   2414        browser.ownerGlobal.openTrustedLinkIn(
   2415          "about:privatebrowsing?debug",
   2416          "window",
   2417          {
   2418            private: true,
   2419            triggeringPrincipal:
   2420              Services.scriptSecurityManager.getSystemPrincipal({}),
   2421            resolveOnContentBrowserCreated,
   2422            opener: "devtools",
   2423          }
   2424        )
   2425    );
   2426 
   2427    lazy.setTimeout(() => {
   2428      // setTimeout is necessary to make sure the private browsing window has a chance to open before the message is sent
   2429      privateBrowserOpener.browsingContext.currentWindowGlobal
   2430        .getActor("AboutPrivateBrowsing")
   2431        .sendAsyncMessage("ShowDevToolsMessage", msg);
   2432    }, 200);
   2433 
   2434    return privateBrowserOpener;
   2435  }
   2436 }
   2437 
   2438 /**
   2439 * ASRouter - singleton instance of _ASRouter that controls all messages
   2440 * in the new tab page.
   2441 */
   2442 export const ASRouter = new _ASRouter();