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"use strict";
6
7const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
8const { XPCOMUtils } = ChromeUtils.import(
9  "resource://gre/modules/XPCOMUtils.jsm"
10);
11XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
12XPCOMUtils.defineLazyModuleGetters(this, {
13  AppConstants: "resource://gre/modules/AppConstants.jsm",
14  BookmarkPanelHub: "resource://activity-stream/lib/BookmarkPanelHub.jsm",
15  SnippetsTestMessageProvider:
16    "resource://activity-stream/lib/SnippetsTestMessageProvider.jsm",
17  PanelTestProvider: "resource://activity-stream/lib/PanelTestProvider.jsm",
18  ToolbarBadgeHub: "resource://activity-stream/lib/ToolbarBadgeHub.jsm",
19  ToolbarPanelHub: "resource://activity-stream/lib/ToolbarPanelHub.jsm",
20  MomentsPageHub: "resource://activity-stream/lib/MomentsPageHub.jsm",
21  InfoBar: "resource://activity-stream/lib/InfoBar.jsm",
22  ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
23  ASRouterPreferences: "resource://activity-stream/lib/ASRouterPreferences.jsm",
24  TARGETING_PREFERENCES:
25    "resource://activity-stream/lib/ASRouterPreferences.jsm",
26  ASRouterTriggerListeners:
27    "resource://activity-stream/lib/ASRouterTriggerListeners.jsm",
28  CFRMessageProvider: "resource://activity-stream/lib/CFRMessageProvider.jsm",
29  KintoHttpClient: "resource://services-common/kinto-http-client.js",
30  Downloader: "resource://services-settings/Attachments.jsm",
31  RemoteL10n: "resource://activity-stream/lib/RemoteL10n.jsm",
32  ExperimentAPI: "resource://nimbus/ExperimentAPI.jsm",
33  SpecialMessageActions:
34    "resource://messaging-system/lib/SpecialMessageActions.jsm",
35  TargetingContext: "resource://messaging-system/targeting/Targeting.jsm",
36  MacAttribution: "resource:///modules/MacAttribution.jsm",
37});
38XPCOMUtils.defineLazyServiceGetters(this, {
39  BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"],
40});
41const { actionCreators: ac } = ChromeUtils.import(
42  "resource://activity-stream/common/Actions.jsm"
43);
44
45const { CFRMessageProvider } = ChromeUtils.import(
46  "resource://activity-stream/lib/CFRMessageProvider.jsm"
47);
48const { OnboardingMessageProvider } = ChromeUtils.import(
49  "resource://activity-stream/lib/OnboardingMessageProvider.jsm"
50);
51const { RemoteSettings } = ChromeUtils.import(
52  "resource://services-settings/remote-settings.js"
53);
54const { CFRPageActions } = ChromeUtils.import(
55  "resource://activity-stream/lib/CFRPageActions.jsm"
56);
57const { AttributionCode } = ChromeUtils.import(
58  "resource:///modules/AttributionCode.jsm"
59);
60
61// List of hosts for endpoints that serve router messages.
62// Key is allowed host, value is a name for the endpoint host.
63const DEFAULT_ALLOWLIST_HOSTS = {
64  "activity-stream-icons.services.mozilla.com": "production",
65  "snippets-admin.mozilla.org": "preview",
66};
67const SNIPPETS_ENDPOINT_ALLOWLIST =
68  "browser.newtab.activity-stream.asrouter.allowHosts";
69// Max possible impressions cap for any message
70const MAX_MESSAGE_LIFETIME_CAP = 100;
71
72const LOCAL_MESSAGE_PROVIDERS = {
73  OnboardingMessageProvider,
74  CFRMessageProvider,
75};
76const STARTPAGE_VERSION = "6";
77
78// Remote Settings
79const RS_SERVER_PREF = "services.settings.server";
80const RS_MAIN_BUCKET = "main";
81const RS_COLLECTION_L10N = "ms-language-packs"; // "ms" stands for Messaging System
82const RS_PROVIDERS_WITH_L10N = ["cfr", "cfr-fxa", "whats-new-panel"];
83const RS_FLUENT_VERSION = "v1";
84const RS_FLUENT_RECORD_PREFIX = `cfr-${RS_FLUENT_VERSION}`;
85const RS_DOWNLOAD_MAX_RETRIES = 2;
86// This is the list of providers for which we want to cache the targeting
87// expression result and reuse between calls. Cache duration is defined in
88// ASRouterTargeting where evaluation takes place.
89const JEXL_PROVIDER_CACHE = new Set(["snippets"]);
90
91// To observe the app locale change notification.
92const TOPIC_INTL_LOCALE_CHANGED = "intl:app-locales-changed";
93// To observe the pref that controls if ASRouter should use the remote Fluent files for l10n.
94const USE_REMOTE_L10N_PREF =
95  "browser.newtabpage.activity-stream.asrouter.useRemoteL10n";
96
97// Experiment groups that need to report the reach event in Messaging-Experiments.
98// If you're adding new groups to it, make sure they're also added in the
99// `messaging_experiments.reach.objects` defined in "toolkit/components/telemetry/Events.yaml"
100const REACH_EVENT_GROUPS = ["cfr", "moments-page"];
101const REACH_EVENT_CATEGORY = "messaging_experiments";
102const REACH_EVENT_METHOD = "reach";
103
104const MessageLoaderUtils = {
105  STARTPAGE_VERSION,
106  REMOTE_LOADER_CACHE_KEY: "RemoteLoaderCache",
107  _errors: [],
108
109  reportError(e) {
110    Cu.reportError(e);
111    this._errors.push({
112      timestamp: new Date(),
113      error: { message: e.toString(), stack: e.stack },
114    });
115  },
116
117  get errors() {
118    const errors = this._errors;
119    this._errors = [];
120    return errors;
121  },
122
123  /**
124   * _localLoader - Loads messages for a local provider (i.e. one that lives in mozilla central)
125   *
126   * @param {obj} provider An AS router provider
127   * @param {Array} provider.messages An array of messages
128   * @returns {Array} the array of messages
129   */
130  _localLoader(provider) {
131    return provider.messages;
132  },
133
134  async _localJsonLoader(provider) {
135    let payload;
136    try {
137      payload = await (
138        await fetch(provider.location, {
139          credentials: "omit",
140        })
141      ).json();
142    } catch (e) {
143      return [];
144    }
145
146    return payload.messages;
147  },
148
149  async _remoteLoaderCache(storage) {
150    let allCached;
151    try {
152      allCached =
153        (await storage.get(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY)) || {};
154    } catch (e) {
155      // istanbul ignore next
156      MessageLoaderUtils.reportError(e);
157      // istanbul ignore next
158      allCached = {};
159    }
160    return allCached;
161  },
162
163  /**
164   * _remoteLoader - Loads messages for a remote provider
165   *
166   * @param {obj} provider An AS router provider
167   * @param {string} provider.url An endpoint that returns an array of messages as JSON
168   * @param {obj} options.storage A storage object with get() and set() methods for caching.
169   * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
170   */
171  async _remoteLoader(provider, options) {
172    let remoteMessages = [];
173    if (provider.url) {
174      const allCached = await MessageLoaderUtils._remoteLoaderCache(
175        options.storage
176      );
177      const cached = allCached[provider.id];
178      let etag;
179
180      if (
181        cached &&
182        cached.url === provider.url &&
183        cached.version === STARTPAGE_VERSION
184      ) {
185        const { lastFetched, messages } = cached;
186        if (
187          !MessageLoaderUtils.shouldProviderUpdate({
188            ...provider,
189            lastUpdated: lastFetched,
190          })
191        ) {
192          // Cached messages haven't expired, return early.
193          return messages;
194        }
195        etag = cached.etag;
196        remoteMessages = messages;
197      }
198
199      let headers = new Headers();
200      if (etag) {
201        headers.set("If-None-Match", etag);
202      }
203
204      let response;
205      try {
206        response = await fetch(provider.url, { headers, credentials: "omit" });
207      } catch (e) {
208        MessageLoaderUtils.reportError(e);
209      }
210      if (
211        response &&
212        response.ok &&
213        response.status >= 200 &&
214        response.status < 400
215      ) {
216        let jsonResponse;
217        try {
218          jsonResponse = await response.json();
219        } catch (e) {
220          MessageLoaderUtils.reportError(e);
221          return remoteMessages;
222        }
223        if (jsonResponse && jsonResponse.messages) {
224          remoteMessages = jsonResponse.messages.map(msg => ({
225            ...msg,
226            provider_url: provider.url,
227          }));
228
229          // Cache the results if this isn't a preview URL.
230          if (provider.updateCycleInMs > 0) {
231            etag = response.headers.get("ETag");
232            const cacheInfo = {
233              messages: remoteMessages,
234              etag,
235              lastFetched: Date.now(),
236              version: STARTPAGE_VERSION,
237            };
238
239            options.storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, {
240              ...allCached,
241              [provider.id]: cacheInfo,
242            });
243          }
244        } else {
245          MessageLoaderUtils.reportError(
246            `No messages returned from ${provider.url}.`
247          );
248        }
249      } else if (response) {
250        MessageLoaderUtils.reportError(
251          `Invalid response status ${response.status} from ${provider.url}.`
252        );
253      }
254    }
255    return remoteMessages;
256  },
257
258  /**
259   * _remoteSettingsLoader - Loads messages for a RemoteSettings provider
260   *
261   * Note:
262   * 1). Both "cfr" and "cfr-fxa" require the Fluent file for l10n, so there is
263   * another file downloading phase for those two providers after their messages
264   * are successfully fetched from Remote Settings. Currently, they share the same
265   * attachment of the record "${RS_FLUENT_RECORD_PREFIX}-${locale}" in the
266   * "ms-language-packs" collection. E.g. for "en-US" with version "v1",
267   * the Fluent file is attched to the record with ID "cfr-v1-en-US".
268   *
269   * 2). The Remote Settings downloader is able to detect the duplicate download
270   * requests for the same attachment and ignore the redundent requests automatically.
271   *
272   * @param {obj} provider An AS router provider
273   * @param {string} provider.id The id of the provider
274   * @param {string} provider.bucket The name of the Remote Settings bucket
275   * @param {func} options.dispatchCFRAction dispatch an action the main AS Store
276   * @returns {Promise} resolves with an array of messages, or an empty array if none could be fetched
277   */
278  async _remoteSettingsLoader(provider, options) {
279    let messages = [];
280    if (provider.bucket) {
281      try {
282        messages = await MessageLoaderUtils._getRemoteSettingsMessages(
283          provider.bucket
284        );
285        if (!messages.length) {
286          MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
287            "ASR_RS_NO_MESSAGES",
288            provider.id,
289            options.dispatchCFRAction
290          );
291        } else if (
292          RS_PROVIDERS_WITH_L10N.includes(provider.id) &&
293          (RemoteL10n.isLocaleSupported(Services.locale.appLocaleAsBCP47) ||
294            // While it's not a valid locale, "und" is commonly observed on
295            // Linux platforms. Per l10n team, it's reasonable to fallback to
296            // "en-US", therefore, we should allow the fetch for it.
297            Services.locale.appLocaleAsBCP47 === "und")
298        ) {
299          let locale = Services.locale.appLocaleAsBCP47;
300          // Fallback to "en-US" if locale is "und"
301          if (locale === "und") {
302            locale = "en-US";
303          }
304          const recordId = `${RS_FLUENT_RECORD_PREFIX}-${locale}`;
305          const kinto = new KintoHttpClient(
306            Services.prefs.getStringPref(RS_SERVER_PREF)
307          );
308          const record = await kinto
309            .bucket(RS_MAIN_BUCKET)
310            .collection(RS_COLLECTION_L10N)
311            .getRecord(recordId);
312          if (record && record.data) {
313            const downloader = new Downloader(
314              RS_MAIN_BUCKET,
315              RS_COLLECTION_L10N
316            );
317            // Await here in order to capture the exceptions for reporting.
318            await downloader.download(record.data, {
319              retries: RS_DOWNLOAD_MAX_RETRIES,
320            });
321            RemoteL10n.reloadL10n();
322          } else {
323            MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
324              "ASR_RS_NO_MESSAGES",
325              RS_COLLECTION_L10N,
326              options.dispatchCFRAction
327            );
328          }
329        }
330      } catch (e) {
331        MessageLoaderUtils._handleRemoteSettingsUndesiredEvent(
332          "ASR_RS_ERROR",
333          provider.id,
334          options.dispatchCFRAction
335        );
336        MessageLoaderUtils.reportError(e);
337      }
338    }
339    return messages;
340  },
341
342  _getRemoteSettingsMessages(bucket) {
343    return RemoteSettings(bucket).get();
344  },
345
346  async _experimentsAPILoader(provider, options) {
347    await ExperimentAPI.ready();
348
349    let experiments = [];
350    for (const featureId of provider.messageGroups) {
351      let experimentData = ExperimentAPI.getExperiment({ featureId });
352      // Not enrolled in any experiment for this feature, we can skip
353      if (!experimentData) {
354        continue;
355      }
356
357      // If the feature is not enabled there is no message to send back.
358      // Other branches might be enabled so we check those as well in case we
359      // need to send a reach ping.
360      let featureData = experimentData.branch.feature;
361      if (featureData.enabled) {
362        experiments.push({
363          forExposureEvent: {
364            experimentSlug: experimentData.slug,
365            branchSlug: experimentData.branch.slug,
366          },
367          ...featureData.value,
368        });
369      }
370
371      if (!REACH_EVENT_GROUPS.includes(featureId)) {
372        continue;
373      }
374      // Check other sibling branches for triggers, add them to the return
375      // array if found any. The `forReachEvent` label is used to identify
376      // those branches so that they would only used to record the Reach
377      // event.
378      const branches =
379        (await ExperimentAPI.getAllBranches(experimentData.slug)) || [];
380      for (const branch of branches) {
381        let branchValue = branch.feature.value;
382        if (branch.slug !== experimentData.branch.slug && branchValue.trigger) {
383          experiments.push({
384            forReachEvent: { sent: false, group: featureId },
385            experimentSlug: experimentData.slug,
386            branchSlug: branch.slug,
387            ...branchValue,
388          });
389        }
390      }
391    }
392
393    return experiments;
394  },
395
396  _handleRemoteSettingsUndesiredEvent(event, providerId, dispatchCFRAction) {
397    if (dispatchCFRAction) {
398      dispatchCFRAction(
399        ac.ASRouterUserEvent({
400          action: "asrouter_undesired_event",
401          event,
402          message_id: "n/a",
403          event_context: providerId,
404        })
405      );
406    }
407  },
408
409  /**
410   * _getMessageLoader - return the right loading function given the provider's type
411   *
412   * @param {obj} provider An AS Router provider
413   * @returns {func} A loading function
414   */
415  _getMessageLoader(provider) {
416    switch (provider.type) {
417      case "remote":
418        return this._remoteLoader;
419      case "remote-settings":
420        return this._remoteSettingsLoader;
421      case "json":
422        return this._localJsonLoader;
423      case "remote-experiments":
424        return this._experimentsAPILoader;
425      case "local":
426      default:
427        return this._localLoader;
428    }
429  },
430
431  /**
432   * shouldProviderUpdate - Given the current time, should a provider update its messages?
433   *
434   * @param {any} provider An AS Router provider
435   * @param {int} provider.updateCycleInMs The number of milliseconds we should wait between updates
436   * @param {Date} provider.lastUpdated If the provider has been updated, the time the last update occurred
437   * @param {Date} currentTime The time we should check against. (defaults to Date.now())
438   * @returns {bool} Should an update happen?
439   */
440  shouldProviderUpdate(provider, currentTime = Date.now()) {
441    return (
442      !(provider.lastUpdated >= 0) ||
443      currentTime - provider.lastUpdated > provider.updateCycleInMs
444    );
445  },
446
447  async _loadDataForProvider(provider, options) {
448    const loader = this._getMessageLoader(provider);
449    let messages = await loader(provider, options);
450    // istanbul ignore if
451    if (!messages) {
452      messages = [];
453      MessageLoaderUtils.reportError(
454        new Error(
455          `Tried to load messages for ${provider.id} but the result was not an Array.`
456        )
457      );
458    }
459
460    return { messages };
461  },
462
463  /**
464   * loadMessagesForProvider - Load messages for a provider, given the provider's type.
465   *
466   * @param {obj} provider An AS Router provider
467   * @param {string} provider.type An AS Router provider type (defaults to "local")
468   * @param {obj} options.storage A storage object with get() and set() methods for caching.
469   * @param {func} options.dispatchCFRAction dispatch an action the main AS Store
470   * @returns {obj} Returns an object with .messages (an array of messages) and .lastUpdated (the time the messages were updated)
471   */
472  async loadMessagesForProvider(provider, options) {
473    let { messages } = await this._loadDataForProvider(provider, options);
474    // Filter out messages we temporarily want to exclude
475    if (provider.exclude && provider.exclude.length) {
476      messages = messages.filter(
477        message => !provider.exclude.includes(message.id)
478      );
479    }
480    const lastUpdated = Date.now();
481    return {
482      messages: messages
483        .map(messageData => {
484          const message = {
485            weight: 100,
486            ...messageData,
487            groups: messageData.groups || [],
488            provider: provider.id,
489          };
490
491          return message;
492        })
493        .filter(message => message.weight > 0),
494      lastUpdated,
495      errors: MessageLoaderUtils.errors,
496    };
497  },
498
499  /**
500   * cleanupCache - Removes cached data of removed providers.
501   *
502   * @param {Array} providers A list of activer AS Router providers
503   */
504  async cleanupCache(providers, storage) {
505    const ids = providers.filter(p => p.type === "remote").map(p => p.id);
506    const cache = await MessageLoaderUtils._remoteLoaderCache(storage);
507    let dirty = false;
508    for (let id in cache) {
509      if (!ids.includes(id)) {
510        delete cache[id];
511        dirty = true;
512      }
513    }
514    if (dirty) {
515      await storage.set(MessageLoaderUtils.REMOTE_LOADER_CACHE_KEY, cache);
516    }
517  },
518};
519
520this.MessageLoaderUtils = MessageLoaderUtils;
521
522/**
523 * @class _ASRouter - Keeps track of all messages, UI surfaces, and
524 * handles blocking, rotation, etc. Inspecting ASRouter.state will
525 * tell you what the current displayed message is in all UI surfaces.
526 *
527 * Note: This is written as a constructor rather than just a plain object
528 * so that it can be more easily unit tested.
529 */
530class _ASRouter {
531  constructor(localProviders = LOCAL_MESSAGE_PROVIDERS) {
532    this.initialized = false;
533    this.clearChildMessages = null;
534    this.clearChildProviders = null;
535    this.updateAdminState = null;
536    this.sendTelemetry = null;
537    this.dispatchCFRAction = null;
538    this._storage = null;
539    this._resetInitialization();
540    this._state = {
541      providers: [],
542      messageBlockList: [],
543      messageImpressions: {},
544      messages: [],
545      groups: [],
546      errors: [],
547      localeInUse: Services.locale.appLocaleAsBCP47,
548    };
549    this._triggerHandler = this._triggerHandler.bind(this);
550    this._localProviders = localProviders;
551    this.blockMessageById = this.blockMessageById.bind(this);
552    this.unblockMessageById = this.unblockMessageById.bind(this);
553    this.handleMessageRequest = this.handleMessageRequest.bind(this);
554    this.addImpression = this.addImpression.bind(this);
555    this._handleTargetingError = this._handleTargetingError.bind(this);
556    this.onPrefChange = this.onPrefChange.bind(this);
557    this._onLocaleChanged = this._onLocaleChanged.bind(this);
558    this.isUnblockedMessage = this.isUnblockedMessage.bind(this);
559    this.unblockAll = this.unblockAll.bind(this);
560    this.forceWNPanel = this.forceWNPanel.bind(this);
561    Services.telemetry.setEventRecordingEnabled(REACH_EVENT_CATEGORY, true);
562  }
563
564  async onPrefChange(prefName) {
565    if (TARGETING_PREFERENCES.includes(prefName)) {
566      let invalidMessages = [];
567      // Notify all tabs of messages that have become invalid after pref change
568      const context = this._getMessagesContext();
569      const targetingContext = new TargetingContext(context);
570
571      for (const msg of this.state.messages.filter(this.isUnblockedMessage)) {
572        if (!msg.targeting) {
573          continue;
574        }
575        const isMatch = await targetingContext.evalWithDefault(msg.targeting);
576        if (!isMatch) {
577          invalidMessages.push(msg.id);
578        }
579      }
580      this.clearChildMessages(invalidMessages);
581    } else {
582      // Update message providers and fetch new messages on pref change
583      this._loadLocalProviders();
584      let invalidProviders = await this._updateMessageProviders();
585      if (invalidProviders.length) {
586        this.clearChildProviders(invalidProviders);
587      }
588      await this.loadMessagesFromAllProviders();
589      // Any change in user prefs can disable or enable groups
590      await this.setState(state => ({
591        groups: state.groups.map(this._checkGroupEnabled),
592      }));
593    }
594  }
595
596  // Fetch and decode the message provider pref JSON, and update the message providers
597  async _updateMessageProviders() {
598    const previousProviders = this.state.providers;
599    const providers = await Promise.all(
600      [
601        // If we have added a `preview` provider, hold onto it
602        ...previousProviders.filter(p => p.id === "preview"),
603        // The provider should be enabled and not have a user preference set to false
604        ...ASRouterPreferences.providers.filter(
605          p =>
606            p.enabled && ASRouterPreferences.getUserPreference(p.id) !== false
607        ),
608      ].map(async _provider => {
609        // make a copy so we don't modify the source of the pref
610        const provider = { ..._provider };
611
612        if (provider.type === "local" && !provider.messages) {
613          // Get the messages from the local message provider
614          const localProvider = this._localProviders[provider.localProvider];
615          provider.messages = [];
616          if (localProvider) {
617            provider.messages = await localProvider.getMessages();
618          }
619        }
620        if (provider.type === "remote" && provider.url) {
621          provider.url = provider.url.replace(
622            /%STARTPAGE_VERSION%/g,
623            STARTPAGE_VERSION
624          );
625          provider.url = Services.urlFormatter.formatURL(provider.url);
626        }
627        // Reset provider update timestamp to force message refresh
628        provider.lastUpdated = undefined;
629        return provider;
630      })
631    );
632
633    const providerIDs = providers.map(p => p.id);
634    let invalidProviders = [];
635
636    // Clear old messages for providers that are no longer enabled
637    for (const prevProvider of previousProviders) {
638      if (!providerIDs.includes(prevProvider.id)) {
639        invalidProviders.push(prevProvider.id);
640      }
641    }
642
643    return this.setState(prevState => ({
644      providers,
645      // Clear any messages from removed providers
646      messages: [
647        ...prevState.messages.filter(message =>
648          providerIDs.includes(message.provider)
649        ),
650      ],
651    })).then(() => invalidProviders);
652  }
653
654  get state() {
655    return this._state;
656  }
657
658  set state(value) {
659    throw new Error(
660      "Do not modify this.state directy. Instead, call this.setState(newState)"
661    );
662  }
663
664  /**
665   * _resetInitialization - adds the following to the instance:
666   *  .initialized {bool}            Has AS Router been initialized?
667   *  .waitForInitialized {Promise}  A promise that resolves when initializion is complete
668   *  ._finishInitializing {func}    A function that, when called, resolves the .waitForInitialized
669   *                                 promise and sets .initialized to true.
670   * @memberof _ASRouter
671   */
672  _resetInitialization() {
673    this.initialized = false;
674    this.initializing = false;
675    this.waitForInitialized = new Promise(resolve => {
676      this._finishInitializing = () => {
677        this.initialized = true;
678        this.initializing = false;
679        resolve();
680      };
681    });
682  }
683
684  /**
685   * Check all provided groups are enabled.
686   * @param groups Set of groups to verify
687   * @returns bool
688   */
689  hasGroupsEnabled(groups = []) {
690    return this.state.groups
691      .filter(({ id }) => groups.includes(id))
692      .every(({ enabled }) => enabled);
693  }
694
695  /**
696   * Verify that the provider block the message through the `exclude` field
697   * @param message Message to verify
698   * @returns bool
699   */
700  isExcludedByProvider(message) {
701    // preview snippets are never excluded
702    if (message.provider === "preview") {
703      return false;
704    }
705    const provider = this.state.providers.find(p => p.id === message.provider);
706    if (!provider) {
707      return true;
708    }
709    if (provider.exclude) {
710      return provider.exclude.includes(message.id);
711    }
712    return false;
713  }
714
715  /**
716   * Takes a group and sets the correct `enabled` state based on message config
717   * and user preferences
718   *
719   * @param {GroupConfig} group
720   * @returns {GroupConfig}
721   */
722  _checkGroupEnabled(group) {
723    return {
724      ...group,
725      enabled:
726        group.enabled &&
727        // And if defined user preferences are true. If multiple prefs are
728        // defined then at least one has to be enabled.
729        (Array.isArray(group.userPreferences)
730          ? group.userPreferences.some(pref =>
731              ASRouterPreferences.getUserPreference(pref)
732            )
733          : true),
734    };
735  }
736
737  /**
738   * Fetch all message groups and update Router.state.groups.
739   * There are two cases to consider:
740   * 1. The provider needs to update as determined by the update cycle
741   * 2. Some pref change occured which could invalidate one of the existing
742   *    groups.
743   */
744  async loadAllMessageGroups() {
745    const provider = this.state.providers.find(
746      p =>
747        p.id === "message-groups" && MessageLoaderUtils.shouldProviderUpdate(p)
748    );
749    let remoteMessages = null;
750    if (provider) {
751      const { messages } = await MessageLoaderUtils._loadDataForProvider(
752        provider,
753        {
754          storage: this._storage,
755          dispatchCFRAction: this.dispatchCFRAction,
756        }
757      );
758      remoteMessages = messages;
759    }
760    await this.setState(state => ({
761      // If fetching remote messages fails we default to existing state.groups.
762      groups: (remoteMessages || state.groups).map(this._checkGroupEnabled),
763    }));
764  }
765
766  /**
767   * loadMessagesFromAllProviders - Loads messages from all providers if they require updates.
768   *                                Checks the .lastUpdated field on each provider to see if updates are needed
769   * @memberof _ASRouter
770   */
771  async loadMessagesFromAllProviders() {
772    const needsUpdate = this.state.providers.filter(provider =>
773      MessageLoaderUtils.shouldProviderUpdate(provider)
774    );
775    await this.loadAllMessageGroups();
776    // Don't do extra work if we don't need any updates
777    if (needsUpdate.length) {
778      let newState = { messages: [], providers: [] };
779      for (const provider of this.state.providers) {
780        if (needsUpdate.includes(provider)) {
781          const {
782            messages,
783            lastUpdated,
784            errors,
785          } = await MessageLoaderUtils.loadMessagesForProvider(provider, {
786            storage: this._storage,
787            dispatchCFRAction: this.dispatchCFRAction,
788          });
789          newState.providers.push({ ...provider, lastUpdated, errors });
790          newState.messages = [...newState.messages, ...messages];
791        } else {
792          // Skip updating this provider's messages if no update is required
793          let messages = this.state.messages.filter(
794            msg => msg.provider === provider.id
795          );
796          newState.providers.push(provider);
797          newState.messages = [...newState.messages, ...messages];
798        }
799      }
800
801      // Some messages have triggers that require us to initalise trigger listeners
802      const unseenListeners = new Set(ASRouterTriggerListeners.keys());
803      for (const { trigger } of newState.messages) {
804        if (trigger && ASRouterTriggerListeners.has(trigger.id)) {
805          ASRouterTriggerListeners.get(trigger.id).init(
806            this._triggerHandler,
807            trigger.params,
808            trigger.patterns
809          );
810          unseenListeners.delete(trigger.id);
811        }
812      }
813      // We don't need these listeners, but they may have previously been
814      // initialised, so uninitialise them
815      for (const triggerID of unseenListeners) {
816        ASRouterTriggerListeners.get(triggerID).uninit();
817      }
818
819      // We don't want to cache preview endpoints, remove them after messages are fetched
820      await this.setState(this._removePreviewEndpoint(newState));
821      await this.cleanupImpressions();
822    }
823    return this.state;
824  }
825
826  async _maybeUpdateL10nAttachment() {
827    const { localeInUse } = this.state.localeInUse;
828    const newLocale = Services.locale.appLocaleAsBCP47;
829    if (newLocale !== localeInUse) {
830      const providers = [...this.state.providers];
831      let needsUpdate = false;
832      providers.forEach(provider => {
833        if (RS_PROVIDERS_WITH_L10N.includes(provider.id)) {
834          // Force to refresh the messages as well as the attachment.
835          provider.lastUpdated = undefined;
836          needsUpdate = true;
837        }
838      });
839      if (needsUpdate) {
840        await this.setState({
841          localeInUse: newLocale,
842          providers,
843        });
844        await this.loadMessagesFromAllProviders();
845      }
846    }
847    return this.state;
848  }
849
850  async _onLocaleChanged(subject, topic, data) {
851    await this._maybeUpdateL10nAttachment();
852  }
853
854  observe(aSubject, aTopic, aPrefName) {
855    switch (aPrefName) {
856      case USE_REMOTE_L10N_PREF:
857        CFRPageActions.reloadL10n();
858        break;
859    }
860  }
861
862  toWaitForInitFunc(func) {
863    return (...args) => this.waitForInitialized.then(() => func(...args));
864  }
865
866  /**
867   * init - Initializes the MessageRouter.
868   *
869   * @param {obj} parameters parameters to initialize ASRouter
870   * @memberof _ASRouter
871   */
872  async init({
873    storage,
874    sendTelemetry,
875    clearChildMessages,
876    clearChildProviders,
877    updateAdminState,
878    dispatchCFRAction,
879  }) {
880    if (this.initializing || this.initialized) {
881      return null;
882    }
883    this.initializing = true;
884    this._storage = storage;
885    this.ALLOWLIST_HOSTS = this._loadSnippetsAllowHosts();
886    this.clearChildMessages = this.toWaitForInitFunc(clearChildMessages);
887    this.clearChildProviders = this.toWaitForInitFunc(clearChildProviders);
888    // NOTE: This is only necessary to sync devtools and snippets when devtools is active.
889    this.updateAdminState = this.toWaitForInitFunc(updateAdminState);
890    this.sendTelemetry = sendTelemetry;
891    this.dispatchCFRAction = this.toWaitForInitFunc(dispatchCFRAction);
892
893    ASRouterPreferences.init();
894    ASRouterPreferences.addListener(this.onPrefChange);
895    BookmarkPanelHub.init(
896      this.handleMessageRequest,
897      this.addImpression,
898      this.sendTelemetry
899    );
900    ToolbarBadgeHub.init(this.waitForInitialized, {
901      handleMessageRequest: this.handleMessageRequest,
902      addImpression: this.addImpression,
903      blockMessageById: this.blockMessageById,
904      unblockMessageById: this.unblockMessageById,
905      sendTelemetry: this.sendTelemetry,
906    });
907    ToolbarPanelHub.init(this.waitForInitialized, {
908      getMessages: this.handleMessageRequest,
909      sendTelemetry: this.sendTelemetry,
910    });
911    MomentsPageHub.init(this.waitForInitialized, {
912      handleMessageRequest: this.handleMessageRequest,
913      addImpression: this.addImpression,
914      blockMessageById: this.blockMessageById,
915      sendTelemetry: this.sendTelemetry,
916    });
917
918    this._loadLocalProviders();
919
920    const messageBlockList =
921      (await this._storage.get("messageBlockList")) || [];
922    const messageImpressions =
923      (await this._storage.get("messageImpressions")) || {};
924    const groupImpressions =
925      (await this._storage.get("groupImpressions")) || {};
926    const previousSessionEnd =
927      (await this._storage.get("previousSessionEnd")) || 0;
928
929    await this.setState({
930      messageBlockList,
931      groupImpressions,
932      messageImpressions,
933      previousSessionEnd,
934      ...(ASRouterPreferences.specialConditions || {}),
935      initialized: false,
936    });
937    await this._updateMessageProviders();
938    await this.loadMessagesFromAllProviders();
939    await MessageLoaderUtils.cleanupCache(this.state.providers, storage);
940
941    SpecialMessageActions.blockMessageById = this.blockMessageById;
942    Services.obs.addObserver(this._onLocaleChanged, TOPIC_INTL_LOCALE_CHANGED);
943    Services.prefs.addObserver(USE_REMOTE_L10N_PREF, this);
944    // sets .initialized to true and resolves .waitForInitialized promise
945    this._finishInitializing();
946    return this.state;
947  }
948
949  uninit() {
950    this._storage.set("previousSessionEnd", Date.now());
951
952    this.clearChildMessages = null;
953    this.clearChildProviders = null;
954    this.updateAdminState = null;
955    this.sendTelemetry = null;
956    this.dispatchCFRAction = null;
957
958    ASRouterPreferences.removeListener(this.onPrefChange);
959    ASRouterPreferences.uninit();
960    BookmarkPanelHub.uninit();
961    ToolbarPanelHub.uninit();
962    ToolbarBadgeHub.uninit();
963    MomentsPageHub.uninit();
964
965    // Uninitialise all trigger listeners
966    for (const listener of ASRouterTriggerListeners.values()) {
967      listener.uninit();
968    }
969    Services.obs.removeObserver(
970      this._onLocaleChanged,
971      TOPIC_INTL_LOCALE_CHANGED
972    );
973    Services.prefs.removeObserver(USE_REMOTE_L10N_PREF, this);
974    // If we added any CFR recommendations, they need to be removed
975    CFRPageActions.clearRecommendations();
976    this._resetInitialization();
977  }
978
979  setState(callbackOrObj) {
980    const newState =
981      typeof callbackOrObj === "function"
982        ? callbackOrObj(this.state)
983        : callbackOrObj;
984    this._state = {
985      ...this.state,
986      ...newState,
987    };
988    if (ASRouterPreferences.devtoolsEnabled) {
989      return this.updateTargetingParameters().then(state => {
990        this.updateAdminState(state);
991        return state;
992      });
993    }
994    return Promise.resolve(this.state);
995  }
996
997  updateTargetingParameters() {
998    return this.getTargetingParameters(
999      ASRouterTargeting.Environment,
1000      this._getMessagesContext()
1001    ).then(targetingParameters => ({
1002      ...this.state,
1003      providerPrefs: ASRouterPreferences.providers,
1004      userPrefs: ASRouterPreferences.getAllUserPreferences(),
1005      targetingParameters,
1006      errors: this.errors,
1007    }));
1008  }
1009
1010  getMessageById(id) {
1011    return this.state.messages.find(message => message.id === id);
1012  }
1013
1014  _loadLocalProviders() {
1015    // If we're in ASR debug mode add the local test providers
1016    if (ASRouterPreferences.devtoolsEnabled) {
1017      this._localProviders = {
1018        ...this._localProviders,
1019        SnippetsTestMessageProvider,
1020        PanelTestProvider,
1021      };
1022    }
1023  }
1024
1025  /**
1026   * Used by ASRouter Admin returns all ASRouterTargeting.Environment
1027   * and ASRouter._getMessagesContext parameters and values
1028   */
1029  async getTargetingParameters(environment, localContext) {
1030    const targetingParameters = {};
1031    for (const param of Object.keys(environment)) {
1032      targetingParameters[param] = await environment[param];
1033    }
1034    for (const param of Object.keys(localContext)) {
1035      targetingParameters[param] = await localContext[param];
1036    }
1037
1038    return targetingParameters;
1039  }
1040
1041  _handleTargetingError(error, message) {
1042    Cu.reportError(error);
1043    this.dispatchCFRAction(
1044      ac.ASRouterUserEvent({
1045        message_id: message.id,
1046        action: "asrouter_undesired_event",
1047        event: "TARGETING_EXPRESSION_ERROR",
1048        event_context: {},
1049      })
1050    );
1051  }
1052
1053  // Return an object containing targeting parameters used to select messages
1054  _getMessagesContext() {
1055    const { messageImpressions, previousSessionEnd } = this.state;
1056
1057    return {
1058      get messageImpressions() {
1059        return messageImpressions;
1060      },
1061      get previousSessionEnd() {
1062        return previousSessionEnd;
1063      },
1064    };
1065  }
1066
1067  async evaluateExpression({ expression, context }) {
1068    const targetingContext = new TargetingContext(context);
1069    let evaluationStatus;
1070    try {
1071      evaluationStatus = {
1072        result: await targetingContext.evalWithDefault(expression),
1073        success: true,
1074      };
1075    } catch (e) {
1076      evaluationStatus = { result: e.message, success: false };
1077    }
1078    return Promise.resolve({ evaluationStatus });
1079  }
1080
1081  unblockAll() {
1082    return this.setState({ messageBlockList: [] });
1083  }
1084
1085  isUnblockedMessage(message) {
1086    let { state } = this;
1087    return (
1088      !state.messageBlockList.includes(message.id) &&
1089      (!message.campaign ||
1090        !state.messageBlockList.includes(message.campaign)) &&
1091      this.hasGroupsEnabled(message.groups) &&
1092      !this.isExcludedByProvider(message)
1093    );
1094  }
1095
1096  // Work out if a message can be shown based on its and its provider's frequency caps.
1097  isBelowFrequencyCaps(message) {
1098    const { messageImpressions, groupImpressions } = this.state;
1099    const impressionsForMessage = messageImpressions[message.id];
1100
1101    return (
1102      this._isBelowItemFrequencyCap(
1103        message,
1104        impressionsForMessage,
1105        MAX_MESSAGE_LIFETIME_CAP
1106      ) &&
1107      message.groups.every(messageGroup =>
1108        this._isBelowItemFrequencyCap(
1109          this.state.groups.find(({ id }) => id === messageGroup),
1110          groupImpressions[messageGroup]
1111        )
1112      )
1113    );
1114  }
1115
1116  // Helper for isBelowFrecencyCaps - work out if the frequency cap for the given
1117  //                                  item has been exceeded or not
1118  _isBelowItemFrequencyCap(item, impressions, maxLifetimeCap = Infinity) {
1119    if (item && item.frequency && impressions && impressions.length) {
1120      if (
1121        item.frequency.lifetime &&
1122        impressions.length >= Math.min(item.frequency.lifetime, maxLifetimeCap)
1123      ) {
1124        return false;
1125      }
1126      if (item.frequency.custom) {
1127        const now = Date.now();
1128        for (const setting of item.frequency.custom) {
1129          let { period } = setting;
1130          const impressionsInPeriod = impressions.filter(t => now - t < period);
1131          if (impressionsInPeriod.length >= setting.cap) {
1132            return false;
1133          }
1134        }
1135      }
1136    }
1137    return true;
1138  }
1139
1140  async _extraTemplateStrings(originalMessage) {
1141    let extraTemplateStrings;
1142    let localProvider = this._findProvider(originalMessage.provider);
1143    if (localProvider && localProvider.getExtraAttributes) {
1144      extraTemplateStrings = await localProvider.getExtraAttributes();
1145    }
1146
1147    return extraTemplateStrings;
1148  }
1149
1150  _findProvider(providerID) {
1151    return this._localProviders[
1152      this.state.providers.find(i => i.id === providerID).localProvider
1153    ];
1154  }
1155
1156  routeCFRMessage(message, browser, trigger, force = false) {
1157    if (!message) {
1158      return { message: {} };
1159    }
1160
1161    switch (message.template) {
1162      case "whatsnew_panel_message":
1163        if (force) {
1164          ToolbarPanelHub.forceShowMessage(browser, message);
1165        }
1166        break;
1167      case "cfr_doorhanger":
1168      case "milestone_message":
1169        if (force) {
1170          CFRPageActions.forceRecommendation(
1171            browser,
1172            message,
1173            this.dispatchCFRAction
1174          );
1175        } else {
1176          CFRPageActions.addRecommendation(
1177            browser,
1178            trigger.param && trigger.param.host,
1179            message,
1180            this.dispatchCFRAction
1181          );
1182        }
1183        break;
1184      case "cfr_urlbar_chiclet":
1185        if (force) {
1186          CFRPageActions.forceRecommendation(
1187            browser,
1188            message,
1189            this.dispatchCFRAction
1190          );
1191        } else {
1192          CFRPageActions.addRecommendation(
1193            browser,
1194            null,
1195            message,
1196            this.dispatchCFRAction
1197          );
1198        }
1199        break;
1200      case "fxa_bookmark_panel":
1201        if (force) {
1202          BookmarkPanelHub.forceShowMessage(browser, message);
1203        }
1204        break;
1205      case "toolbar_badge":
1206        ToolbarBadgeHub.registerBadgeNotificationListener(message, { force });
1207        break;
1208      case "update_action":
1209        MomentsPageHub.executeAction(message);
1210        break;
1211      case "infobar":
1212        InfoBar.showInfoBarMessage(browser, message, this.dispatchCFRAction);
1213        break;
1214    }
1215
1216    return { message };
1217  }
1218
1219  addImpression(message) {
1220    const groupsWithFrequency = this.state.groups.filter(
1221      ({ frequency, id }) => frequency && message.groups.includes(id)
1222    );
1223    // We only need to store impressions for messages that have frequency, or
1224    // that have providers that have frequency
1225    if (message.frequency || groupsWithFrequency.length) {
1226      const time = Date.now();
1227      return this.setState(state => {
1228        const messageImpressions = this._addImpressionForItem(
1229          state,
1230          message,
1231          "messageImpressions",
1232          time
1233        );
1234        let { groupImpressions } = this.state;
1235        for (const group of groupsWithFrequency) {
1236          groupImpressions = this._addImpressionForItem(
1237            state,
1238            group,
1239            "groupImpressions",
1240            time
1241          );
1242        }
1243        return { messageImpressions, groupImpressions };
1244      });
1245    }
1246    return Promise.resolve();
1247  }
1248
1249  // Helper for addImpression - calculate the updated impressions object for the given
1250  //                            item, then store it and return it
1251  _addImpressionForItem(state, item, impressionsString, time) {
1252    // The destructuring here is to avoid mutating existing objects in state as in redux
1253    // (see https://redux.js.org/recipes/structuring-reducers/prerequisite-concepts#immutable-data-management)
1254    const impressions = { ...state[impressionsString] };
1255    if (item.frequency) {
1256      impressions[item.id] = impressions[item.id]
1257        ? [...impressions[item.id]]
1258        : [];
1259      impressions[item.id].push(time);
1260      this._storage.set(impressionsString, impressions);
1261    }
1262    return impressions;
1263  }
1264
1265  /**
1266   * getLongestPeriod
1267   *
1268   * @param {obj} item Either an ASRouter message or an ASRouter provider
1269   * @returns {int|null} if the item has custom frequency caps, the longest period found in the list of caps.
1270                         if the item has no custom frequency caps, null
1271   * @memberof _ASRouter
1272   */
1273  getLongestPeriod(item) {
1274    if (!item.frequency || !item.frequency.custom) {
1275      return null;
1276    }
1277    return item.frequency.custom.sort((a, b) => b.period - a.period)[0].period;
1278  }
1279
1280  /**
1281   * cleanupImpressions - this function cleans up obsolete impressions whenever
1282   * messages are refreshed or fetched. It will likely need to be more sophisticated in the future,
1283   * but the current behaviour for when both message impressions and provider impressions are
1284   * cleared is as follows (where `item` is either `message` or `provider`):
1285   *
1286   * 1. If the item id for a list of item impressions no longer exists in the ASRouter state, it
1287   *    will be cleared.
1288   * 2. If the item has time-bound frequency caps but no lifetime cap, any item impressions older
1289   *    than the longest time period will be cleared.
1290   */
1291  cleanupImpressions() {
1292    return this.setState(state => {
1293      const messageImpressions = this._cleanupImpressionsForItems(
1294        state,
1295        state.messages,
1296        "messageImpressions"
1297      );
1298      const groupImpressions = this._cleanupImpressionsForItems(
1299        state,
1300        state.groups,
1301        "groupImpressions"
1302      );
1303      return { messageImpressions, groupImpressions };
1304    });
1305  }
1306
1307  /** _cleanupImpressionsForItems - Helper for cleanupImpressions - calculate the updated
1308  /*                                impressions object for the given items, then store it and return it
1309   *
1310   * @param {obj} state Reference to ASRouter internal state
1311   * @param {array} items Can be messages, providers or groups that we count impressions for
1312   * @param {string} impressionsString Key name for entry in state where impressions are stored
1313   */
1314  _cleanupImpressionsForItems(state, items, impressionsString) {
1315    const impressions = { ...state[impressionsString] };
1316    let needsUpdate = false;
1317    Object.keys(impressions).forEach(id => {
1318      const [item] = items.filter(x => x.id === id);
1319      // Don't keep impressions for items that no longer exist
1320      if (!item || !item.frequency || !Array.isArray(impressions[id])) {
1321        delete impressions[id];
1322        needsUpdate = true;
1323        return;
1324      }
1325      if (!impressions[id].length) {
1326        return;
1327      }
1328      // If we don't want to store impressions older than the longest period
1329      if (item.frequency.custom && !item.frequency.lifetime) {
1330        const now = Date.now();
1331        impressions[id] = impressions[id].filter(
1332          t => now - t < this.getLongestPeriod(item)
1333        );
1334        needsUpdate = true;
1335      }
1336    });
1337    if (needsUpdate) {
1338      this._storage.set(impressionsString, impressions);
1339    }
1340    return impressions;
1341  }
1342
1343  handleMessageRequest({
1344    messages: candidates,
1345    triggerId,
1346    triggerParam,
1347    triggerContext,
1348    template,
1349    provider,
1350    ordered = false,
1351    returnAll = false,
1352  }) {
1353    let shouldCache;
1354    const messages =
1355      candidates ||
1356      this.state.messages.filter(m => {
1357        if (provider && m.provider !== provider) {
1358          return false;
1359        }
1360        if (template && m.template !== template) {
1361          return false;
1362        }
1363        if (triggerId && !m.trigger) {
1364          return false;
1365        }
1366        if (triggerId && m.trigger.id !== triggerId) {
1367          return false;
1368        }
1369        if (!this.isUnblockedMessage(m)) {
1370          return false;
1371        }
1372        if (!this.isBelowFrequencyCaps(m)) {
1373          return false;
1374        }
1375
1376        if (shouldCache !== false) {
1377          shouldCache = JEXL_PROVIDER_CACHE.has(m.provider);
1378        }
1379
1380        return true;
1381      });
1382
1383    if (!messages.length) {
1384      return returnAll ? messages : null;
1385    }
1386
1387    const context = this._getMessagesContext();
1388
1389    // Find a message that matches the targeting context as well as the trigger context (if one is provided)
1390    // If no trigger is provided, we should find a message WITHOUT a trigger property defined.
1391    return ASRouterTargeting.findMatchingMessage({
1392      messages,
1393      trigger: triggerId && {
1394        id: triggerId,
1395        param: triggerParam,
1396        context: triggerContext,
1397      },
1398      context,
1399      onError: this._handleTargetingError,
1400      ordered,
1401      shouldCache,
1402      returnAll,
1403    });
1404  }
1405
1406  setMessageById({ id, ...data }, force, browser) {
1407    return this.routeCFRMessage(this.getMessageById(id), browser, data, force);
1408  }
1409
1410  blockMessageById(idOrIds) {
1411    const idsToBlock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
1412
1413    return this.setState(state => {
1414      const messageBlockList = [...state.messageBlockList];
1415      const messageImpressions = { ...state.messageImpressions };
1416
1417      idsToBlock.forEach(id => {
1418        const message = state.messages.find(m => m.id === id);
1419        const idToBlock = message && message.campaign ? message.campaign : id;
1420        if (!messageBlockList.includes(idToBlock)) {
1421          messageBlockList.push(idToBlock);
1422        }
1423
1424        // When a message is blocked, its impressions should be cleared as well
1425        delete messageImpressions[id];
1426      });
1427
1428      this._storage.set("messageBlockList", messageBlockList);
1429      this._storage.set("messageImpressions", messageImpressions);
1430      return { messageBlockList, messageImpressions };
1431    });
1432  }
1433
1434  unblockMessageById(idOrIds) {
1435    const idsToUnblock = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
1436
1437    return this.setState(state => {
1438      const messageBlockList = [...state.messageBlockList];
1439      idsToUnblock
1440        .map(id => state.messages.find(m => m.id === id))
1441        // Remove all `id`s (or `campaign`s for snippets) from the message
1442        // block list
1443        .forEach(message => {
1444          const idToUnblock =
1445            message && message.campaign ? message.campaign : message.id;
1446          messageBlockList.splice(messageBlockList.indexOf(idToUnblock), 1);
1447        });
1448
1449      this._storage.set("messageBlockList", messageBlockList);
1450      return { messageBlockList };
1451    });
1452  }
1453
1454  resetGroupsState() {
1455    const newGroupImpressions = {};
1456    for (let { id } of this.state.groups) {
1457      newGroupImpressions[id] = [];
1458    }
1459    // Update storage
1460    this._storage.set("groupImpressions", newGroupImpressions);
1461    return this.setState(({ groups }) => ({
1462      groupImpressions: newGroupImpressions,
1463    }));
1464  }
1465
1466  _validPreviewEndpoint(url) {
1467    try {
1468      const endpoint = new URL(url);
1469      if (!this.ALLOWLIST_HOSTS[endpoint.host]) {
1470        Cu.reportError(
1471          `The preview URL host ${endpoint.host} is not in the list of allowed hosts.`
1472        );
1473      }
1474      if (endpoint.protocol !== "https:") {
1475        Cu.reportError("The URL protocol is not https.");
1476      }
1477      return (
1478        endpoint.protocol === "https:" && this.ALLOWLIST_HOSTS[endpoint.host]
1479      );
1480    } catch (e) {
1481      return false;
1482    }
1483  }
1484
1485  // Ensure we switch to the Onboarding message after RTAMO addon was installed
1486  _updateOnboardingState() {
1487    let addonInstallObs = (subject, topic) => {
1488      Services.obs.removeObserver(
1489        addonInstallObs,
1490        "webextension-install-notify"
1491      );
1492    };
1493    Services.obs.addObserver(addonInstallObs, "webextension-install-notify");
1494  }
1495
1496  _loadSnippetsAllowHosts() {
1497    let additionalHosts = [];
1498    const allowPrefValue = Services.prefs.getStringPref(
1499      SNIPPETS_ENDPOINT_ALLOWLIST,
1500      ""
1501    );
1502    try {
1503      additionalHosts = JSON.parse(allowPrefValue);
1504    } catch (e) {
1505      if (allowPrefValue) {
1506        Cu.reportError(
1507          `Pref ${SNIPPETS_ENDPOINT_ALLOWLIST} value is not valid JSON`
1508        );
1509      }
1510    }
1511
1512    if (!additionalHosts.length) {
1513      return DEFAULT_ALLOWLIST_HOSTS;
1514    }
1515
1516    // If there are additional hosts we want to allow, add them as
1517    // `preview` so that the updateCycle is 0
1518    return additionalHosts.reduce(
1519      (allow_hosts, host) => {
1520        allow_hosts[host] = "preview";
1521        Services.console.logStringMessage(
1522          `Adding ${host} to list of allowed hosts.`
1523        );
1524        return allow_hosts;
1525      },
1526      { ...DEFAULT_ALLOWLIST_HOSTS }
1527    );
1528  }
1529
1530  // To be passed to ASRouterTriggerListeners
1531  _triggerHandler(browser, trigger) {
1532    // Disable ASRouterTriggerListeners in kiosk mode.
1533    if (BrowserHandler.kiosk) {
1534      return Promise.resolve();
1535    }
1536    return this.sendTriggerMessage({ ...trigger, browser });
1537  }
1538
1539  _removePreviewEndpoint(state) {
1540    state.providers = state.providers.filter(p => p.id !== "preview");
1541    return state;
1542  }
1543
1544  addPreviewEndpoint(url, browser) {
1545    const providers = [...this.state.providers];
1546    if (
1547      this._validPreviewEndpoint(url) &&
1548      !providers.find(p => p.url === url)
1549    ) {
1550      // When you view a preview snippet we want to hide all real content -
1551      // sending EnterSnippetsPreviewMode puts this browser tab in that state.
1552      browser.sendMessageToActor("EnterSnippetsPreviewMode", {}, "ASRouter");
1553      providers.push({
1554        id: "preview",
1555        type: "remote",
1556        enabled: true,
1557        url,
1558        updateCycleInMs: 0,
1559      });
1560      return this.setState({ providers });
1561    }
1562    return Promise.resolve();
1563  }
1564
1565  /**
1566   * forceAttribution - this function should only be called from within about:newtab#asrouter.
1567   * It forces the browser attribution to be set to something specified in asrouter admin
1568   * tools, and reloads the providers in order to get messages that are dependant on this
1569   * attribution data (see Return to AMO flow in bug 1475354 for example). Note - OSX and Windows only
1570   * @param {data} Object an object containing the attribtion data that came from asrouter admin page
1571   */
1572  async forceAttribution(data) {
1573    // Extract the parameters from data that will make up the referrer url
1574    const attributionData = AttributionCode.allowedCodeKeys
1575      .map(key => `${key}=${encodeURIComponent(data[key] || "")}`)
1576      .join("&");
1577    if (AppConstants.platform === "win") {
1578      // The whole attribution data is encoded (again) for windows
1579      await AttributionCode.writeAttributionFile(
1580        encodeURIComponent(attributionData)
1581      );
1582    } else if (AppConstants.platform === "macosx") {
1583      let appPath = MacAttribution.applicationPath;
1584      let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
1585        Ci.nsIMacAttributionService
1586      );
1587
1588      // The attribution data is treated as a url query for mac
1589      let referrer = `https://www.mozilla.org/anything/?${attributionData}`;
1590
1591      // This sets the Attribution to be the referrer
1592      attributionSvc.setReferrerUrl(appPath, referrer, true);
1593
1594      // Delete attribution data file
1595      await AttributionCode.deleteFileAsync();
1596    }
1597
1598    // Clear cache call is only possible in a testing environment
1599    let env = Cc["@mozilla.org/process/environment;1"].getService(
1600      Ci.nsIEnvironment
1601    );
1602    env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
1603
1604    // Clear and refresh Attribution, and then fetch the messages again to update
1605    AttributionCode._clearCache();
1606    await AttributionCode.getAttrDataAsync();
1607    await this._updateMessageProviders();
1608    return this.loadMessagesFromAllProviders();
1609  }
1610
1611  async sendNewTabMessage({ endpoint, tabId, browser }) {
1612    let message;
1613
1614    // Load preview endpoint for snippets if one is sent
1615    if (endpoint) {
1616      await this.addPreviewEndpoint(endpoint.url, browser);
1617    }
1618
1619    // Load all messages
1620    await this.loadMessagesFromAllProviders();
1621
1622    if (endpoint) {
1623      message = await this.handleMessageRequest({ provider: "preview" });
1624
1625      // We don't want to cache preview messages, remove them after we selected the message to show
1626      if (message) {
1627        await this.setState(state => ({
1628          messages: state.messages.filter(m => m.id !== message.id),
1629        }));
1630      }
1631    } else {
1632      const telemetryObject = { tabId };
1633      TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
1634      message = await this.handleMessageRequest({ provider: "snippets" });
1635      TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
1636    }
1637
1638    return this.routeCFRMessage(message, browser, undefined, false);
1639  }
1640
1641  _recordReachEvent(message) {
1642    const messageGroup = message.forReachEvent.group;
1643    // Events telemetry only accepts understores for the event `object`
1644    const underscored = messageGroup.split("-").join("_");
1645    const extra = { branches: message.branchSlug };
1646    Services.telemetry.recordEvent(
1647      REACH_EVENT_CATEGORY,
1648      REACH_EVENT_METHOD,
1649      underscored,
1650      message.experimentSlug,
1651      extra
1652    );
1653  }
1654
1655  async sendTriggerMessage({ tabId, browser, ...trigger }) {
1656    await this.loadMessagesFromAllProviders();
1657
1658    const telemetryObject = { tabId };
1659    TelemetryStopwatch.start("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
1660    // Return all the messages so that it can record the Reach event
1661    const messages =
1662      (await this.handleMessageRequest({
1663        triggerId: trigger.id,
1664        triggerParam: trigger.param,
1665        triggerContext: trigger.context,
1666        returnAll: true,
1667      })) || [];
1668    TelemetryStopwatch.finish("MS_MESSAGE_REQUEST_TIME_MS", telemetryObject);
1669
1670    // Record the Reach event for all the messages with `forReachEvent`,
1671    // only send the first message without forReachEvent to the target
1672    const nonReachMessages = [];
1673    for (const message of messages) {
1674      if (message.forReachEvent) {
1675        if (!message.forReachEvent.sent) {
1676          this._recordReachEvent(message);
1677          message.forReachEvent.sent = true;
1678        }
1679      } else {
1680        nonReachMessages.push(message);
1681      }
1682    }
1683
1684    // Exposure events only apply to messages that come from the
1685    // messaging-experiments provider
1686    if (nonReachMessages.length && nonReachMessages[0].forExposureEvent) {
1687      ExperimentAPI.recordExposureEvent({
1688        // Any message processed by ASRouter will report the exposure event
1689        // as `cfr`
1690        featureId: "cfr",
1691        // experimentSlug and branchSlug
1692        ...nonReachMessages[0].forExposureEvent,
1693      });
1694    }
1695
1696    return this.routeCFRMessage(
1697      nonReachMessages[0] || null,
1698      browser,
1699      trigger,
1700      false
1701    );
1702  }
1703
1704  async forceWNPanel(browser) {
1705    let win = browser.ownerGlobal;
1706    await ToolbarPanelHub.enableToolbarButton();
1707
1708    win.PanelUI.showSubView(
1709      "PanelUI-whatsNew",
1710      win.document.getElementById("whats-new-menu-button")
1711    );
1712
1713    let panel = win.document.getElementById("customizationui-widget-panel");
1714    // Set the attribute to keep the panel open
1715    panel.setAttribute("noautohide", true);
1716  }
1717
1718  async closeWNPanel(browser) {
1719    let win = browser.ownerGlobal;
1720    let panel = win.document.getElementById("customizationui-widget-panel");
1721    // Set the attribute to allow the panel to close
1722    panel.setAttribute("noautohide", false);
1723    // Removing the button is enough to close the panel.
1724    await ToolbarPanelHub._hideToolbarButton(win);
1725  }
1726}
1727this._ASRouter = _ASRouter;
1728
1729/**
1730 * ASRouter - singleton instance of _ASRouter that controls all messages
1731 * in the new tab page.
1732 */
1733this.ASRouter = new _ASRouter();
1734
1735const EXPORTED_SYMBOLS = ["_ASRouter", "ASRouter", "MessageLoaderUtils"];
1736