1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5const FXA_ENABLED_PREF = "identity.fxaccounts.enabled";
6const DISTRIBUTION_ID_PREF = "distribution.id";
7const DISTRIBUTION_ID_CHINA_REPACK = "MozillaOnline";
8
9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10const { XPCOMUtils } = ChromeUtils.import(
11  "resource://gre/modules/XPCOMUtils.jsm"
12);
13
14XPCOMUtils.defineLazyModuleGetters(this, {
15  ASRouterPreferences: "resource://activity-stream/lib/ASRouterPreferences.jsm",
16  AddonManager: "resource://gre/modules/AddonManager.jsm",
17  ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
18  NewTabUtils: "resource://gre/modules/NewTabUtils.jsm",
19  ProfileAge: "resource://gre/modules/ProfileAge.jsm",
20  ShellService: "resource:///modules/ShellService.jsm",
21  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
22  AppConstants: "resource://gre/modules/AppConstants.jsm",
23  AttributionCode: "resource:///modules/AttributionCode.jsm",
24  TargetingContext: "resource://messaging-system/targeting/Targeting.jsm",
25  fxAccounts: "resource://gre/modules/FxAccounts.jsm",
26  Region: "resource://gre/modules/Region.jsm",
27  TelemetrySession: "resource://gre/modules/TelemetrySession.jsm",
28  HomePage: "resource:///modules/HomePage.jsm",
29  AboutNewTab: "resource:///modules/AboutNewTab.jsm",
30  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
31  TelemetryArchive: "resource://gre/modules/TelemetryArchive.jsm",
32});
33
34XPCOMUtils.defineLazyPreferenceGetter(
35  this,
36  "cfrFeaturesUserPref",
37  "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
38  true
39);
40XPCOMUtils.defineLazyPreferenceGetter(
41  this,
42  "cfrAddonsUserPref",
43  "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.addons",
44  true
45);
46XPCOMUtils.defineLazyPreferenceGetter(
47  this,
48  "isWhatsNewPanelEnabled",
49  "browser.messaging-system.whatsNewPanel.enabled",
50  false
51);
52XPCOMUtils.defineLazyPreferenceGetter(
53  this,
54  "hasAccessedFxAPanel",
55  "identity.fxaccounts.toolbar.accessed",
56  false
57);
58XPCOMUtils.defineLazyPreferenceGetter(
59  this,
60  "clientsDevicesDesktop",
61  "services.sync.clients.devices.desktop",
62  0
63);
64XPCOMUtils.defineLazyPreferenceGetter(
65  this,
66  "clientsDevicesMobile",
67  "services.sync.clients.devices.mobile",
68  0
69);
70XPCOMUtils.defineLazyPreferenceGetter(
71  this,
72  "syncNumClients",
73  "services.sync.numClients",
74  0
75);
76XPCOMUtils.defineLazyPreferenceGetter(
77  this,
78  "devtoolsSelfXSSCount",
79  "devtools.selfxss.count",
80  0
81);
82XPCOMUtils.defineLazyPreferenceGetter(
83  this,
84  "isFxAEnabled",
85  FXA_ENABLED_PREF,
86  true
87);
88XPCOMUtils.defineLazyPreferenceGetter(
89  this,
90  "isXPIInstallEnabled",
91  "xpinstall.enabled",
92  true
93);
94XPCOMUtils.defineLazyPreferenceGetter(
95  this,
96  "snippetsUserPref",
97  "browser.newtabpage.activity-stream.feeds.snippets",
98  false
99);
100
101XPCOMUtils.defineLazyServiceGetters(this, {
102  BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"],
103  TrackingDBService: [
104    "@mozilla.org/tracking-db-service;1",
105    "nsITrackingDBService",
106  ],
107});
108
109const FXA_USERNAME_PREF = "services.sync.username";
110
111const { activityStreamProvider: asProvider } = NewTabUtils;
112
113const FXA_ATTACHED_CLIENTS_UPDATE_INTERVAL = 4 * 60 * 60 * 1000; // Four hours
114const FRECENT_SITES_UPDATE_INTERVAL = 6 * 60 * 60 * 1000; // Six hours
115const FRECENT_SITES_IGNORE_BLOCKED = false;
116const FRECENT_SITES_NUM_ITEMS = 25;
117const FRECENT_SITES_MIN_FRECENCY = 100;
118
119const CACHE_EXPIRATION = 5 * 60 * 1000;
120const jexlEvaluationCache = new Map();
121
122/**
123 * CachedTargetingGetter
124 * @param property {string} Name of the method called on ActivityStreamProvider
125 * @param options {{}?} Options object passsed to ActivityStreamProvider method
126 * @param updateInterval {number?} Update interval for query. Defaults to FRECENT_SITES_UPDATE_INTERVAL
127 */
128function CachedTargetingGetter(
129  property,
130  options = null,
131  updateInterval = FRECENT_SITES_UPDATE_INTERVAL
132) {
133  return {
134    _lastUpdated: 0,
135    _value: null,
136    // For testing
137    expire() {
138      this._lastUpdated = 0;
139      this._value = null;
140    },
141    async get() {
142      const now = Date.now();
143      if (now - this._lastUpdated >= updateInterval) {
144        this._value = await asProvider[property](options);
145        this._lastUpdated = now;
146      }
147      return this._value;
148    },
149  };
150}
151
152function CacheListAttachedOAuthClients() {
153  return {
154    _lastUpdated: 0,
155    _value: null,
156    expire() {
157      this._lastUpdated = 0;
158      this._value = null;
159    },
160    get() {
161      const now = Date.now();
162      if (now - this._lastUpdated >= FXA_ATTACHED_CLIENTS_UPDATE_INTERVAL) {
163        this._value = new Promise(resolve => {
164          fxAccounts
165            .listAttachedOAuthClients()
166            .then(clients => {
167              resolve(clients);
168            })
169            .catch(() => resolve([]));
170        });
171        this._lastUpdated = now;
172      }
173      return this._value;
174    },
175  };
176}
177
178function CheckBrowserNeedsUpdate(
179  updateInterval = FRECENT_SITES_UPDATE_INTERVAL
180) {
181  const UpdateChecker = Cc["@mozilla.org/updates/update-checker;1"];
182  const checker = {
183    _lastUpdated: 0,
184    _value: null,
185    // For testing. Avoid update check network call.
186    setUp(value) {
187      this._lastUpdated = Date.now();
188      this._value = value;
189    },
190    expire() {
191      this._lastUpdated = 0;
192      this._value = null;
193    },
194    get() {
195      return new Promise((resolve, reject) => {
196        const now = Date.now();
197        const updateServiceListener = {
198          onCheckComplete(request, updates) {
199            checker._value = !!updates.length;
200            resolve(checker._value);
201          },
202          onError(request, update) {
203            reject(request);
204          },
205
206          QueryInterface: ChromeUtils.generateQI(["nsIUpdateCheckListener"]),
207        };
208
209        if (UpdateChecker && now - this._lastUpdated >= updateInterval) {
210          const checkerInstance = UpdateChecker.createInstance(
211            Ci.nsIUpdateChecker
212          );
213          if (checkerInstance.canCheckForUpdates) {
214            checkerInstance.checkForUpdates(updateServiceListener, true);
215            this._lastUpdated = now;
216          } else {
217            resolve(false);
218          }
219        } else {
220          resolve(this._value);
221        }
222      });
223    },
224  };
225
226  return checker;
227}
228
229const QueryCache = {
230  expireAll() {
231    Object.keys(this.queries).forEach(query => {
232      this.queries[query].expire();
233    });
234  },
235  queries: {
236    TopFrecentSites: new CachedTargetingGetter("getTopFrecentSites", {
237      ignoreBlocked: FRECENT_SITES_IGNORE_BLOCKED,
238      numItems: FRECENT_SITES_NUM_ITEMS,
239      topsiteFrecency: FRECENT_SITES_MIN_FRECENCY,
240      onePerDomain: true,
241      includeFavicon: false,
242    }),
243    TotalBookmarksCount: new CachedTargetingGetter("getTotalBookmarksCount"),
244    CheckBrowserNeedsUpdate: new CheckBrowserNeedsUpdate(),
245    RecentBookmarks: new CachedTargetingGetter("getRecentBookmarks"),
246    ListAttachedOAuthClients: new CacheListAttachedOAuthClients(),
247    UserMonthlyActivity: new CachedTargetingGetter("getUserMonthlyActivity"),
248  },
249};
250
251/**
252 * sortMessagesByWeightedRank
253 *
254 * Each message has an associated weight, which is guaranteed to be strictly
255 * positive. Sort the messages so that higher weighted messages are more likely
256 * to come first.
257 *
258 * Specifically, sort them so that the probability of message x_1 with weight
259 * w_1 appearing before message x_2 with weight w_2 is (w_1 / (w_1 + w_2)).
260 *
261 * This is equivalent to requiring that x_1 appearing before x_2 is (w_1 / w_2)
262 * "times" as likely as x_2 appearing before x_1.
263 *
264 * See Bug 1484996, Comment 2 for a justification of the method.
265 *
266 * @param {Array} messages - A non-empty array of messages to sort, all with
267 *                           strictly positive weights
268 * @returns the sorted array
269 */
270function sortMessagesByWeightedRank(messages) {
271  return messages
272    .map(message => ({
273      message,
274      rank: Math.pow(Math.random(), 1 / message.weight),
275    }))
276    .sort((a, b) => b.rank - a.rank)
277    .map(({ message }) => message);
278}
279
280/**
281 * getSortedMessages - Given an array of Messages, applies sorting and filtering rules
282 *                     in expected order.
283 *
284 * @param {Array<Message>} messages
285 * @param {{}} options
286 * @param {boolean} options.ordered - Should .order be used instead of random weighted sorting?
287 * @returns {Array<Message>}
288 */
289function getSortedMessages(messages, options = {}) {
290  let { ordered } = { ordered: false, ...options };
291  let result = messages;
292
293  if (!ordered) {
294    result = sortMessagesByWeightedRank(result);
295  }
296
297  result.sort((a, b) => {
298    // Next, sort by priority
299    if (a.priority > b.priority || (!isNaN(a.priority) && isNaN(b.priority))) {
300      return -1;
301    }
302    if (a.priority < b.priority || (isNaN(a.priority) && !isNaN(b.priority))) {
303      return 1;
304    }
305
306    // Sort messages with targeting expressions higher than those with none
307    if (a.targeting && !b.targeting) {
308      return -1;
309    }
310    if (!a.targeting && b.targeting) {
311      return 1;
312    }
313
314    // Next, sort by order *ascending* if ordered = true
315    if (ordered) {
316      if (a.order > b.order || (!isNaN(a.order) && isNaN(b.order))) {
317        return 1;
318      }
319      if (a.order < b.order || (isNaN(a.order) && !isNaN(b.order))) {
320        return -1;
321      }
322    }
323
324    return 0;
325  });
326
327  return result;
328}
329
330/**
331 * parseAboutPageURL - Parse a URL string retrieved from about:home and about:new, returns
332 *                    its type (web extenstion or custom url) and the parsed url(s)
333 *
334 * @param {string} url - A URL string for home page or newtab page
335 * @returns {Object} {
336 *   isWebExt: boolean,
337 *   isCustomUrl: boolean,
338 *   urls: Array<{url: string, host: string}>
339 * }
340 */
341function parseAboutPageURL(url) {
342  let ret = {
343    isWebExt: false,
344    isCustomUrl: false,
345    urls: [],
346  };
347  if (url.startsWith("moz-extension://")) {
348    ret.isWebExt = true;
349    ret.urls.push({ url, host: "" });
350  } else {
351    // The home page URL could be either a single URL or a list of "|" separated URLs.
352    // Note that it should work with "about:home" and "about:blank", in which case the
353    // "host" is set as an empty string.
354    for (const _url of url.split("|")) {
355      if (!["about:home", "about:newtab", "about:blank"].includes(_url)) {
356        ret.isCustomUrl = true;
357      }
358      try {
359        const parsedURL = new URL(_url);
360        const host = parsedURL.hostname.replace(/^www\./i, "");
361        ret.urls.push({ url: _url, host });
362      } catch (e) {}
363    }
364    // If URL parsing failed, just return the given url with an empty host
365    if (!ret.urls.length) {
366      ret.urls.push({ url, host: "" });
367    }
368  }
369
370  return ret;
371}
372
373const TargetingGetters = {
374  get locale() {
375    return Services.locale.appLocaleAsBCP47;
376  },
377  get localeLanguageCode() {
378    return (
379      Services.locale.appLocaleAsBCP47 &&
380      Services.locale.appLocaleAsBCP47.substr(0, 2)
381    );
382  },
383  get browserSettings() {
384    const { settings } = TelemetryEnvironment.currentEnvironment;
385    return {
386      update: settings.update,
387    };
388  },
389  get attributionData() {
390    // Attribution is determined at startup - so we can use the cached attribution at this point
391    return AttributionCode.getCachedAttributionData();
392  },
393  get currentDate() {
394    return new Date();
395  },
396  get profileAgeCreated() {
397    return ProfileAge().then(times => times.created);
398  },
399  get profileAgeReset() {
400    return ProfileAge().then(times => times.reset);
401  },
402  get usesFirefoxSync() {
403    return Services.prefs.prefHasUserValue(FXA_USERNAME_PREF);
404  },
405  get isFxAEnabled() {
406    return isFxAEnabled;
407  },
408  get sync() {
409    return {
410      desktopDevices: clientsDevicesDesktop,
411      mobileDevices: clientsDevicesMobile,
412      totalDevices: syncNumClients,
413    };
414  },
415  get xpinstallEnabled() {
416    // This is needed for all add-on recommendations, to know if we allow xpi installs in the first place
417    return isXPIInstallEnabled;
418  },
419  get addonsInfo() {
420    return AddonManager.getActiveAddons(["extension", "service"]).then(
421      ({ addons, fullData }) => {
422        const info = {};
423        for (const addon of addons) {
424          info[addon.id] = {
425            version: addon.version,
426            type: addon.type,
427            isSystem: addon.isSystem,
428            isWebExtension: addon.isWebExtension,
429          };
430          if (fullData) {
431            Object.assign(info[addon.id], {
432              name: addon.name,
433              userDisabled: addon.userDisabled,
434              installDate: addon.installDate,
435            });
436          }
437        }
438        return { addons: info, isFullData: fullData };
439      }
440    );
441  },
442  get searchEngines() {
443    return new Promise(resolve => {
444      // Note: calling init ensures this code is only executed after Search has been initialized
445      Services.search
446        .getAppProvidedEngines()
447        .then(engines => {
448          resolve({
449            current: Services.search.defaultEngine.identifier,
450            installed: engines.map(engine => engine.identifier),
451          });
452        })
453        .catch(() => resolve({ installed: [], current: "" }));
454    });
455  },
456  get isDefaultBrowser() {
457    try {
458      return ShellService.isDefaultBrowser();
459    } catch (e) {}
460    return null;
461  },
462  get devToolsOpenedCount() {
463    return devtoolsSelfXSSCount;
464  },
465  get topFrecentSites() {
466    return QueryCache.queries.TopFrecentSites.get().then(sites =>
467      sites.map(site => ({
468        url: site.url,
469        host: new URL(site.url).hostname,
470        frecency: site.frecency,
471        lastVisitDate: site.lastVisitDate,
472      }))
473    );
474  },
475  get recentBookmarks() {
476    return QueryCache.queries.RecentBookmarks.get();
477  },
478  get pinnedSites() {
479    return NewTabUtils.pinnedLinks.links.map(site =>
480      site
481        ? {
482            url: site.url,
483            host: new URL(site.url).hostname,
484            searchTopSite: site.searchTopSite,
485          }
486        : {}
487    );
488  },
489  get providerCohorts() {
490    return ASRouterPreferences.providers.reduce((prev, current) => {
491      prev[current.id] = current.cohort || "";
492      return prev;
493    }, {});
494  },
495  get totalBookmarksCount() {
496    return QueryCache.queries.TotalBookmarksCount.get();
497  },
498  get firefoxVersion() {
499    return parseInt(AppConstants.MOZ_APP_VERSION.match(/\d+/), 10);
500  },
501  get region() {
502    return Region.home || "";
503  },
504  get needsUpdate() {
505    return QueryCache.queries.CheckBrowserNeedsUpdate.get();
506  },
507  get hasPinnedTabs() {
508    for (let win of Services.wm.getEnumerator("navigator:browser")) {
509      if (win.closed || !win.ownerGlobal.gBrowser) {
510        continue;
511      }
512      if (win.ownerGlobal.gBrowser.visibleTabs.filter(t => t.pinned).length) {
513        return true;
514      }
515    }
516
517    return false;
518  },
519  get hasAccessedFxAPanel() {
520    return hasAccessedFxAPanel;
521  },
522  get isWhatsNewPanelEnabled() {
523    return isWhatsNewPanelEnabled;
524  },
525  get userPrefs() {
526    return {
527      cfrFeatures: cfrFeaturesUserPref,
528      cfrAddons: cfrAddonsUserPref,
529      snippets: snippetsUserPref,
530    };
531  },
532  get totalBlockedCount() {
533    return TrackingDBService.sumAllEvents();
534  },
535  get blockedCountByType() {
536    const idToTextMap = new Map([
537      [Ci.nsITrackingDBService.TRACKERS_ID, "trackerCount"],
538      [Ci.nsITrackingDBService.TRACKING_COOKIES_ID, "cookieCount"],
539      [Ci.nsITrackingDBService.CRYPTOMINERS_ID, "cryptominerCount"],
540      [Ci.nsITrackingDBService.FINGERPRINTERS_ID, "fingerprinterCount"],
541      [Ci.nsITrackingDBService.SOCIAL_ID, "socialCount"],
542    ]);
543
544    const dateTo = new Date();
545    const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000);
546    return TrackingDBService.getEventsByDateRange(dateFrom, dateTo).then(
547      eventsByDate => {
548        let totalEvents = {};
549        for (let blockedType of idToTextMap.values()) {
550          totalEvents[blockedType] = 0;
551        }
552
553        return eventsByDate.reduce((acc, day) => {
554          const type = day.getResultByName("type");
555          const count = day.getResultByName("count");
556          acc[idToTextMap.get(type)] = acc[idToTextMap.get(type)] + count;
557          return acc;
558        }, totalEvents);
559      }
560    );
561  },
562  get attachedFxAOAuthClients() {
563    return this.usesFirefoxSync
564      ? QueryCache.queries.ListAttachedOAuthClients.get()
565      : [];
566  },
567  get platformName() {
568    return AppConstants.platform;
569  },
570  get isChinaRepack() {
571    return (
572      Services.prefs
573        .getDefaultBranch(null)
574        .getCharPref(DISTRIBUTION_ID_PREF, "default") ===
575      DISTRIBUTION_ID_CHINA_REPACK
576    );
577  },
578  get userId() {
579    return ClientEnvironment.userId;
580  },
581  get profileRestartCount() {
582    // Counter starts at 1 when a profile is created, substract 1 so the value
583    // returned matches expectations
584    return (
585      TelemetrySession.getMetadata("targeting").profileSubsessionCounter - 1
586    );
587  },
588  get homePageSettings() {
589    const url = HomePage.get();
590    const { isWebExt, isCustomUrl, urls } = parseAboutPageURL(url);
591
592    return {
593      isWebExt,
594      isCustomUrl,
595      urls,
596      isDefault: HomePage.isDefault,
597      isLocked: HomePage.locked,
598    };
599  },
600  get newtabSettings() {
601    const url = AboutNewTab.newTabURL;
602    const { isWebExt, isCustomUrl, urls } = parseAboutPageURL(url);
603
604    return {
605      isWebExt,
606      isCustomUrl,
607      isDefault: AboutNewTab.activityStreamEnabled,
608      url: urls[0].url,
609      host: urls[0].host,
610    };
611  },
612  get isFissionExperimentEnabled() {
613    return (
614      Services.appinfo.fissionExperimentStatus ===
615      Ci.nsIXULRuntime.eExperimentStatusTreatment
616    );
617  },
618  get activeNotifications() {
619    let window = BrowserWindowTracker.getTopWindow();
620
621    if (
622      window.gURLBar.view.isOpen ||
623      window.gHighPriorityNotificationBox.currentNotification ||
624      window.gBrowser.getNotificationBox().currentNotification
625    ) {
626      return true;
627    }
628
629    return false;
630  },
631
632  get isMajorUpgrade() {
633    return BrowserHandler.majorUpgrade;
634  },
635
636  get hasActiveEnterprisePolicies() {
637    return Services.policies.status === Services.policies.ACTIVE;
638  },
639
640  get mainPingSubmissions() {
641    return (
642      TelemetryArchive.promiseArchivedPingList()
643        // Filter out non-main pings. Do it before so we compare timestamps
644        // between pings of same type.
645        .then(pings => pings.filter(p => p.type === "main"))
646        .then(pings => {
647          if (pings.length <= 1) {
648            return pings;
649          }
650          // Pings are returned in ascending order.
651          return pings.reduce(
652            (acc, ping) => {
653              if (
654                // Keep only main pings sent a day (or more) apart
655                new Date(ping.timestampCreated).toDateString() !==
656                new Date(acc[acc.length - 1].timestampCreated).toDateString()
657              ) {
658                acc.push(ping);
659              }
660              return acc;
661            },
662            [pings[0]]
663          );
664        })
665    );
666  },
667
668  get userMonthlyActivity() {
669    return QueryCache.queries.UserMonthlyActivity.get();
670  },
671};
672
673this.ASRouterTargeting = {
674  Environment: TargetingGetters,
675
676  isTriggerMatch(trigger = {}, candidateMessageTrigger = {}) {
677    if (trigger.id !== candidateMessageTrigger.id) {
678      return false;
679    } else if (
680      !candidateMessageTrigger.params &&
681      !candidateMessageTrigger.patterns
682    ) {
683      return true;
684    }
685
686    if (!trigger.param) {
687      return false;
688    }
689
690    return (
691      (candidateMessageTrigger.params &&
692        trigger.param.host &&
693        candidateMessageTrigger.params.includes(trigger.param.host)) ||
694      (candidateMessageTrigger.params &&
695        trigger.param.type &&
696        candidateMessageTrigger.params.filter(t => t === trigger.param.type)
697          .length) ||
698      (candidateMessageTrigger.params &&
699        trigger.param.type &&
700        candidateMessageTrigger.params.filter(
701          t => (t & trigger.param.type) === t
702        ).length) ||
703      (candidateMessageTrigger.patterns &&
704        trigger.param.url &&
705        new MatchPatternSet(candidateMessageTrigger.patterns).matches(
706          trigger.param.url
707        ))
708    );
709  },
710
711  /**
712   * getCachedEvaluation - Return a cached jexl evaluation if available
713   *
714   * @param {string} targeting JEXL expression to lookup
715   * @returns {obj|null} Object with value result or null if not available
716   */
717  getCachedEvaluation(targeting) {
718    if (jexlEvaluationCache.has(targeting)) {
719      const { timestamp, value } = jexlEvaluationCache.get(targeting);
720      if (Date.now() - timestamp <= CACHE_EXPIRATION) {
721        return { value };
722      }
723      jexlEvaluationCache.delete(targeting);
724    }
725
726    return null;
727  },
728
729  /**
730   * checkMessageTargeting - Checks is a message's targeting parameters are satisfied
731   *
732   * @param {*} message An AS router message
733   * @param {obj} targetingContext a TargetingContext instance complete with eval environment
734   * @param {func} onError A function to handle errors (takes two params; error, message)
735   * @param {boolean} shouldCache Should the JEXL evaluations be cached and reused.
736   * @returns
737   */
738  async checkMessageTargeting(message, targetingContext, onError, shouldCache) {
739    // If no targeting is specified,
740    if (!message.targeting) {
741      return true;
742    }
743    let result;
744    try {
745      if (shouldCache) {
746        result = this.getCachedEvaluation(message.targeting);
747        if (result) {
748          return result.value;
749        }
750      }
751      result = await targetingContext.evalWithDefault(message.targeting);
752      if (shouldCache) {
753        jexlEvaluationCache.set(message.targeting, {
754          timestamp: Date.now(),
755          value: result,
756        });
757      }
758    } catch (error) {
759      if (onError) {
760        onError(error, message);
761      }
762      Cu.reportError(error);
763      result = false;
764    }
765    return result;
766  },
767
768  _isMessageMatch(
769    message,
770    trigger,
771    targetingContext,
772    onError,
773    shouldCache = false
774  ) {
775    return (
776      message &&
777      (trigger
778        ? this.isTriggerMatch(trigger, message.trigger)
779        : !message.trigger) &&
780      // If a trigger expression was passed to this function, the message should match it.
781      // Otherwise, we should choose a message with no trigger property (i.e. a message that can show up at any time)
782      this.checkMessageTargeting(
783        message,
784        targetingContext,
785        onError,
786        shouldCache
787      )
788    );
789  },
790
791  /**
792   * findMatchingMessage - Given an array of messages, returns one message
793   *                       whos targeting expression evaluates to true
794   *
795   * @param {Array<Message>} messages An array of AS router messages
796   * @param {trigger} string A trigger expression if a message for that trigger is desired
797   * @param {obj|null} context A FilterExpression context. Defaults to TargetingGetters above.
798   * @param {func} onError A function to handle errors (takes two params; error, message)
799   * @param {func} ordered An optional param when true sort message by order specified in message
800   * @param {boolean} shouldCache Should the JEXL evaluations be cached and reused.
801   * @param {boolean} returnAll Should we return all matching messages, not just the first one found.
802   * @returns {obj|Array<Message>} If returnAll is false, a single message. If returnAll is true, an array of messages.
803   */
804  async findMatchingMessage({
805    messages,
806    trigger = {},
807    context = {},
808    onError,
809    ordered = false,
810    shouldCache = false,
811    returnAll = false,
812  }) {
813    const sortedMessages = getSortedMessages(messages, { ordered });
814    const matching = returnAll ? [] : null;
815    const targetingContext = new TargetingContext(
816      TargetingContext.combineContexts(
817        context,
818        this.Environment,
819        trigger.context || {}
820      )
821    );
822
823    const isMatch = candidate =>
824      this._isMessageMatch(
825        candidate,
826        trigger,
827        targetingContext,
828        onError,
829        shouldCache
830      );
831
832    for (const candidate of sortedMessages) {
833      if (await isMatch(candidate)) {
834        // If not returnAll, we should return the first message we find that matches.
835        if (!returnAll) {
836          return candidate;
837        }
838
839        matching.push(candidate);
840      }
841    }
842    return matching;
843  },
844};
845
846// Export for testing
847this.getSortedMessages = getSortedMessages;
848this.QueryCache = QueryCache;
849this.CachedTargetingGetter = CachedTargetingGetter;
850this.EXPORTED_SYMBOLS = [
851  "ASRouterTargeting",
852  "QueryCache",
853  "CachedTargetingGetter",
854  "getSortedMessages",
855];
856