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