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