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