DiscoveryStreamFeed.sys.mjs (100128B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 ChromeUtils.defineESModuleGetters(lazy, { 7 ContextId: "moz-src:///browser/modules/ContextId.sys.mjs", 8 DEFAULT_SECTION_LAYOUT: "resource://newtab/lib/SectionsLayoutManager.sys.mjs", 9 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", 10 NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", 11 ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs", 12 PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs", 13 Region: "resource://gre/modules/Region.sys.mjs", 14 RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", 15 ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", 16 }); 17 18 // We use importESModule here instead of static import so that 19 // the Karma test environment won't choke on this module. This 20 // is because the Karma test environment already stubs out 21 // setTimeout / clearTimeout, and overrides importESModule 22 // to be a no-op (which can't be done for a static import statement). 23 24 // eslint-disable-next-line mozilla/use-static-import 25 const { setTimeout, clearTimeout } = ChromeUtils.importESModule( 26 "resource://gre/modules/Timer.sys.mjs" 27 ); 28 import { 29 actionTypes as at, 30 actionCreators as ac, 31 } from "resource://newtab/common/Actions.mjs"; 32 33 import { scoreItemInferred } from "resource://newtab/lib/InferredModel/GreedyContentRanker.mjs"; 34 35 const LOCAL_POPULAR_RERANK = false; // default behavior for local re-ranking 36 const LOCAL_WEIGHT = 1; 37 const SERVER_WEIGHT = 1; 38 const CACHE_KEY = "discovery_stream"; 39 const STARTUP_CACHE_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 1 week 40 const COMPONENT_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes 41 const SPOCS_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes 42 const DEFAULT_RECS_ROTATION_TIME = 60 * 60 * 1000; // 1 hour 43 const DEFAULT_RECS_IMPRESSION_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 7 days 44 const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server 45 const SPOCS_CAP_DURATION = 24 * 60 * 60; // 1 day in seconds. 46 const FETCH_TIMEOUT = 45 * 1000; 47 const TOPIC_LOADING_TIMEOUT = 1 * 1000; 48 const TOPIC_SELECTION_DISPLAY_COUNT = 49 "discoverystream.topicSelection.onboarding.displayCount"; 50 const TOPIC_SELECTION_LAST_DISPLAYED = 51 "discoverystream.topicSelection.onboarding.lastDisplayed"; 52 const TOPIC_SELECTION_DISPLAY_TIMEOUT = 53 "discoverystream.topicSelection.onboarding.displayTimeout"; 54 55 const SPOCS_URL = "https://spocs.getpocket.com/spocs"; 56 const PREF_CONFIG = "discoverystream.config"; 57 const PREF_ENDPOINTS = "discoverystream.endpoints"; 58 const PREF_IMPRESSION_ID = "browser.newtabpage.activity-stream.impressionId"; 59 // const PREF_LAYOUT_EXPERIMENT_A = "newtabLayouts.variant-a"; 60 // const PREF_LAYOUT_EXPERIMENT_B = "newtabLayouts.variant-b"; 61 const PREF_CONTEXTUAL_SPOC_PLACEMENTS = 62 "discoverystream.placements.contextualSpocs"; 63 const PREF_CONTEXTUAL_SPOC_COUNTS = 64 "discoverystream.placements.contextualSpocs.counts"; 65 const PREF_SPOC_PLACEMENTS = "discoverystream.placements.spocs"; 66 const PREF_SPOC_COUNTS = "discoverystream.placements.spocs.counts"; 67 const PREF_SPOC_POSITIONS = "discoverystream.spoc-positions"; 68 const PREF_MERINO_FEED_EXPERIMENT = 69 "browser.newtabpage.activity-stream.discoverystream.merino-feed-experiment"; 70 const PREF_ENABLED = "discoverystream.enabled"; 71 const PREF_HARDCODED_BASIC_LAYOUT = "discoverystream.hardcoded-basic-layout"; 72 const PREF_SPOCS_ENDPOINT = "discoverystream.spocs-endpoint"; 73 const PREF_SPOCS_ENDPOINT_QUERY = "discoverystream.spocs-endpoint-query"; 74 const PREF_REGION_BASIC_LAYOUT = "discoverystream.region-basic-layout"; 75 const PREF_USER_TOPSTORIES = "feeds.section.topstories"; 76 const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories"; 77 const PREF_UNIFIED_ADS_BLOCKED_LIST = "unifiedAds.blockedAds"; 78 const PREF_UNIFIED_ADS_SPOCS_ENABLED = "unifiedAds.spocs.enabled"; 79 const PREF_UNIFIED_ADS_ADSFEED_ENABLED = "unifiedAds.adsFeed.enabled"; 80 const PREF_UNIFIED_ADS_ENDPOINT = "unifiedAds.endpoint"; 81 const PREF_UNIFIED_ADS_OHTTP = "unifiedAds.ohttp.enabled"; 82 const PREF_SPOCS_CLEAR_ENDPOINT = "discoverystream.endpointSpocsClear"; 83 const PREF_SHOW_SPONSORED = "showSponsored"; 84 const PREF_SYSTEM_SHOW_SPONSORED = "system.showSponsored"; 85 const PREF_SPOC_IMPRESSIONS = "discoverystream.spoc.impressions"; 86 const PREF_FLIGHT_BLOCKS = "discoverystream.flight.blocks"; 87 const PREF_SELECTED_TOPICS = "discoverystream.topicSelection.selectedTopics"; 88 const PREF_TOPIC_SELECTION_ENABLED = "discoverystream.topicSelection.enabled"; 89 const PREF_TOPIC_SELECTION_PREVIOUS_SELECTED = 90 "discoverystream.topicSelection.hasBeenUpdatedPreviously"; 91 const PREF_SPOCS_CACHE_ONDEMAND = "discoverystream.spocs.onDemand"; 92 const PREF_SPOCS_CACHE_TIMEOUT = "discoverystream.spocs.cacheTimeout"; 93 const PREF_SPOCS_STARTUP_CACHE_ENABLED = 94 "discoverystream.spocs.startupCache.enabled"; 95 const PREF_CONTEXTUAL_ADS = "discoverystream.sections.contextualAds.enabled"; 96 const PREF_USER_INFERRED_PERSONALIZATION = 97 "discoverystream.sections.personalization.inferred.user.enabled"; 98 const PREF_SYSTEM_INFERRED_PERSONALIZATION = 99 "discoverystream.sections.personalization.inferred.enabled"; 100 const PREF_INFERRED_INTERESTS_OVERRIDE = 101 "discoverystream.sections.personalization.inferred.interests.override"; 102 103 const PREF_MERINO_OHTTP = "discoverystream.merino-provider.ohttp.enabled"; 104 const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard"; 105 const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; 106 const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position"; 107 const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position"; 108 const PREF_CONTEXTUAL_BANNER_PLACEMENTS = 109 "discoverystream.placements.contextualBanners"; 110 const PREF_CONTEXTUAL_BANNER_COUNTS = 111 "discoverystream.placements.contextualBanners.counts"; 112 113 const PREF_SECTIONS_ENABLED = "discoverystream.sections.enabled"; 114 const PREF_SECTIONS_FOLLOWING = "discoverystream.sections.following"; 115 const PREF_SECTIONS_BLOCKED = "discoverystream.sections.blocked"; 116 const PREF_INTEREST_PICKER_ENABLED = 117 "discoverystream.sections.interestPicker.enabled"; 118 const PREF_VISIBLE_SECTIONS = 119 "discoverystream.sections.interestPicker.visibleSections"; 120 const PREF_PRIVATE_PING_ENABLED = "telemetry.privatePing.enabled"; 121 const PREF_SURFACE_ID = "telemetry.surfaceId"; 122 const PREF_CLIENT_LAYOUT_ENABLED = 123 "discoverystream.sections.clientLayout.enabled"; 124 125 let getHardcodedLayout; 126 127 ChromeUtils.defineLazyGetter(lazy, "userAgent", () => { 128 return Cc["@mozilla.org/network/protocol;1?name=http"].getService( 129 Ci.nsIHttpProtocolHandler 130 ).userAgent; 131 }); 132 133 export class DiscoveryStreamFeed { 134 constructor() { 135 // Internal state for checking if we've intialized all our data 136 this.loaded = false; 137 138 // Persistent cache for remote endpoint data. 139 this.cache = new lazy.PersistentCache(CACHE_KEY, true); 140 this.locale = Services.locale.appLocaleAsBCP47; 141 this._impressionId = this.getOrCreateImpressionId(); 142 // Internal in-memory cache for parsing json prefs. 143 this._prefCache = {}; 144 } 145 146 getOrCreateImpressionId() { 147 let impressionId = Services.prefs.getCharPref(PREF_IMPRESSION_ID, ""); 148 if (!impressionId) { 149 impressionId = String(Services.uuid.generateUUID()); 150 Services.prefs.setCharPref(PREF_IMPRESSION_ID, impressionId); 151 } 152 return impressionId; 153 } 154 155 get config() { 156 if (this._prefCache.config) { 157 return this._prefCache.config; 158 } 159 try { 160 this._prefCache.config = JSON.parse( 161 this.store.getState().Prefs.values[PREF_CONFIG] 162 ); 163 } catch (e) { 164 // istanbul ignore next 165 this._prefCache.config = {}; 166 // istanbul ignore next 167 console.error( 168 `Could not parse preference. Try resetting ${PREF_CONFIG} in about:config.`, 169 e 170 ); 171 } 172 this._prefCache.config.enabled = 173 this._prefCache.config.enabled && 174 this.store.getState().Prefs.values[PREF_ENABLED]; 175 176 return this._prefCache.config; 177 } 178 179 resetConfigDefauts() { 180 this.store.dispatch({ 181 type: at.CLEAR_PREF, 182 data: { 183 name: PREF_CONFIG, 184 }, 185 }); 186 } 187 188 get region() { 189 return lazy.Region.home; 190 } 191 192 get isContextualAds() { 193 if (this._isContextualAds === undefined) { 194 // We care about if the contextual ads pref is on, if contextual is supported, 195 // and if inferred is on, but OHTTP is off. 196 const state = this.store.getState(); 197 const marsOhttpEnabled = state.Prefs.values[PREF_UNIFIED_ADS_OHTTP]; 198 const contextualAds = state.Prefs.values[PREF_CONTEXTUAL_ADS]; 199 const inferredPersonalization = 200 state.Prefs.values[PREF_USER_INFERRED_PERSONALIZATION] && 201 state.Prefs.values[PREF_SYSTEM_INFERRED_PERSONALIZATION]; 202 const sectionsEnabled = state.Prefs.values[PREF_SECTIONS_ENABLED]; 203 // We want this if contextual ads are on, and also if inferred personalization is on, we also use OHTTP. 204 const useContextualAds = 205 contextualAds && 206 ((inferredPersonalization && marsOhttpEnabled) || 207 !inferredPersonalization); 208 this._isContextualAds = sectionsEnabled && useContextualAds; 209 } 210 211 return this._isContextualAds; 212 } 213 214 get doLocalInferredRerank() { 215 if (this._doLocalInferredRerank === undefined) { 216 const state = this.store.getState(); 217 218 const inferredPersonalization = 219 state.Prefs.values[PREF_USER_INFERRED_PERSONALIZATION] && 220 state.Prefs.values[PREF_SYSTEM_INFERRED_PERSONALIZATION]; 221 const sectionsEnabled = state.Prefs.values[PREF_SECTIONS_ENABLED]; 222 223 const systemPref = inferredPersonalization && sectionsEnabled; 224 const expPref = 225 state.Prefs.values.inferredPersonalizationConfig 226 ?.local_popular_today_rerank ?? LOCAL_POPULAR_RERANK; 227 228 // we do it if inferred is on and the experiment is on 229 this._doLocalInferredRerank = systemPref && expPref; 230 } 231 return this._doLocalInferredRerank; 232 } 233 234 get showSponsoredStories() { 235 // Combine user-set sponsored opt-out with Mozilla-set config 236 return ( 237 this.store.getState().Prefs.values[PREF_SHOW_SPONSORED] && 238 this.store.getState().Prefs.values[PREF_SYSTEM_SHOW_SPONSORED] 239 ); 240 } 241 242 get showStories() { 243 // Combine user-set stories opt-out with Mozilla-set config 244 return ( 245 this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES] && 246 this.store.getState().Prefs.values[PREF_USER_TOPSTORIES] 247 ); 248 } 249 250 get personalized() { 251 return this.recommendationProvider.personalized; 252 } 253 254 get recommendationProvider() { 255 if (this._recommendationProvider) { 256 return this._recommendationProvider; 257 } 258 this._recommendationProvider = this.store.feeds.get( 259 "feeds.recommendationprovider" 260 ); 261 return this._recommendationProvider; 262 } 263 264 setupConfig(isStartup = false) { 265 // Send the initial state of the pref on our reducer 266 this.store.dispatch( 267 ac.BroadcastToContent({ 268 type: at.DISCOVERY_STREAM_CONFIG_SETUP, 269 data: this.config, 270 meta: { 271 isStartup, 272 }, 273 }) 274 ); 275 } 276 277 async setupDevtoolsState(isStartup = false) { 278 const cachedData = (await this.cache.get()) || {}; 279 let impressions = cachedData.recsImpressions || {}; 280 let blocks = cachedData.recsBlocks || {}; 281 282 this.store.dispatch({ 283 type: at.DISCOVERY_STREAM_DEV_IMPRESSIONS, 284 data: impressions, 285 meta: { 286 isStartup, 287 }, 288 }); 289 290 this.store.dispatch({ 291 type: at.DISCOVERY_STREAM_DEV_BLOCKS, 292 data: blocks, 293 meta: { 294 isStartup, 295 }, 296 }); 297 } 298 299 setupPrefs(isStartup = false) { 300 const experimentMetadata = 301 lazy.NimbusFeatures.pocketNewtab.getEnrollmentMetadata(); 302 303 let utmSource = "pocket-newtab"; 304 let utmCampaign = experimentMetadata?.slug; 305 let utmContent = experimentMetadata?.branch; 306 307 this.store.dispatch( 308 ac.BroadcastToContent({ 309 type: at.DISCOVERY_STREAM_EXPERIMENT_DATA, 310 data: { 311 utmSource, 312 utmCampaign, 313 utmContent, 314 }, 315 meta: { 316 isStartup, 317 }, 318 }) 319 ); 320 321 const nimbusConfig = this.store.getState().Prefs.values?.pocketConfig || {}; 322 const { region } = this.store.getState().Prefs.values; 323 324 const hideDescriptionsRegions = nimbusConfig.hideDescriptionsRegions 325 ?.split(",") 326 .map(s => s.trim()); 327 const hideDescriptions = 328 nimbusConfig.hideDescriptions || 329 hideDescriptionsRegions?.includes(region); 330 331 // We don't BroadcastToContent for this, as the changes may 332 // shift around elements on an open newtab the user is currently reading. 333 // So instead we AlsoToPreloaded so the next tab is updated. 334 // This is because setupPrefs is called by the system and not a user interaction. 335 this.store.dispatch( 336 ac.AlsoToPreloaded({ 337 type: at.DISCOVERY_STREAM_PREFS_SETUP, 338 data: { 339 hideDescriptions, 340 compactImages: nimbusConfig.compactImages, 341 imageGradient: nimbusConfig.imageGradient, 342 newSponsoredLabel: nimbusConfig.newSponsoredLabel, 343 titleLines: nimbusConfig.titleLines, 344 descLines: nimbusConfig.descLines, 345 readTime: nimbusConfig.readTime, 346 }, 347 meta: { 348 isStartup, 349 }, 350 }) 351 ); 352 353 // sync redux store with PersistantCache personalization data 354 this.configureFollowedSections(isStartup); 355 } 356 357 async configureFollowedSections(isStartup = false) { 358 const prefs = this.store.getState().Prefs.values; 359 const cachedData = (await this.cache.get()) || {}; 360 let { sectionPersonalization } = cachedData; 361 362 // if sectionPersonalization is empty, populate it with data from the followed and blocked prefs 363 // eventually we could remove this (maybe once more of sections is added to release) 364 if ( 365 sectionPersonalization && 366 Object.keys(sectionPersonalization).length === 0 367 ) { 368 // Raw string of followed/blocked topics, ex: "entertainment, news" 369 const followedSectionsString = prefs[PREF_SECTIONS_FOLLOWING]; 370 const blockedSectionsString = prefs[PREF_SECTIONS_BLOCKED]; 371 // Format followed sections 372 const followedSections = followedSectionsString 373 ? followedSectionsString.split(",").map(s => s.trim()) 374 : []; 375 376 // Format blocked sections 377 const blockedSections = blockedSectionsString 378 ? blockedSectionsString.split(",").map(s => s.trim()) 379 : []; 380 381 const sectionTopics = new Set([...followedSections, ...blockedSections]); 382 sectionPersonalization = Array.from(sectionTopics).reduce( 383 (acc, section) => { 384 acc[section] = { 385 isFollowed: followedSections.includes(section), 386 isBlocked: blockedSections.includes(section), 387 }; 388 return acc; 389 }, 390 {} 391 ); 392 await this.cache.set( 393 "sectionPersonalization", 394 sectionPersonalization || {} 395 ); 396 } 397 this.store.dispatch( 398 ac.BroadcastToContent({ 399 type: at.SECTION_PERSONALIZATION_UPDATE, 400 data: sectionPersonalization || {}, 401 meta: { 402 isStartup, 403 }, 404 }) 405 ); 406 } 407 408 uninitPrefs() { 409 // Reset in-memory cache 410 this._prefCache = {}; 411 } 412 413 async fetchFromEndpoint(endpoint, options = {}, useOhttp = false) { 414 let fetchPromise; 415 if (!endpoint) { 416 console.error("Tried to fetch endpoint but none was configured."); 417 return null; 418 } 419 420 const ohttpRelayURL = Services.prefs.getStringPref( 421 "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL", 422 "" 423 ); 424 const ohttpConfigURL = Services.prefs.getStringPref( 425 "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL", 426 "" 427 ); 428 429 try { 430 // Make sure the requested endpoint is allowed 431 const allowed = 432 this.store 433 .getState() 434 .Prefs.values[PREF_ENDPOINTS].split(",") 435 .map(item => item.trim()) 436 .filter(item => item) || []; 437 if (!allowed.some(prefix => endpoint.startsWith(prefix))) { 438 throw new Error(`Not one of allowed prefixes (${allowed})`); 439 } 440 441 const controller = new AbortController(); 442 const { signal } = controller; 443 444 if (useOhttp && ohttpConfigURL && ohttpRelayURL) { 445 let config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL); 446 447 if (!config) { 448 console.error( 449 new Error( 450 `OHTTP was configured for ${endpoint} but we couldn't fetch a valid config` 451 ) 452 ); 453 return null; 454 } 455 456 // ObliviousHTTP.ohttpRequest only accepts a key/value object, and not 457 // a Headers instance. We normalize any headers to a key/value object. 458 // 459 // We use instanceof here since isInstance isn't available for node 460 // tests like DiscoveryStreamFeed.test.js. 461 // eslint-disable-next-line mozilla/use-isInstance 462 if (options.headers && options.headers instanceof Headers) { 463 options.headers = Object.fromEntries(options.headers); 464 } 465 466 fetchPromise = lazy.ObliviousHTTP.ohttpRequest( 467 ohttpRelayURL, 468 config, 469 endpoint, 470 { 471 ...options, 472 credentials: "omit", 473 signal, 474 } 475 ); 476 } else { 477 fetchPromise = fetch(endpoint, { 478 ...options, 479 credentials: "omit", 480 signal, 481 }); 482 } 483 484 // istanbul ignore next 485 const timeoutId = setTimeout(() => { 486 controller.abort(); 487 }, FETCH_TIMEOUT); 488 489 const response = await fetchPromise; 490 491 if (!response.ok) { 492 throw new Error(`Unexpected status (${response.status})`); 493 } 494 clearTimeout(timeoutId); 495 496 return response.json(); 497 } catch (error) { 498 console.error(`Failed to fetch ${endpoint}:`, error.message); 499 } 500 return null; 501 } 502 get spocsOnDemand() { 503 if (this._spocsOnDemand === undefined) { 504 const { values } = this.store.getState().Prefs; 505 const spocsOnDemandConfig = values.trainhopConfig?.spocsOnDemand || {}; 506 const spocsOnDemand = 507 spocsOnDemandConfig.enabled || values[PREF_SPOCS_CACHE_ONDEMAND]; 508 this._spocsOnDemand = this.showSponsoredStories && spocsOnDemand; 509 } 510 511 return this._spocsOnDemand; 512 } 513 514 get spocsCacheUpdateTime() { 515 if (this._spocsCacheUpdateTime === undefined) { 516 const { values } = this.store.getState().Prefs; 517 const spocsOnDemandConfig = values.trainhopConfig?.spocsOnDemand || {}; 518 const spocsCacheTimeout = 519 spocsOnDemandConfig.timeout || values[PREF_SPOCS_CACHE_TIMEOUT]; 520 const MAX_TIMEOUT = 30; 521 const MIN_TIMEOUT = 5; 522 523 // We have some guard rails against misconfigured values. 524 // Ignore 0: a zero-minute timeout would cause constant fetches. 525 // Check min max times, or ensure we don't make requests on a timer. 526 const guardRailed = 527 spocsCacheTimeout && 528 (this.spocsOnDemand || 529 (spocsCacheTimeout <= MAX_TIMEOUT && 530 spocsCacheTimeout >= MIN_TIMEOUT)); 531 532 if (guardRailed) { 533 // This value is in minutes, but we want ms. 534 this._spocsCacheUpdateTime = spocsCacheTimeout * 60 * 1000; 535 } else { 536 // The const is already in ms. 537 this._spocsCacheUpdateTime = SPOCS_FEEDS_UPDATE_TIME; 538 } 539 } 540 541 return this._spocsCacheUpdateTime; 542 } 543 544 /** 545 * Returns true if data in the cache for a particular key has expired or is missing. 546 * 547 * @param {object} cachedData data returned from cache.get() 548 * @param {string} key a cache key 549 * @param {string?} url for "feed" only, the URL of the feed. 550 * @param {boolean} is this check done at initial browser load 551 */ 552 isExpired({ cachedData, key, url, isStartup }) { 553 const { spocs, feeds } = cachedData; 554 const updateTimePerComponent = { 555 spocs: this.spocsCacheUpdateTime, 556 feed: COMPONENT_FEEDS_UPDATE_TIME, 557 }; 558 const EXPIRATION_TIME = isStartup 559 ? STARTUP_CACHE_EXPIRE_TIME 560 : updateTimePerComponent[key]; 561 562 switch (key) { 563 case "spocs": 564 return !spocs || !(Date.now() - spocs.lastUpdated < EXPIRATION_TIME); 565 case "feed": { 566 if (!feeds || !feeds[url]) { 567 return true; 568 } 569 const feed = feeds[url]; 570 const isTimeExpired = Date.now() - feed.lastUpdated >= EXPIRATION_TIME; 571 const sectionsEnabled = 572 this.store.getState().Prefs.values[PREF_SECTIONS_ENABLED]; 573 const sectionsEnabledChanged = feed.sectionsEnabled !== sectionsEnabled; 574 return isTimeExpired || sectionsEnabledChanged; 575 } 576 default: 577 // istanbul ignore next 578 throw new Error(`${key} is not a valid key`); 579 } 580 } 581 582 async _checkExpirationPerComponent() { 583 const cachedData = (await this.cache.get()) || {}; 584 const { feeds } = cachedData; 585 586 return { 587 spocs: 588 this.showSponsoredStories && 589 this.isExpired({ cachedData, key: "spocs" }), 590 feeds: 591 this.showStories && 592 (!feeds || 593 Object.keys(feeds).some(url => 594 this.isExpired({ cachedData, key: "feed", url }) 595 )), 596 }; 597 } 598 599 updatePlacements(sendUpdate, layout, isStartup = false) { 600 const placements = []; 601 const placementsMap = {}; 602 for (const row of layout.filter(r => r.components && r.components.length)) { 603 for (const component of row.components.filter( 604 c => c.placement && c.spocs 605 )) { 606 // If we find a valid placement, we set it to this value. 607 let placement; 608 609 if (this.showSponsoredStories) { 610 placement = component.placement; 611 } 612 613 // Validate this placement and check for dupes. 614 if (placement?.name && !placementsMap[placement.name]) { 615 placementsMap[placement.name] = placement; 616 placements.push(placement); 617 } 618 } 619 } 620 621 // Update placements data. 622 // Even if we have no placements, we still want to update it to clear it. 623 sendUpdate({ 624 type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS, 625 data: { placements }, 626 meta: { 627 isStartup, 628 }, 629 }); 630 } 631 632 /** 633 * Adds a query string to a URL. 634 * A query can be any string literal accepted by https://developer.mozilla.org/docs/Web/API/URLSearchParams 635 * Examples: "?foo=1&bar=2", "&foo=1&bar=2", "foo=1&bar=2", "?bar=2" or "bar=2" 636 */ 637 addEndpointQuery(url, query) { 638 if (!query) { 639 return url; 640 } 641 642 const urlObject = new URL(url); 643 const params = new URLSearchParams(query); 644 645 for (let [key, val] of params.entries()) { 646 urlObject.searchParams.append(key, val); 647 } 648 649 return urlObject.toString(); 650 } 651 652 parseGridPositions(csvPositions) { 653 let gridPositions; 654 655 // Only accept parseable non-negative integers 656 try { 657 gridPositions = csvPositions.map(index => { 658 let parsedInt = parseInt(index, 10); 659 660 if (!isNaN(parsedInt) && parsedInt >= 0) { 661 return parsedInt; 662 } 663 664 throw new Error("Bad input"); 665 }); 666 } catch (e) { 667 // Catch spoc positions that are not numbers or negative, and do nothing. 668 // We have hard coded backup positions. 669 gridPositions = undefined; 670 } 671 672 return gridPositions; 673 } 674 675 generateFeedUrl() { 676 return `https://${Services.prefs.getStringPref( 677 "browser.newtabpage.activity-stream.discoverystream.merino-provider.endpoint" 678 )}/api/v1/curated-recommendations`; 679 } 680 681 loadLayout(sendUpdate, isStartup) { 682 let layoutData = {}; 683 let url = ""; 684 685 const isBasicLayout = 686 this.config.hardcoded_basic_layout || 687 this.store.getState().Prefs.values[PREF_HARDCODED_BASIC_LAYOUT] || 688 this.store.getState().Prefs.values[PREF_REGION_BASIC_LAYOUT]; 689 690 const pocketConfig = this.store.getState().Prefs.values?.pocketConfig || {}; 691 const items = isBasicLayout ? 4 : 24; 692 const ctaButtonSponsors = pocketConfig.ctaButtonSponsors 693 ?.split(",") 694 .map(s => s.trim().toLowerCase()); 695 let ctaButtonVariant = ""; 696 // We specifically against hard coded values, instead of applying whatever is in the pref. 697 // This is to ensure random class names from a user modified pref doesn't make it into the class list. 698 if ( 699 pocketConfig.ctaButtonVariant === "variant-a" || 700 pocketConfig.ctaButtonVariant === "variant-b" 701 ) { 702 ctaButtonVariant = pocketConfig.ctaButtonVariant; 703 } 704 705 const topicSelectionHasBeenUpdatedPreviously = 706 this.store.getState().Prefs.values[ 707 PREF_TOPIC_SELECTION_PREVIOUS_SELECTED 708 ]; 709 710 const selectedTopics = 711 this.store.getState().Prefs.values[PREF_SELECTED_TOPICS]; 712 713 // Note: This requires a cache update to react to a pref update 714 const pocketStoriesHeadlineId = 715 topicSelectionHasBeenUpdatedPreviously || selectedTopics 716 ? "newtab-section-header-todays-picks" 717 : "newtab-section-header-stories"; 718 719 pocketConfig.pocketStoriesHeadlineId = pocketStoriesHeadlineId; 720 721 const prepConfArr = arr => { 722 return arr 723 ?.split(",") 724 .filter(item => item) 725 .map(item => parseInt(item, 10)); 726 }; 727 728 const spocAdTypes = prepConfArr(pocketConfig.spocAdTypes); 729 const spocZoneIds = prepConfArr(pocketConfig.spocZoneIds); 730 const { spocSiteId } = pocketConfig; 731 let spocPlacementData; 732 let spocsUrl; 733 734 if (spocAdTypes?.length && spocZoneIds?.length) { 735 spocPlacementData = { 736 ad_types: spocAdTypes, 737 zone_ids: spocZoneIds, 738 }; 739 } 740 741 if (spocSiteId) { 742 const newUrl = new URL(SPOCS_URL); 743 newUrl.searchParams.set("site", spocSiteId); 744 spocsUrl = newUrl.href; 745 } 746 747 let feedUrl = this.generateFeedUrl(); 748 749 // Set layout config. 750 // Changing values in this layout in memory object is unnecessary. 751 layoutData = getHardcodedLayout({ 752 spocsUrl, 753 feedUrl, 754 items, 755 spocPlacementData, 756 spocPositions: this.parseGridPositions( 757 this.store.getState().Prefs.values[PREF_SPOC_POSITIONS]?.split(`,`) 758 ), 759 widgetPositions: this.parseGridPositions( 760 pocketConfig.widgetPositions?.split(`,`) 761 ), 762 widgetData: [ 763 ...(this.locale.startsWith("en-") ? [{ type: "TopicsWidget" }] : []), 764 ], 765 hybridLayout: pocketConfig.hybridLayout, 766 hideCardBackground: pocketConfig.hideCardBackground, 767 fourCardLayout: pocketConfig.fourCardLayout, 768 newFooterSection: pocketConfig.newFooterSection, 769 compactGrid: pocketConfig.compactGrid, 770 // For now button variants are for experimentation and English only. 771 ctaButtonSponsors: this.locale.startsWith("en-") ? ctaButtonSponsors : [], 772 ctaButtonVariant: this.locale.startsWith("en-") ? ctaButtonVariant : "", 773 pocketStoriesHeadlineId: pocketConfig.pocketStoriesHeadlineId, 774 }); 775 776 sendUpdate({ 777 type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, 778 data: layoutData, 779 meta: { 780 isStartup, 781 }, 782 }); 783 784 if (layoutData.spocs) { 785 url = 786 this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT] || 787 this.config.spocs_endpoint || 788 layoutData.spocs.url; 789 790 const spocsEndpointQuery = 791 this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT_QUERY]; 792 793 // For QA, testing, or debugging purposes, there may be a query string to add. 794 url = this.addEndpointQuery(url, spocsEndpointQuery); 795 796 if ( 797 url && 798 url !== this.store.getState().DiscoveryStream.spocs.spocs_endpoint 799 ) { 800 sendUpdate({ 801 type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT, 802 data: { 803 url, 804 }, 805 meta: { 806 isStartup, 807 }, 808 }); 809 this.updatePlacements(sendUpdate, layoutData.layout, isStartup); 810 } 811 } 812 } 813 814 /** 815 * Adds the promise result to newFeeds and pushes a promise to newsFeedsPromises. 816 * 817 * @param {object} Has both newFeedsPromises (Array) and newFeeds (Object) 818 * @param {boolean} isStartup We have different cache handling for startup. 819 * @returns {Function} We return a function so we can contain 820 * the scope for isStartup and the promises object. 821 * Combines feed results and promises for each component with a feed. 822 */ 823 buildFeedPromise( 824 { newFeedsPromises, newFeeds }, 825 isStartup = false, 826 sendUpdate 827 ) { 828 return component => { 829 const { url } = component.feed; 830 831 if (!newFeeds[url]) { 832 // We initially stub this out so we don't fetch dupes, 833 // we then fill in with the proper object inside the promise. 834 newFeeds[url] = {}; 835 const feedPromise = this.getComponentFeed(url, isStartup); 836 837 feedPromise 838 .then(feed => { 839 // I think we could reduce doing this for cache fetches. 840 // Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1606277 841 // We can remove filterRecommendations once ESR catches up to bug 1932196 842 newFeeds[url] = this.filterRecommendations(feed); 843 sendUpdate({ 844 type: at.DISCOVERY_STREAM_FEED_UPDATE, 845 data: { 846 feed: newFeeds[url], 847 url, 848 }, 849 meta: { 850 isStartup, 851 }, 852 }); 853 }) 854 .catch( 855 /* istanbul ignore next */ error => { 856 console.error( 857 `Error trying to load component feed ${url}:`, 858 error 859 ); 860 } 861 ); 862 newFeedsPromises.push(feedPromise); 863 } 864 }; 865 } 866 867 // This filters just recommendations using NewTabUtils.blockedLinks only. 868 // This is essentially a sync blocked links filter. filterBlocked is async. 869 // See bug 1606277. 870 filterRecommendations(feed) { 871 if (feed?.data?.recommendations?.length) { 872 const recommendations = feed.data.recommendations.filter(item => { 873 const blocked = lazy.NewTabUtils.blockedLinks.isBlocked({ 874 url: item.url, 875 }); 876 return !blocked; 877 }); 878 879 return { 880 ...feed, 881 data: { 882 ...feed.data, 883 recommendations, 884 }, 885 }; 886 } 887 return feed; 888 } 889 890 /** 891 * Filters out components with no feeds, and combines all feeds on this component 892 * with the feeds from other components. 893 * 894 * @param {boolean} isStartup We have different cache handling for startup. 895 * @returns {Function} We return a function so we can contain the scope for isStartup. 896 * Reduces feeds into promises and feed data. 897 */ 898 reduceFeedComponents(isStartup, sendUpdate) { 899 return (accumulator, row) => { 900 row.components 901 .filter(component => component && component.feed) 902 .forEach(this.buildFeedPromise(accumulator, isStartup, sendUpdate)); 903 return accumulator; 904 }; 905 } 906 907 /** 908 * Filters out rows with no components, and gets us a promise for each unique feed. 909 * 910 * @param {object} layout This is the Discovery Stream layout object. 911 * @param {boolean} isStartup We have different cache handling for startup. 912 * @returns {object} An object with newFeedsPromises (Array) and newFeeds (Object), 913 * we can Promise.all newFeedsPromises to get completed data in newFeeds. 914 */ 915 buildFeedPromises(layout, isStartup, sendUpdate) { 916 const initialData = { 917 newFeedsPromises: [], 918 newFeeds: {}, 919 }; 920 return layout 921 .filter(row => row && row.components) 922 .reduce(this.reduceFeedComponents(isStartup, sendUpdate), initialData); 923 } 924 925 async loadComponentFeeds(sendUpdate, isStartup = false) { 926 const { DiscoveryStream } = this.store.getState(); 927 928 if (!DiscoveryStream || !DiscoveryStream.layout) { 929 return; 930 } 931 932 // Reset the flag that indicates whether or not at least one API request 933 // was issued to fetch the component feed in `getComponentFeed()`. 934 this.componentFeedFetched = false; 935 const { newFeedsPromises, newFeeds } = this.buildFeedPromises( 936 DiscoveryStream.layout, 937 isStartup, 938 sendUpdate 939 ); 940 941 // Each promise has a catch already built in, so no need to catch here. 942 await Promise.all(newFeedsPromises); 943 await this.cache.set("feeds", newFeeds); 944 sendUpdate({ 945 type: at.DISCOVERY_STREAM_FEEDS_UPDATE, 946 meta: { 947 isStartup, 948 }, 949 }); 950 } 951 952 getPlacements() { 953 const { placements } = this.store.getState().DiscoveryStream.spocs; 954 return placements; 955 } 956 957 // I wonder, can this be better as a reducer? 958 // See Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1606717 959 placementsForEach(callback) { 960 this.getPlacements().forEach(callback); 961 } 962 963 // Bug 1567271 introduced meta data on a list of spocs. 964 // This involved moving the spocs array into an items prop. 965 // However, old data could still be returned, and cached data might also be old. 966 // For ths reason, we want to ensure if we don't find an items array, 967 // we use the previous array placement, and then stub out title and context to empty strings. 968 // We need to do this *after* both fresh fetches and cached data to reduce repetition. 969 970 // Bug 1916488 introduced a new data stricture from the unified ads API. 971 // We want to maintain both implementations until we're done rollout out, 972 // so for now we are going to normlaize the new data to match the old data props, 973 // so we can change as little as possible. Once we commit to one, we can remove all this. 974 normalizeSpocsItems(spocs) { 975 const unifiedAdsEnabled = 976 this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED]; 977 if (unifiedAdsEnabled) { 978 return { 979 items: spocs.map(spoc => ({ 980 format: spoc.format, 981 alt_text: spoc.alt_text, 982 id: spoc.caps?.cap_key, 983 flight_id: spoc.block_key, 984 block_key: spoc.block_key, 985 shim: spoc.callbacks, 986 caps: { 987 flight: { 988 count: spoc.caps?.day, 989 period: SPOCS_CAP_DURATION, 990 }, 991 }, 992 domain: spoc.domain, 993 excerpt: spoc.excerpt, 994 raw_image_src: spoc.image_url, 995 priority: spoc.ranking?.priority || 1, 996 personalization_models: spoc.ranking?.personalization_models, 997 item_score: spoc.ranking?.item_score, 998 sponsor: spoc.sponsor, 999 title: spoc.title, 1000 url: spoc.url, 1001 attribution: spoc.attributions || null, 1002 })), 1003 }; 1004 } 1005 1006 const items = spocs.items || spocs; 1007 const title = spocs.title || ""; 1008 const context = spocs.context || ""; 1009 const sponsor = spocs.sponsor || ""; 1010 // We do not stub sponsored_by_override with an empty string. It is an override, and an empty string 1011 // explicitly means to override the client to display an empty string. 1012 // An empty string is not an no op in this case. Undefined is the proper no op here. 1013 const { sponsored_by_override } = spocs; 1014 // Undefined is fine here. It's optional and only used by collections. 1015 // If we leave it out, you get a collection that cannot be dismissed. 1016 const { flight_id } = spocs; 1017 return { 1018 items, 1019 title, 1020 context, 1021 sponsor, 1022 sponsored_by_override, 1023 ...(flight_id ? { flight_id } : {}), 1024 }; 1025 } 1026 1027 // This returns ad placements that contain IAB content. 1028 // The results are ads that are contextual, and match an IAB category. 1029 getContextualAdsPlacements() { 1030 const state = this.store.getState(); 1031 1032 const billboardEnabled = state.Prefs.values[PREF_BILLBOARD_ENABLED]; 1033 const billboardPosition = state.Prefs.values[PREF_BILLBOARD_POSITION]; 1034 const leaderboardEnabled = state.Prefs.values[PREF_LEADERBOARD_ENABLED]; 1035 const leaderboardPosition = state.Prefs.values[PREF_LEADERBOARD_POSITION]; 1036 1037 function getContextualStringPref(prefName) { 1038 return state.Prefs.values[prefName] 1039 ?.split(",") 1040 .map(s => s.trim()) 1041 .filter(item => item); 1042 } 1043 1044 function getContextualCountPref(prefName) { 1045 return state.Prefs.values[prefName] 1046 ?.split(`,`) 1047 .map(s => s.trim()) 1048 .filter(item => item) 1049 .map(item => parseInt(item, 10)); 1050 } 1051 1052 const placementSpocsArray = getContextualStringPref( 1053 PREF_CONTEXTUAL_SPOC_PLACEMENTS 1054 ); 1055 const countsSpocsArray = getContextualCountPref( 1056 PREF_CONTEXTUAL_SPOC_COUNTS 1057 ); 1058 const bannerPlacementsArray = getContextualStringPref( 1059 PREF_CONTEXTUAL_BANNER_PLACEMENTS 1060 ); 1061 const bannerCountsArray = getContextualCountPref( 1062 PREF_CONTEXTUAL_BANNER_COUNTS 1063 ); 1064 1065 const feeds = state.DiscoveryStream.feeds.data; 1066 1067 const recsFeed = Object.values(feeds).find( 1068 feed => feed?.data?.sections?.length 1069 ); 1070 1071 let iabSections = []; 1072 let iabPlacements = []; 1073 let bannerPlacements = []; 1074 1075 // If we don't have recsFeed, it means we are loading for the first time, 1076 // and don't have any cached data. 1077 // In this situation, we don't fill iabPlacements, 1078 // and go with the non IAB default contextual placement prefs. 1079 if (recsFeed) { 1080 iabSections = recsFeed.data.sections 1081 .filter(section => section.iab) 1082 .sort((a, b) => a.receivedRank - b.receivedRank); 1083 1084 // An array of all iab placement, flattened, sorted, and filtered. 1085 iabPlacements = iabSections 1086 // .filter(section => section.iab) 1087 // .sort((a, b) => a.receivedRank - b.receivedRank) 1088 .reduce((acc, section) => { 1089 const iabArray = section.layout.responsiveLayouts[0].tiles 1090 .filter(tile => tile.hasAd) 1091 .map(() => { 1092 return section.iab; 1093 }); 1094 return [...acc, ...iabArray]; 1095 }, []); 1096 } 1097 1098 const spocPlacements = placementSpocsArray.map((placement, index) => ({ 1099 placement, 1100 count: countsSpocsArray[index], 1101 ...(iabPlacements[index] ? { content: iabPlacements[index] } : {}), 1102 })); 1103 1104 if (billboardEnabled) { 1105 bannerPlacements = bannerPlacementsArray.map((placement, index) => ({ 1106 placement, 1107 count: bannerCountsArray[index], 1108 ...(iabSections[billboardPosition - 2] 1109 ? { content: iabSections[billboardPosition - 2].iab } 1110 : {}), 1111 })); 1112 } else if (leaderboardEnabled) { 1113 bannerPlacements = bannerPlacementsArray.map((placement, index) => ({ 1114 placement, 1115 count: bannerCountsArray[index], 1116 ...(iabSections[leaderboardPosition - 2] 1117 ? { content: iabSections[leaderboardPosition - 2].iab } 1118 : {}), 1119 })); 1120 } 1121 1122 return [...spocPlacements, ...bannerPlacements]; 1123 } 1124 1125 // This returns ad placements that don't contain IAB content. 1126 // The results are ads that are not contextual, and can be of any IAB category. 1127 getSimpleAdsPlacements() { 1128 const state = this.store.getState(); 1129 const placementsArray = state.Prefs.values[PREF_SPOC_PLACEMENTS]?.split(`,`) 1130 .map(s => s.trim()) 1131 .filter(item => item); 1132 const countsArray = state.Prefs.values[PREF_SPOC_COUNTS]?.split(`,`) 1133 .map(s => s.trim()) 1134 .filter(item => item) 1135 .map(item => parseInt(item, 10)); 1136 1137 return placementsArray.map((placement, index) => ({ 1138 placement, 1139 count: countsArray[index], 1140 })); 1141 } 1142 1143 getAdsPlacements() { 1144 // We can replace unifiedAdsPlacements if we have and can use contextual ads. 1145 // No longer relying on pref based placements and counts. 1146 if (this.isContextualAds) { 1147 return this.getContextualAdsPlacements(); 1148 } 1149 return this.getSimpleAdsPlacements(); 1150 } 1151 1152 async updateOrRemoveSpocs() { 1153 const dispatch = update => 1154 this.store.dispatch(ac.BroadcastToContent(update)); 1155 // We refresh placements data because one of the spocs were turned off. 1156 this.updatePlacements( 1157 dispatch, 1158 this.store.getState().DiscoveryStream.layout 1159 ); 1160 // Currently the order of this is important. 1161 // We need to check this after updatePlacements is called, 1162 // because some of the spoc logic depends on the result of placement updates. 1163 if (!this.showSponsoredStories) { 1164 // Ensure we delete any remote data potentially related to spocs. 1165 this.clearSpocs(); 1166 } 1167 // Placements have changed so consider spocs expired, and reload them. 1168 await this.cache.set("spocs", {}); 1169 await this.loadSpocs(dispatch); 1170 } 1171 1172 // eslint-disable-next-line max-statements 1173 async loadSpocs(sendUpdate, isStartup) { 1174 const cachedData = (await this.cache.get()) || {}; 1175 const unifiedAdsEnabled = 1176 this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED]; 1177 1178 const adsFeedEnabled = 1179 this.store.getState().Prefs.values[PREF_UNIFIED_ADS_ADSFEED_ENABLED]; 1180 1181 let spocsState = cachedData.spocs; 1182 let placements = this.getPlacements(); 1183 let unifiedAdsPlacements = []; 1184 1185 if ( 1186 this.showSponsoredStories && 1187 placements?.length && 1188 this.isExpired({ cachedData, key: "spocs", isStartup }) 1189 ) { 1190 if (placements?.length) { 1191 const headers = new Headers(); 1192 headers.append("content-type", "application/json"); 1193 const apiKeyPref = this.config.api_key_pref; 1194 const apiKey = Services.prefs.getCharPref(apiKeyPref, ""); 1195 const state = this.store.getState(); 1196 let endpoint = state.DiscoveryStream.spocs.spocs_endpoint; 1197 let body = { 1198 pocket_id: this._impressionId, 1199 version: 2, 1200 consumer_key: apiKey, 1201 ...(placements.length ? { placements } : {}), 1202 }; 1203 1204 const marsOhttpEnabled = state.Prefs.values[PREF_UNIFIED_ADS_OHTTP]; 1205 1206 // Bug 1964715: Remove this logic when AdsFeed is 100% enabled 1207 if (unifiedAdsEnabled && !adsFeedEnabled) { 1208 const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT]; 1209 endpoint = `${endpointBaseUrl}v1/ads`; 1210 unifiedAdsPlacements = this.getAdsPlacements(); 1211 const blockedSponsors = 1212 state.Prefs.values[PREF_UNIFIED_ADS_BLOCKED_LIST]; 1213 1214 // We need some basic data that we can pass along to the ohttp request. 1215 // We purposefully don't use ohttp on this request. We also expect to 1216 // mostly hit the HTTP cache rather than the network with these requests. 1217 if (marsOhttpEnabled) { 1218 const preFlight = await this.fetchFromEndpoint( 1219 `${endpointBaseUrl}v1/ads-preflight`, 1220 { 1221 method: "GET", 1222 } 1223 ); 1224 1225 if (preFlight) { 1226 // If we don't get a normalized_ua, it means it matched the default userAgent. 1227 headers.append( 1228 "X-User-Agent", 1229 preFlight.normalized_ua || lazy.userAgent 1230 ); 1231 headers.append("X-Geoname-ID", preFlight.geoname_id); 1232 headers.append("X-Geo-Location", preFlight.geo_location); 1233 } 1234 } 1235 1236 body = { 1237 context_id: await lazy.ContextId.request(), 1238 placements: unifiedAdsPlacements, 1239 blocks: blockedSponsors.split(","), 1240 }; 1241 } 1242 1243 let spocsResponse; 1244 // Logic decision point: Query ads servers in this file or utilize AdsFeed method 1245 if (adsFeedEnabled) { 1246 const { spocs, spocPlacements } = state.Ads; 1247 1248 if (spocs) { 1249 spocsResponse = { newtab_spocs: spocs }; 1250 unifiedAdsPlacements = spocPlacements; 1251 } else { 1252 throw new Error("DSFeed cannot read AdsFeed spocs"); 1253 } 1254 } else { 1255 try { 1256 spocsResponse = await this.fetchFromEndpoint( 1257 endpoint, 1258 { 1259 method: "POST", 1260 headers, 1261 body: JSON.stringify(body), 1262 }, 1263 marsOhttpEnabled 1264 ); 1265 } catch (error) { 1266 console.error("Error trying to load spocs feeds:", error); 1267 } 1268 } 1269 1270 if (spocsResponse) { 1271 const fetchTimestamp = Date.now(); 1272 spocsState = { 1273 lastUpdated: fetchTimestamp, 1274 spocs: { 1275 ...spocsResponse, 1276 }, 1277 }; 1278 1279 if (spocsResponse.settings && spocsResponse.settings.feature_flags) { 1280 this.store.dispatch( 1281 ac.OnlyToMain({ 1282 type: at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE, 1283 data: { 1284 override: !spocsResponse.settings.feature_flags.spoc_v2, 1285 }, 1286 }) 1287 ); 1288 } 1289 1290 const spocsResultPromises = this.getPlacements().map( 1291 async placement => { 1292 let freshSpocs = spocsState.spocs[placement.name]; 1293 1294 if (unifiedAdsEnabled) { 1295 if (!unifiedAdsPlacements) { 1296 throw new Error("unifiedAdsPlacements has no value"); 1297 } 1298 1299 // No placements to reduce upon 1300 if (!unifiedAdsPlacements.length) { 1301 return; 1302 } 1303 1304 freshSpocs = unifiedAdsPlacements.reduce( 1305 (accumulator, currentValue) => { 1306 return accumulator.concat( 1307 spocsState.spocs[currentValue.placement] 1308 ); 1309 }, 1310 [] 1311 ); 1312 } 1313 1314 if (!freshSpocs) { 1315 return; 1316 } 1317 1318 // spocs can be returns as an array, or an object with an items array. 1319 // We want to normalize this so all our spocs have an items array. 1320 // There can also be some meta data for title and context. 1321 // This is mostly because of backwards compat. 1322 const { 1323 items: normalizedSpocsItems, 1324 title, 1325 context, 1326 sponsor, 1327 sponsored_by_override, 1328 } = this.normalizeSpocsItems(freshSpocs); 1329 1330 if (!normalizedSpocsItems || !normalizedSpocsItems.length) { 1331 // In the case of old data, we still want to ensure we normalize the data structure, 1332 // even if it's empty. We expect the empty data to be an object with items array, 1333 // and not just an empty array. 1334 spocsState.spocs = { 1335 ...spocsState.spocs, 1336 [placement.name]: { 1337 title, 1338 context, 1339 items: [], 1340 }, 1341 }; 1342 return; 1343 } 1344 1345 // Migrate flight_id 1346 const { data: migratedSpocs } = 1347 this.migrateFlightId(normalizedSpocsItems); 1348 1349 const { data: capResult } = this.frequencyCapSpocs(migratedSpocs); 1350 1351 const { data: blockedResults } = 1352 await this.filterBlocked(capResult); 1353 1354 const { data: spocsWithFetchTimestamp } = this.addFetchTimestamp( 1355 blockedResults, 1356 fetchTimestamp 1357 ); 1358 1359 let items = spocsWithFetchTimestamp; 1360 let personalized = false; 1361 1362 // We only need to rank if we don't have contextual ads. 1363 if (!this.isContextualAds) { 1364 const scoreResults = await this.scoreItems( 1365 spocsWithFetchTimestamp, 1366 "spocs" 1367 ); 1368 items = scoreResults.data; 1369 personalized = scoreResults.personalized; 1370 } 1371 1372 spocsState.spocs = { 1373 ...spocsState.spocs, 1374 [placement.name]: { 1375 title, 1376 context, 1377 sponsor, 1378 sponsored_by_override, 1379 personalized, 1380 items, 1381 }, 1382 }; 1383 } 1384 ); 1385 await Promise.all(spocsResultPromises); 1386 1387 this.cleanUpFlightImpressionPref(spocsState.spocs); 1388 } else { 1389 console.error("No response for spocs_endpoint prop"); 1390 } 1391 } 1392 } 1393 1394 // Use good data if we have it, otherwise nothing. 1395 // We can have no data if spocs set to off. 1396 // We can have no data if request fails and there is no good cache. 1397 // We want to send an update spocs or not, so client can render something. 1398 spocsState = 1399 spocsState && spocsState.spocs 1400 ? spocsState 1401 : { 1402 lastUpdated: Date.now(), 1403 spocs: {}, 1404 }; 1405 await this.cache.set("spocs", { 1406 lastUpdated: spocsState.lastUpdated, 1407 spocs: spocsState.spocs, 1408 spocsOnDemand: this.spocsOnDemand, 1409 spocsCacheUpdateTime: this.spocsCacheUpdateTime, 1410 }); 1411 1412 sendUpdate({ 1413 type: at.DISCOVERY_STREAM_SPOCS_UPDATE, 1414 data: { 1415 lastUpdated: spocsState.lastUpdated, 1416 spocs: spocsState.spocs, 1417 spocsOnDemand: this.spocsOnDemand, 1418 spocsCacheUpdateTime: this.spocsCacheUpdateTime, 1419 }, 1420 meta: { 1421 isStartup, 1422 }, 1423 }); 1424 } 1425 1426 async clearSpocs() { 1427 const state = this.store.getState(); 1428 let endpoint = state.Prefs.values[PREF_SPOCS_CLEAR_ENDPOINT]; 1429 1430 const unifiedAdsEnabled = 1431 state.Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED]; 1432 1433 let body = { 1434 pocket_id: this._impressionId, 1435 }; 1436 1437 if (unifiedAdsEnabled) { 1438 const adsFeedEnabled = 1439 state.Prefs.values[PREF_UNIFIED_ADS_ADSFEED_ENABLED]; 1440 1441 const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT]; 1442 1443 // Exit if there no DELETE endpoint or AdsFeed is enabled (which will handle the DELETE request) 1444 if (!endpointBaseUrl || adsFeedEnabled) { 1445 return; 1446 } 1447 1448 // If rotation is enabled, then the module is going to take care of 1449 // sending the request to MARS to delete the context_id. Otherwise, 1450 // we do it manually here. 1451 if (lazy.ContextId.rotationEnabled) { 1452 await lazy.ContextId.forceRotation(); 1453 } else { 1454 endpoint = `${endpointBaseUrl}v1/delete_user`; 1455 body = { 1456 context_id: await lazy.ContextId.request(), 1457 }; 1458 } 1459 } 1460 1461 if (!endpoint) { 1462 return; 1463 } 1464 const headers = new Headers(); 1465 headers.append("content-type", "application/json"); 1466 const marsOhttpEnabled = state.Prefs.values[PREF_UNIFIED_ADS_OHTTP]; 1467 1468 await this.fetchFromEndpoint( 1469 endpoint, 1470 { 1471 method: "DELETE", 1472 headers, 1473 body: JSON.stringify(body), 1474 }, 1475 marsOhttpEnabled 1476 ); 1477 } 1478 1479 /* 1480 * This function is used to sort any type of story, both spocs and recs. 1481 * This uses hierarchical sorting, first sorting by priority, then by score within a priority. 1482 * This function could be sorting an array of spocs or an array of recs. 1483 * A rec would have priority undefined, and a spoc would probably have a priority set. 1484 * Priority is sorted ascending, so low numbers are the highest priority. 1485 * Score is sorted descending, so high numbers are the highest score. 1486 * Undefined priority values are considered the lowest priority. 1487 * A negative priority is considered the same as undefined, lowest priority. 1488 * A negative priority is unlikely and not currently supported or expected. 1489 * A negative score is a possible use case. 1490 */ 1491 sortItem(a, b) { 1492 // If the priorities are the same, sort based on score. 1493 // If both item priorities are undefined, 1494 // we can safely sort via score. 1495 if (a.priority === b.priority) { 1496 return b.score - a.score; 1497 } else if (!a.priority || a.priority <= 0) { 1498 // If priority is undefined or an unexpected value, 1499 // consider it lowest priority. 1500 return 1; 1501 } else if (!b.priority || b.priority <= 0) { 1502 // Also consider this case lowest priority. 1503 return -1; 1504 } 1505 // Our primary sort for items with priority. 1506 return a.priority - b.priority; 1507 } 1508 1509 async scoreItems(items, type) { 1510 const spocsPersonalized = 1511 this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized; 1512 const recsPersonalized = 1513 this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized; 1514 const personalizedByType = 1515 type === "feed" ? recsPersonalized : spocsPersonalized; 1516 // If this is initialized, we are ready to go. 1517 let personalized = this.store.getState().Personalization.initialized; 1518 let data = null; 1519 if (type === "feed" && this.doLocalInferredRerank) { 1520 // make a flag for this 1521 const { inferredInterests = {} } = 1522 this.store.getState().InferredPersonalization ?? {}; 1523 const weights = { 1524 inferred_norm: Object.entries(inferredInterests).reduce( 1525 (acc, [, v]) => 1526 Number.isFinite(v) && !Number.isInteger(v) ? acc + v : acc, 1527 0 1528 ), 1529 local: 1530 (this.store.getState().Prefs.values?.inferredPersonalizationConfig 1531 ?.local_inferred_weight ?? LOCAL_WEIGHT) / 100, 1532 server: 1533 (this.store.getState().Prefs.values?.inferredPersonalizationConfig 1534 ?.server_inferred_weight ?? SERVER_WEIGHT) / 100, 1535 }; 1536 data = ( 1537 await Promise.all( 1538 items.map(item => scoreItemInferred(item, inferredInterests, weights)) 1539 ) 1540 ) 1541 // Sort by highest scores. 1542 .sort(this.sortItem); 1543 personalized = true; 1544 } else { 1545 data = ( 1546 await Promise.all( 1547 items.map(item => this.scoreItem(item, personalizedByType)) 1548 ) 1549 ) 1550 // Sort by highest scores. 1551 .sort(this.sortItem); 1552 } 1553 1554 return { data, personalized }; 1555 } 1556 1557 async scoreItem(item, personalizedByType) { 1558 item.score = item.item_score; 1559 if (item.score !== 0 && !item.score) { 1560 item.score = 1; 1561 } 1562 if (this.personalized && personalizedByType) { 1563 await this.recommendationProvider.calculateItemRelevanceScore(item); 1564 } 1565 return item; 1566 } 1567 1568 async filterBlocked(data) { 1569 if (data?.length) { 1570 let flights = this.readDataPref(PREF_FLIGHT_BLOCKS); 1571 1572 const cachedData = (await this.cache.get()) || {}; 1573 let blocks = cachedData.recsBlocks || {}; 1574 1575 const filteredItems = data.filter(item => { 1576 const blocked = 1577 lazy.NewTabUtils.blockedLinks.isBlocked({ url: item.url }) || 1578 flights[item.flight_id] || 1579 blocks[item.id]; 1580 return !blocked; 1581 }); 1582 return { data: filteredItems }; 1583 } 1584 return { data }; 1585 } 1586 1587 // Add the fetch timestamp property to each spoc returned to communicate how 1588 // old the spoc is in telemetry when it is used by the client 1589 addFetchTimestamp(spocs, fetchTimestamp) { 1590 if (spocs && spocs.length) { 1591 return { 1592 data: spocs.map(s => { 1593 return { 1594 ...s, 1595 fetchTimestamp, 1596 }; 1597 }), 1598 }; 1599 } 1600 return { data: spocs }; 1601 } 1602 1603 // For backwards compatibility, older spoc endpoint don't have flight_id, 1604 // but instead had campaign_id we can use 1605 // 1606 // @param {Object} data An object that might have a SPOCS array. 1607 // @returns {Object} An object with a property `data` as the result. 1608 migrateFlightId(spocs) { 1609 if (spocs && spocs.length) { 1610 return { 1611 data: spocs.map(s => { 1612 return { 1613 ...s, 1614 ...(s.flight_id || s.campaign_id 1615 ? { 1616 flight_id: s.flight_id || s.campaign_id, 1617 } 1618 : {}), 1619 ...(s.caps 1620 ? { 1621 caps: { 1622 ...s.caps, 1623 flight: s.caps.flight || s.caps.campaign, 1624 }, 1625 } 1626 : {}), 1627 }; 1628 }), 1629 }; 1630 } 1631 return { data: spocs }; 1632 } 1633 1634 // Filter spocs based on frequency caps 1635 // 1636 // @param {Object} data An object that might have a SPOCS array. 1637 // @returns {Object} An object with a property `data` as the result, and a property 1638 // `filterItems` as the frequency capped items. 1639 frequencyCapSpocs(spocs) { 1640 if (spocs?.length) { 1641 const impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS); 1642 const caps = []; 1643 const result = spocs.filter(s => { 1644 const isBelow = this.isBelowFrequencyCap(impressions, s); 1645 if (!isBelow) { 1646 caps.push(s); 1647 } 1648 return isBelow; 1649 }); 1650 // send caps to redux if any. 1651 if (caps.length) { 1652 this.store.dispatch({ 1653 type: at.DISCOVERY_STREAM_SPOCS_CAPS, 1654 data: caps, 1655 }); 1656 } 1657 return { data: result, filtered: caps }; 1658 } 1659 return { data: spocs, filtered: [] }; 1660 } 1661 1662 // Frequency caps are based on flight, which may include multiple spocs. 1663 // We currently support two types of frequency caps: 1664 // - lifetime: Indicates how many times spocs from a flight can be shown in total 1665 // - period: Indicates how many times spocs from a flight can be shown within a period 1666 // 1667 // So, for example, the feed configuration below defines that for flight 1 no more 1668 // than 5 spocs can be shown in total, and no more than 2 per hour. 1669 // "flight_id": 1, 1670 // "caps": { 1671 // "lifetime": 5, 1672 // "flight": { 1673 // "count": 2, 1674 // "period": 3600 1675 // } 1676 // } 1677 isBelowFrequencyCap(impressions, spoc) { 1678 const flightImpressions = impressions[spoc.flight_id]; 1679 if (!flightImpressions) { 1680 return true; 1681 } 1682 1683 const lifetime = spoc.caps && spoc.caps.lifetime; 1684 1685 const lifeTimeCap = Math.min( 1686 lifetime || MAX_LIFETIME_CAP, 1687 MAX_LIFETIME_CAP 1688 ); 1689 const lifeTimeCapExceeded = flightImpressions.length >= lifeTimeCap; 1690 if (lifeTimeCapExceeded) { 1691 return false; 1692 } 1693 1694 const flightCap = spoc.caps && spoc.caps.flight; 1695 if (flightCap) { 1696 const flightCapExceeded = 1697 flightImpressions.filter(i => Date.now() - i < flightCap.period * 1000) 1698 .length >= flightCap.count; 1699 return !flightCapExceeded; 1700 } 1701 return true; 1702 } 1703 1704 async retryFeed(feed) { 1705 const { url } = feed; 1706 const newFeed = await this.getComponentFeed(url); 1707 this.store.dispatch( 1708 ac.BroadcastToContent({ 1709 type: at.DISCOVERY_STREAM_FEED_UPDATE, 1710 data: { 1711 feed: newFeed, 1712 url, 1713 }, 1714 }) 1715 ); 1716 } 1717 1718 getExperimentInfo() { 1719 // We want to know if the user is in an experiment or rollout, 1720 // but we prioritize experiments over rollouts. 1721 const experimentMetadata = 1722 lazy.NimbusFeatures.pocketNewtab.getEnrollmentMetadata(); 1723 1724 let experimentName = experimentMetadata?.slug ?? ""; 1725 let experimentBranch = experimentMetadata?.branch ?? ""; 1726 1727 return { 1728 experimentName, 1729 experimentBranch, 1730 }; 1731 } 1732 1733 // eslint-disable-next-line max-statements 1734 async getComponentFeed(feedUrl, isStartup) { 1735 const cachedData = (await this.cache.get()) || {}; 1736 const prefs = this.store.getState().Prefs.values; 1737 const sectionsEnabled = prefs[PREF_SECTIONS_ENABLED]; 1738 // Should we fetch /curated-recommendations over OHTTP 1739 const merinoOhttpEnabled = prefs[PREF_MERINO_OHTTP]; 1740 let sections = []; 1741 const { feeds } = cachedData; 1742 1743 let feed = feeds ? feeds[feedUrl] : null; 1744 if (this.isExpired({ cachedData, key: "feed", url: feedUrl, isStartup })) { 1745 const options = this.formatComponentFeedRequest( 1746 cachedData.sectionPersonalization 1747 ); 1748 1749 const feedResponse = await this.fetchFromEndpoint( 1750 feedUrl, 1751 options, 1752 merinoOhttpEnabled 1753 ); 1754 1755 if (feedResponse) { 1756 const { settings = {} } = feedResponse; 1757 let { recommendations } = feedResponse; 1758 1759 recommendations = feedResponse.data.map(item => ({ 1760 id: item.corpusItemId || item.scheduledCorpusItemId || item.tileId, 1761 scheduled_corpus_item_id: item.scheduledCorpusItemId, 1762 corpus_item_id: item.corpusItemId, 1763 features: item.features, 1764 excerpt: item.excerpt, 1765 icon_src: item.iconUrl, 1766 isTimeSensitive: item.isTimeSensitive, 1767 publisher: item.publisher, 1768 raw_image_src: item.imageUrl, 1769 received_rank: item.receivedRank, 1770 recommended_at: feedResponse.recommendedAt, 1771 title: item.title, 1772 topic: item.topic, 1773 url: item.url, 1774 })); 1775 1776 if (sectionsEnabled) { 1777 const useClientLayout = 1778 this.store.getState().Prefs.values[PREF_CLIENT_LAYOUT_ENABLED]; 1779 1780 for (const [sectionKey, sectionData] of Object.entries( 1781 feedResponse.feeds 1782 )) { 1783 if (sectionData) { 1784 for (const item of sectionData.recommendations) { 1785 recommendations.push({ 1786 id: 1787 item.corpusItemId || 1788 item.scheduledCorpusItemId || 1789 item.tileId, 1790 scheduled_corpus_item_id: item.scheduledCorpusItemId, 1791 corpus_item_id: item.corpusItemId, 1792 url: item.url, 1793 title: item.title, 1794 topic: item.topic, 1795 features: item.features, 1796 excerpt: item.excerpt, 1797 publisher: item.publisher, 1798 raw_image_src: item.imageUrl, 1799 received_rank: item.receivedRank, 1800 server_score: item.serverScore, 1801 recommended_at: feedResponse.recommendedAt, 1802 section: sectionKey, 1803 icon_src: item.iconUrl, 1804 isTimeSensitive: item.isTimeSensitive, 1805 }); 1806 } 1807 1808 sections.push({ 1809 sectionKey, 1810 title: sectionData.title, 1811 subtitle: sectionData.subtitle || "", 1812 receivedRank: sectionData.receivedFeedRank, 1813 layout: sectionData.layout, 1814 iab: sectionData.iab, 1815 // property if initially shown (with interest picker) 1816 visible: sectionData.isInitiallyVisible, 1817 }); 1818 } 1819 } 1820 1821 if (useClientLayout || sections.some(s => !s.layout)) { 1822 sections.sort((a, b) => a.receivedRank - b.receivedRank); 1823 1824 sections.forEach((section, index) => { 1825 if (useClientLayout || !section.layout) { 1826 section.layout = 1827 lazy.DEFAULT_SECTION_LAYOUT[ 1828 index % lazy.DEFAULT_SECTION_LAYOUT.length 1829 ]; 1830 } 1831 }); 1832 } 1833 } 1834 1835 const { data: scoredItems, personalized } = await this.scoreItems( 1836 recommendations, 1837 "feed" 1838 ); 1839 1840 if (sections.length) { 1841 const visibleSections = sections 1842 .filter(({ visible }) => visible) 1843 .sort((a, b) => a.receivedRank - b.receivedRank) 1844 .map(section => section.sectionKey) 1845 .join(","); 1846 1847 // after the request only show the sections that are 1848 // initially visible and only keep the initial order (determined by the server) 1849 this.store.dispatch( 1850 ac.SetPref(PREF_VISIBLE_SECTIONS, visibleSections) 1851 ); 1852 } 1853 1854 // This assigns the section title to the interestPicker.sections 1855 // object to more easily access the title in JSX files 1856 if ( 1857 feedResponse.interestPicker && 1858 feedResponse.interestPicker.sections 1859 ) { 1860 feedResponse.interestPicker.sections = 1861 feedResponse.interestPicker.sections.map(section => { 1862 const { sectionId } = section; 1863 const title = sections.find( 1864 ({ sectionKey }) => sectionKey === sectionId 1865 )?.title; 1866 return { sectionId, title }; 1867 }); 1868 } 1869 if (feedResponse.inferredLocalModel) { 1870 this.store.dispatch( 1871 ac.AlsoToMain({ 1872 type: at.INFERRED_PERSONALIZATION_MODEL_UPDATE, 1873 data: feedResponse.inferredLocalModel || {}, 1874 }) 1875 ); 1876 } 1877 // We can cleanup any impressions we have that are old before we rotate. 1878 // In theory we can do this anywhere, but doing it just before rotate is optimal. 1879 // Rotate is also the only place that uses these impressions. 1880 await this.cleanUpTopRecImpressions(); 1881 const rotatedItems = await this.rotate(scoredItems); 1882 1883 const { data: filteredResults } = 1884 await this.filterBlocked(rotatedItems); 1885 this.componentFeedFetched = true; 1886 feed = { 1887 lastUpdated: Date.now(), 1888 personalized, 1889 sectionsEnabled, 1890 data: { 1891 settings, 1892 sections, 1893 interestPicker: feedResponse.interestPicker || {}, 1894 recommendations: filteredResults, 1895 surfaceId: feedResponse.surfaceId || "", 1896 status: "success", 1897 }, 1898 }; 1899 } else { 1900 console.error("No response for feed"); 1901 } 1902 } 1903 1904 // if surfaceID is availible either through the cache or the response set value in Glean 1905 if (prefs[PREF_PRIVATE_PING_ENABLED] && feed.data.surfaceId) { 1906 Glean.newtabContent.surfaceId.set(feed.data.surfaceId); 1907 this.store.dispatch(ac.SetPref(PREF_SURFACE_ID, feed.data.surfaceId)); 1908 } 1909 1910 // If we have no feed at this point, both fetch and cache failed for some reason. 1911 return ( 1912 feed || { 1913 data: { 1914 status: "failed", 1915 }, 1916 } 1917 ); 1918 } 1919 1920 formatComponentFeedRequest(sectionPersonalization = {}) { 1921 const prefs = this.store.getState().Prefs.values; 1922 const inferredPersonalization = 1923 prefs[PREF_USER_INFERRED_PERSONALIZATION] && 1924 prefs[PREF_SYSTEM_INFERRED_PERSONALIZATION]; 1925 const merinoOhttpEnabled = prefs[PREF_MERINO_OHTTP]; 1926 const headers = new Headers(); 1927 const topicSelectionEnabled = prefs[PREF_TOPIC_SELECTION_ENABLED]; 1928 const topicsString = prefs[PREF_SELECTED_TOPICS]; 1929 const topics = topicSelectionEnabled 1930 ? topicsString 1931 .split(",") 1932 .map(s => s.trim()) 1933 .filter(item => item) 1934 : []; 1935 1936 // Should we pass the experiment branch and slug to the Merino feed request. 1937 const prefMerinoFeedExperiment = Services.prefs.getBoolPref( 1938 PREF_MERINO_FEED_EXPERIMENT 1939 ); 1940 1941 // convert section to array to match what merino is expecting 1942 const sections = Object.entries(sectionPersonalization).map( 1943 ([sectionId, data]) => ({ 1944 sectionId, 1945 isFollowed: data.isFollowed, 1946 isBlocked: data.isBlocked, 1947 ...(data.followedAt && { followedAt: data.followedAt }), 1948 }) 1949 ); 1950 1951 // To display the inline interest picker pass `enableInterestPicker` into the request 1952 const interestPickerEnabled = prefs[PREF_INTEREST_PICKER_ENABLED]; 1953 1954 let inferredInterests = null; 1955 if (inferredPersonalization && merinoOhttpEnabled) { 1956 inferredInterests = 1957 this.store.getState().InferredPersonalization 1958 ?.coarsePrivateInferredInterests || {}; 1959 if (prefs[PREF_INFERRED_INTERESTS_OVERRIDE]) { 1960 try { 1961 inferredInterests = JSON.parse( 1962 prefs[PREF_INFERRED_INTERESTS_OVERRIDE] 1963 ); 1964 } catch (ex) { 1965 console.error("Invalid format json for inferred interest override."); 1966 } 1967 } 1968 } 1969 1970 const requestMetadata = { 1971 utc_offset: prefs.inferredPersonalizationConfig 1972 ?.normalized_time_zone_offset 1973 ? lazy.NewTabUtils.getUtcOffset(prefs[PREF_SURFACE_ID]) 1974 : undefined, 1975 inferredInterests, 1976 }; 1977 headers.append("content-type", "application/json"); 1978 let body = { 1979 ...(prefMerinoFeedExperiment ? this.getExperimentInfo() : {}), 1980 ...requestMetadata, 1981 locale: this.locale, 1982 region: this.region, 1983 topics, 1984 sections, 1985 enableInterestPicker: !!interestPickerEnabled, 1986 }; 1987 1988 const sectionsEnabled = prefs[PREF_SECTIONS_ENABLED]; 1989 1990 if (sectionsEnabled) { 1991 body.feeds = ["sections"]; 1992 } 1993 1994 return { 1995 method: "POST", 1996 headers, 1997 body: JSON.stringify(body), 1998 }; 1999 } 2000 2001 /** 2002 * Called at startup to update cached data in the background. 2003 */ 2004 async _maybeUpdateCachedData() { 2005 const expirationPerComponent = await this._checkExpirationPerComponent(); 2006 // Pass in `store.dispatch` to send the updates only to main 2007 if (expirationPerComponent.spocs) { 2008 await this.loadSpocs(this.store.dispatch); 2009 } 2010 if (expirationPerComponent.feeds) { 2011 await this.loadComponentFeeds(this.store.dispatch); 2012 } 2013 } 2014 2015 async scoreFeeds(feedsState) { 2016 if (feedsState.data) { 2017 const feeds = {}; 2018 const feedsPromises = Object.keys(feedsState.data).map(url => { 2019 let feed = feedsState.data[url]; 2020 if (feed.personalized) { 2021 // Feed was previously personalized then cached, we don't need to do this again. 2022 return Promise.resolve(); 2023 } 2024 const feedPromise = this.scoreItems(feed.data.recommendations, "feed"); 2025 feedPromise.then(({ data: scoredItems, personalized }) => { 2026 feed = { 2027 ...feed, 2028 personalized, 2029 data: { 2030 ...feed.data, 2031 recommendations: scoredItems, 2032 }, 2033 }; 2034 2035 feeds[url] = feed; 2036 2037 this.store.dispatch( 2038 ac.AlsoToPreloaded({ 2039 type: at.DISCOVERY_STREAM_FEED_UPDATE, 2040 data: { 2041 feed, 2042 url, 2043 }, 2044 }) 2045 ); 2046 }); 2047 return feedPromise; 2048 }); 2049 await Promise.all(feedsPromises); 2050 await this.cache.set("feeds", feeds); 2051 } 2052 } 2053 2054 async scoreSpocs(spocsState) { 2055 const spocsResultPromises = this.getPlacements().map(async placement => { 2056 const nextSpocs = spocsState.data[placement.name] || {}; 2057 const { items } = nextSpocs; 2058 2059 if (nextSpocs.personalized || !items || !items.length) { 2060 return; 2061 } 2062 2063 const { data: scoreResult, personalized } = await this.scoreItems( 2064 items, 2065 "spocs" 2066 ); 2067 2068 spocsState.data = { 2069 ...spocsState.data, 2070 [placement.name]: { 2071 ...nextSpocs, 2072 personalized, 2073 items: scoreResult, 2074 }, 2075 }; 2076 }); 2077 await Promise.all(spocsResultPromises); 2078 2079 // Update cache here so we don't need to re calculate scores on loads from cache. 2080 // Related Bug 1606276 2081 await this.cache.set("spocs", { 2082 lastUpdated: spocsState.lastUpdated, 2083 spocs: spocsState.data, 2084 spocsOnDemand: this.spocsOnDemand, 2085 spocsCacheUpdateTime: this.spocsCacheUpdateTime, 2086 }); 2087 this.store.dispatch( 2088 ac.AlsoToPreloaded({ 2089 type: at.DISCOVERY_STREAM_SPOCS_UPDATE, 2090 data: { 2091 lastUpdated: spocsState.lastUpdated, 2092 spocs: spocsState.data, 2093 spocsOnDemand: this.spocsOnDemand, 2094 spocsCacheUpdateTime: this.spocsCacheUpdateTime, 2095 }, 2096 }) 2097 ); 2098 } 2099 2100 /** 2101 * @typedef {object} RefreshAll 2102 * @property {boolean} updateOpenTabs - Sends updates to open tabs immediately if true, 2103 * updates in background if false 2104 * @property {boolean} isStartup - When the function is called at browser startup 2105 * 2106 * Refreshes component feeds, and spocs in order if caches have expired. 2107 * @param {RefreshAll} options 2108 */ 2109 async refreshAll(options = {}) { 2110 const { updateOpenTabs, isStartup, isSystemTick } = options; 2111 2112 const dispatch = updateOpenTabs 2113 ? action => this.store.dispatch(ac.BroadcastToContent(action)) 2114 : this.store.dispatch; 2115 2116 this.loadLayout(dispatch, isStartup); 2117 if (this.showStories) { 2118 const spocsStartupCacheEnabled = 2119 this.store.getState().Prefs.values[PREF_SPOCS_STARTUP_CACHE_ENABLED]; 2120 const promises = []; 2121 2122 // We don't want to make spoc requests during system tick if on demand is on. 2123 if (!(this.spocsOnDemand && isSystemTick)) { 2124 const spocsPromise = this.loadSpocs( 2125 dispatch, 2126 isStartup && spocsStartupCacheEnabled 2127 ).catch(error => 2128 console.error("Error trying to load spocs feeds:", error) 2129 ); 2130 promises.push(spocsPromise); 2131 } 2132 const storiesPromise = this.loadComponentFeeds(dispatch, isStartup).catch( 2133 error => console.error("Error trying to load component feeds:", error) 2134 ); 2135 promises.push(storiesPromise); 2136 await Promise.all(promises); 2137 // We don't need to check onDemand here, 2138 // even though _maybeUpdateCachedData fetches spocs. 2139 // This is because isStartup and isSystemTick can never both be true. 2140 if (isStartup) { 2141 // We don't pass isStartup in _maybeUpdateCachedData on purpose, 2142 // because startup loads have a longer cache timer, 2143 // and we want this to update in the background sooner. 2144 await this._maybeUpdateCachedData(); 2145 } 2146 } 2147 } 2148 2149 // We have to rotate stories on the client so that 2150 // active stories are at the front of the list, followed by stories that have expired 2151 // impressions i.e. have been displayed for longer than DEFAULT_RECS_ROTATION_TIME. 2152 async rotate(recommendations) { 2153 const cachedData = (await this.cache.get()) || {}; 2154 const impressions = cachedData.recsImpressions; 2155 2156 // If we have no impressions, don't bother checking. 2157 if (!impressions) { 2158 return recommendations; 2159 } 2160 2161 const expired = []; 2162 const active = []; 2163 for (const item of recommendations) { 2164 if ( 2165 impressions[item.id] && 2166 Date.now() - impressions[item.id] >= DEFAULT_RECS_ROTATION_TIME 2167 ) { 2168 expired.push(item); 2169 } else { 2170 active.push(item); 2171 } 2172 } 2173 return active.concat(expired); 2174 } 2175 2176 enableStories() { 2177 if (this.config.enabled) { 2178 // If stories are being re enabled, ensure we have stories. 2179 this.refreshAll({ updateOpenTabs: true }); 2180 } 2181 } 2182 2183 async enable(options = {}) { 2184 await this.refreshAll(options); 2185 this.loaded = true; 2186 } 2187 2188 async reset() { 2189 this.resetDataPrefs(); 2190 await this.resetCache(); 2191 this.resetState(); 2192 } 2193 2194 async resetCache() { 2195 await this.resetAllCache(); 2196 } 2197 2198 async resetContentCache() { 2199 await this.cache.set("feeds", {}); 2200 await this.cache.set("spocs", {}); 2201 await this.cache.set("recsImpressions", {}); 2202 } 2203 2204 async resetBlocks() { 2205 await this.cache.set("recsBlocks", {}); 2206 const cachedData = (await this.cache.get()) || {}; 2207 let blocks = cachedData.recsBlocks || {}; 2208 2209 this.store.dispatch({ 2210 type: at.DISCOVERY_STREAM_DEV_BLOCKS, 2211 data: blocks, 2212 }); 2213 // Update newtab after clearing blocks. 2214 await this.refreshAll({ updateOpenTabs: true }); 2215 } 2216 2217 async resetContentFeed() { 2218 await this.cache.set("feeds", {}); 2219 } 2220 2221 async resetSpocs() { 2222 await this.cache.set("spocs", {}); 2223 } 2224 2225 async resetAllCache() { 2226 await this.resetContentCache(); 2227 // Reset in-memory caches. 2228 this._isContextualAds = undefined; 2229 this._doLocalInferredRerank = undefined; 2230 this._spocsCacheUpdateTime = undefined; 2231 this._spocsOnDemand = undefined; 2232 } 2233 2234 resetDataPrefs() { 2235 this.writeDataPref(PREF_SPOC_IMPRESSIONS, {}); 2236 this.writeDataPref(PREF_FLIGHT_BLOCKS, {}); 2237 } 2238 2239 resetState() { 2240 // Reset reducer 2241 this.store.dispatch( 2242 ac.BroadcastToContent({ type: at.DISCOVERY_STREAM_LAYOUT_RESET }) 2243 ); 2244 this.setupPrefs(false /* isStartup */); 2245 this.loaded = false; 2246 } 2247 2248 async onPrefChange() { 2249 // We always want to clear the cache/state if the pref has changed 2250 await this.reset(); 2251 if (this.config.enabled) { 2252 // Load data from all endpoints 2253 await this.enable({ updateOpenTabs: true }); 2254 } 2255 } 2256 2257 // This is a request to change the config from somewhere. 2258 // Can be from a specific pref related to Discovery Stream, 2259 // or can be a generic request from an external feed that 2260 // something changed. 2261 configReset() { 2262 this._prefCache.config = null; 2263 this.store.dispatch( 2264 ac.BroadcastToContent({ 2265 type: at.DISCOVERY_STREAM_CONFIG_CHANGE, 2266 data: this.config, 2267 }) 2268 ); 2269 } 2270 2271 recordFlightImpression(flightId) { 2272 let impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS); 2273 2274 const timeStamps = impressions[flightId] || []; 2275 timeStamps.push(Date.now()); 2276 impressions = { ...impressions, [flightId]: timeStamps }; 2277 2278 this.writeDataPref(PREF_SPOC_IMPRESSIONS, impressions); 2279 } 2280 2281 async recordTopRecImpression(recId) { 2282 const cachedData = (await this.cache.get()) || {}; 2283 let impressions = cachedData.recsImpressions || {}; 2284 2285 if (!impressions[recId]) { 2286 impressions = { ...impressions, [recId]: Date.now() }; 2287 await this.cache.set("recsImpressions", impressions); 2288 2289 this.store.dispatch({ 2290 type: at.DISCOVERY_STREAM_DEV_IMPRESSIONS, 2291 data: impressions, 2292 }); 2293 } 2294 } 2295 2296 async recordBlockRecId(recId) { 2297 const cachedData = (await this.cache.get()) || {}; 2298 let blocks = cachedData.recsBlocks || {}; 2299 2300 if (!blocks[recId]) { 2301 blocks[recId] = 1; 2302 await this.cache.set("recsBlocks", blocks); 2303 2304 this.store.dispatch({ 2305 type: at.DISCOVERY_STREAM_DEV_BLOCKS, 2306 data: blocks, 2307 }); 2308 } 2309 } 2310 2311 recordBlockFlightId(flightId) { 2312 const unifiedAdsEnabled = 2313 this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED]; 2314 2315 const flights = this.readDataPref(PREF_FLIGHT_BLOCKS); 2316 if (!flights[flightId]) { 2317 flights[flightId] = 1; 2318 this.writeDataPref(PREF_FLIGHT_BLOCKS, flights); 2319 2320 if (unifiedAdsEnabled) { 2321 let blockList = 2322 this.store.getState().Prefs.values[PREF_UNIFIED_ADS_BLOCKED_LIST]; 2323 2324 let blockedAdsArray = []; 2325 2326 // If prev ads have been blocked, convert CSV string to array 2327 if (blockList !== "") { 2328 blockedAdsArray = blockList 2329 .split(",") 2330 .map(s => s.trim()) 2331 .filter(item => item); 2332 } 2333 2334 blockedAdsArray.push(flightId); 2335 2336 this.store.dispatch( 2337 ac.SetPref(PREF_UNIFIED_ADS_BLOCKED_LIST, blockedAdsArray.join(",")) 2338 ); 2339 } 2340 } 2341 } 2342 2343 cleanUpFlightImpressionPref(data) { 2344 let flightIds = []; 2345 this.placementsForEach(placement => { 2346 const newSpocs = data[placement.name]; 2347 if (!newSpocs) { 2348 return; 2349 } 2350 2351 const items = newSpocs.items || []; 2352 flightIds = [...flightIds, ...items.map(s => `${s.flight_id}`)]; 2353 }); 2354 if (flightIds && flightIds.length) { 2355 this.cleanUpImpressionPref( 2356 id => !flightIds.includes(id), 2357 PREF_SPOC_IMPRESSIONS 2358 ); 2359 } 2360 } 2361 2362 // Clean up rec impressions that are old. 2363 async cleanUpTopRecImpressions() { 2364 await this.cleanUpImpressionCache( 2365 impression => 2366 Date.now() - impression >= DEFAULT_RECS_IMPRESSION_EXPIRE_TIME, 2367 "recsImpressions" 2368 ); 2369 } 2370 2371 writeDataPref(pref, impressions) { 2372 this.store.dispatch(ac.SetPref(pref, JSON.stringify(impressions))); 2373 } 2374 2375 readDataPref(pref) { 2376 const prefVal = this.store.getState().Prefs.values[pref]; 2377 return prefVal ? JSON.parse(prefVal) : {}; 2378 } 2379 2380 async cleanUpImpressionCache(isExpired, cacheKey) { 2381 const cachedData = (await this.cache.get()) || {}; 2382 let impressions = cachedData[cacheKey]; 2383 let changed = false; 2384 2385 if (impressions) { 2386 Object.keys(impressions).forEach(id => { 2387 if (isExpired(impressions[id])) { 2388 changed = true; 2389 delete impressions[id]; 2390 } 2391 }); 2392 2393 if (changed) { 2394 await this.cache.set(cacheKey, impressions); 2395 2396 this.store.dispatch({ 2397 type: at.DISCOVERY_STREAM_DEV_IMPRESSIONS, 2398 data: impressions, 2399 }); 2400 } 2401 } 2402 } 2403 2404 cleanUpImpressionPref(isExpired, pref) { 2405 const impressions = this.readDataPref(pref); 2406 let changed = false; 2407 2408 Object.keys(impressions).forEach(id => { 2409 if (isExpired(id)) { 2410 changed = true; 2411 delete impressions[id]; 2412 } 2413 }); 2414 2415 if (changed) { 2416 this.writeDataPref(pref, impressions); 2417 } 2418 } 2419 2420 async retreiveProfileAge() { 2421 let profileAccessor = await lazy.ProfileAge(); 2422 let profileCreateTime = await profileAccessor.created; 2423 let timeNow = new Date().getTime(); 2424 let profileAge = timeNow - profileCreateTime; 2425 // Convert milliseconds to days 2426 return profileAge / 1000 / 60 / 60 / 24; 2427 } 2428 2429 topicSelectionImpressionEvent() { 2430 let counter = 2431 this.store.getState().Prefs.values[TOPIC_SELECTION_DISPLAY_COUNT]; 2432 2433 const newCount = counter + 1; 2434 this.store.dispatch(ac.SetPref(TOPIC_SELECTION_DISPLAY_COUNT, newCount)); 2435 this.store.dispatch( 2436 ac.SetPref(TOPIC_SELECTION_LAST_DISPLAYED, `${new Date().getTime()}`) 2437 ); 2438 } 2439 2440 topicSelectionMaybeLaterEvent() { 2441 const age = this.retreiveProfileAge(); 2442 const newProfile = age <= 1; 2443 const day = 24 * 60 * 60 * 1000; 2444 this.store.dispatch( 2445 ac.SetPref( 2446 TOPIC_SELECTION_DISPLAY_TIMEOUT, 2447 newProfile ? 3 * day : 7 * day 2448 ) 2449 ); 2450 } 2451 2452 async onSpocsOnDemandUpdate() { 2453 if (this.spocsOnDemand) { 2454 const expirationPerComponent = await this._checkExpirationPerComponent(); 2455 if (expirationPerComponent.spocs) { 2456 await this.loadSpocs(action => 2457 this.store.dispatch(ac.BroadcastToContent(action)) 2458 ); 2459 } 2460 } 2461 } 2462 2463 async onSystemTick() { 2464 // Only refresh when enabled and after initial load has completed. 2465 if (!this.config.enabled || !this.loaded) { 2466 return; 2467 } 2468 2469 const expirationPerComponent = await this._checkExpirationPerComponent(); 2470 let expired = false; 2471 2472 if (this.spocsOnDemand) { 2473 // With on-demand only feeds can trigger a refresh. 2474 expired = expirationPerComponent.feeds; 2475 } else { 2476 // Without on-demand both feeds or spocs can trigger a refresh. 2477 expired = expirationPerComponent.feeds || expirationPerComponent.spocs; 2478 } 2479 2480 if (expired) { 2481 // We use isSystemTick so refreshAll can know to check onDemand 2482 await this.refreshAll({ updateOpenTabs: false, isSystemTick: true }); 2483 } 2484 } 2485 2486 async onTrainhopConfigChanged() { 2487 this.resetSpocsOnDemand(); 2488 } 2489 2490 async onPrefChangedAction(action) { 2491 switch (action.data.name) { 2492 case PREF_CONFIG: 2493 case PREF_ENABLED: 2494 case PREF_HARDCODED_BASIC_LAYOUT: 2495 case PREF_SPOCS_ENDPOINT: 2496 case PREF_SPOCS_ENDPOINT_QUERY: 2497 case PREF_SPOCS_CLEAR_ENDPOINT: 2498 case PREF_ENDPOINTS: 2499 case PREF_SPOC_POSITIONS: 2500 case PREF_UNIFIED_ADS_SPOCS_ENABLED: 2501 case PREF_SECTIONS_ENABLED: 2502 case PREF_INTEREST_PICKER_ENABLED: 2503 // This is a config reset directly related to Discovery Stream pref. 2504 this.configReset(); 2505 break; 2506 case PREF_USER_INFERRED_PERSONALIZATION: 2507 this.configReset(); 2508 this._isContextualAds = undefined; 2509 this._doLocalInferredRerank = undefined; 2510 await this.resetContentCache(); 2511 break; 2512 case PREF_CONTEXTUAL_ADS: 2513 case PREF_SYSTEM_INFERRED_PERSONALIZATION: 2514 this._isContextualAds = undefined; 2515 this._doLocalInferredRerank = undefined; 2516 break; 2517 case PREF_SELECTED_TOPICS: 2518 this.store.dispatch( 2519 ac.BroadcastToContent({ type: at.DISCOVERY_STREAM_LAYOUT_RESET }) 2520 ); 2521 // Ensure at least a little bit of loading is seen, if this is too fast, 2522 // it's not clear to the user what just happened. 2523 this.store.dispatch( 2524 ac.BroadcastToContent({ 2525 type: at.DISCOVERY_STREAM_TOPICS_LOADING, 2526 data: true, 2527 }) 2528 ); 2529 setTimeout(() => { 2530 this.store.dispatch( 2531 ac.BroadcastToContent({ 2532 type: at.DISCOVERY_STREAM_TOPICS_LOADING, 2533 data: false, 2534 }) 2535 ); 2536 }, TOPIC_LOADING_TIMEOUT); 2537 this.loadLayout( 2538 a => this.store.dispatch(ac.BroadcastToContent(a)), 2539 false 2540 ); 2541 2542 // when topics have been updated, make a new request from merino and clear impression cap 2543 await this.cache.set("recsImpressions", {}); 2544 await this.resetContentFeed(); 2545 this.refreshAll({ updateOpenTabs: true }); 2546 break; 2547 case PREF_USER_TOPSTORIES: 2548 case PREF_SYSTEM_TOPSTORIES: 2549 if (!this.showStories) { 2550 // Ensure we delete any remote data potentially related to spocs. 2551 this.clearSpocs(); 2552 } 2553 if (action.data.value) { 2554 this.enableStories(); 2555 } 2556 break; 2557 // Remove spocs if turned off. 2558 case PREF_SHOW_SPONSORED: { 2559 await this.updateOrRemoveSpocs(); 2560 break; 2561 } 2562 case PREF_SPOCS_CACHE_ONDEMAND: 2563 case PREF_SPOCS_CACHE_TIMEOUT: { 2564 this.resetSpocsOnDemand(); 2565 break; 2566 } 2567 } 2568 2569 if (action.data.name === "pocketConfig") { 2570 await this.onPrefChange(); 2571 this.setupPrefs(false /* isStartup */); 2572 } 2573 if (action.data.name === "trainhopConfig") { 2574 await this.onTrainhopConfigChanged(action); 2575 } 2576 } 2577 2578 resetSpocsOnDemand() { 2579 // This is all we have to do, because we're just changing how often caches update. 2580 // No need to reset what is already fetched, we just care about the next check. 2581 this._spocsCacheUpdateTime = undefined; 2582 this._spocsOnDemand = undefined; 2583 this.store.dispatch({ 2584 type: at.DISCOVERY_STREAM_SPOCS_ONDEMAND_RESET, 2585 data: { 2586 spocsOnDemand: this.spocsOnDemand, 2587 spocsCacheUpdateTime: this.spocsCacheUpdateTime, 2588 }, 2589 }); 2590 } 2591 2592 async onAction(action) { 2593 switch (action.type) { 2594 case at.INIT: 2595 // During the initialization of Firefox: 2596 // 1. Set-up listeners and initialize the redux state for config; 2597 this.setupConfig(true /* isStartup */); 2598 this.setupPrefs(true /* isStartup */); 2599 // 2. If config.enabled is true, start loading data. 2600 if (this.config.enabled) { 2601 await this.enable({ updateOpenTabs: true, isStartup: true }); 2602 } 2603 // This function is async but just for devtools, 2604 // so we don't need to wait for it. 2605 this.setupDevtoolsState(true /* isStartup */); 2606 break; 2607 case at.TOPIC_SELECTION_MAYBE_LATER: 2608 this.topicSelectionMaybeLaterEvent(); 2609 break; 2610 case at.DISCOVERY_STREAM_DEV_BLOCKS_RESET: 2611 await this.resetBlocks(); 2612 break; 2613 case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK: 2614 case at.SYSTEM_TICK: 2615 await this.onSystemTick(); 2616 break; 2617 case at.DISCOVERY_STREAM_SPOCS_ONDEMAND_UPDATE: { 2618 await this.onSpocsOnDemandUpdate(); 2619 break; 2620 } 2621 case at.DISCOVERY_STREAM_DEV_SYNC_RS: 2622 lazy.RemoteSettings.pollChanges(); 2623 break; 2624 case at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE: 2625 // Personalization scores update at a slower interval than content, so in order to debug, 2626 // we want to be able to expire just content to trigger the earlier expire times. 2627 await this.resetContentCache(); 2628 break; 2629 case at.DISCOVERY_STREAM_DEV_SHOW_PLACEHOLDER: { 2630 // We want to display the loading state permanently, for dev purposes. 2631 // We do this by resetting everything, loading the layout, and nothing else. 2632 // This essentially hangs because we never triggered the content load. 2633 await this.reset(); 2634 this.loadLayout( 2635 a => this.store.dispatch(ac.BroadcastToContent(a)), 2636 false 2637 ); 2638 break; 2639 } 2640 case at.DISCOVERY_STREAM_CONFIG_SET_VALUE: 2641 // Use the original string pref to then set a value instead of 2642 // this.config which has some modifications 2643 this.store.dispatch( 2644 ac.SetPref( 2645 PREF_CONFIG, 2646 JSON.stringify({ 2647 ...JSON.parse(this.store.getState().Prefs.values[PREF_CONFIG]), 2648 [action.data.name]: action.data.value, 2649 }) 2650 ) 2651 ); 2652 break; 2653 case at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED: 2654 if (this.personalized) { 2655 const { feeds, spocs } = this.store.getState().DiscoveryStream; 2656 const spocsPersonalized = 2657 this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized; 2658 const recsPersonalized = 2659 this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized; 2660 if (recsPersonalized && feeds.loaded) { 2661 this.scoreFeeds(feeds); 2662 } 2663 if (spocsPersonalized && spocs.loaded) { 2664 this.scoreSpocs(spocs); 2665 } 2666 } 2667 break; 2668 case at.DISCOVERY_STREAM_CONFIG_RESET: 2669 // This is a generic config reset likely related to an external feed pref. 2670 this.configReset(); 2671 break; 2672 case at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS: 2673 this.resetConfigDefauts(); 2674 break; 2675 case at.DISCOVERY_STREAM_RETRY_FEED: 2676 this.retryFeed(action.data.feed); 2677 break; 2678 case at.DISCOVERY_STREAM_CONFIG_CHANGE: 2679 // When the config pref changes, load or unload data as needed. 2680 await this.onPrefChange(); 2681 break; 2682 case at.DISCOVERY_STREAM_IMPRESSION_STATS: 2683 if ( 2684 action.data.tiles && 2685 action.data.tiles[0] && 2686 action.data.tiles[0].id 2687 ) { 2688 this.recordTopRecImpression(action.data.tiles[0].id); 2689 } 2690 break; 2691 case at.DISCOVERY_STREAM_SPOC_IMPRESSION: 2692 if (this.showSponsoredStories) { 2693 this.recordFlightImpression(action.data.flightId); 2694 2695 // Apply frequency capping to SPOCs in the redux store, only update the 2696 // store if the SPOCs are changed. 2697 const spocsState = this.store.getState().DiscoveryStream.spocs; 2698 2699 let frequencyCapped = []; 2700 this.placementsForEach(placement => { 2701 const spocs = spocsState.data[placement.name]; 2702 if (!spocs || !spocs.items) { 2703 return; 2704 } 2705 2706 const { data: capResult, filtered } = this.frequencyCapSpocs( 2707 spocs.items 2708 ); 2709 frequencyCapped = [...frequencyCapped, ...filtered]; 2710 2711 spocsState.data = { 2712 ...spocsState.data, 2713 [placement.name]: { 2714 ...spocs, 2715 items: capResult, 2716 }, 2717 }; 2718 }); 2719 2720 if (frequencyCapped.length) { 2721 // Update cache here so we don't need to re calculate frequency caps on loads from cache. 2722 await this.cache.set("spocs", { 2723 lastUpdated: spocsState.lastUpdated, 2724 spocs: spocsState.data, 2725 spocsOnDemand: this.spocsOnDemand, 2726 spocsCacheUpdateTime: this.spocsCacheUpdateTime, 2727 }); 2728 2729 this.store.dispatch( 2730 ac.AlsoToPreloaded({ 2731 type: at.DISCOVERY_STREAM_SPOCS_UPDATE, 2732 data: { 2733 lastUpdated: spocsState.lastUpdated, 2734 spocs: spocsState.data, 2735 spocsOnDemand: this.spocsOnDemand, 2736 spocsCacheUpdateTime: this.spocsCacheUpdateTime, 2737 }, 2738 }) 2739 ); 2740 } 2741 } 2742 break; 2743 2744 // This is fired from the browser, it has no concept of spocs, flights or pocket. 2745 // We match the blocked url with our available story urls to see if there is a match. 2746 // I suspect we *could* instead do this in BLOCK_URL but I'm not sure. 2747 case at.PLACES_LINK_BLOCKED: { 2748 const feedsState = this.store.getState().DiscoveryStream.feeds; 2749 const feeds = {}; 2750 2751 for (const url of Object.keys(feedsState.data)) { 2752 let feed = feedsState.data[url]; 2753 2754 const { data: filteredResults } = await this.filterBlocked( 2755 feed.data.recommendations 2756 ); 2757 2758 feed = { 2759 ...feed, 2760 data: { 2761 ...feed.data, 2762 recommendations: filteredResults, 2763 }, 2764 }; 2765 2766 feeds[url] = feed; 2767 } 2768 2769 await this.cache.set("feeds", feeds); 2770 2771 if (this.showSponsoredStories) { 2772 let blockedItems = []; 2773 const spocsState = this.store.getState().DiscoveryStream.spocs; 2774 2775 this.placementsForEach(placement => { 2776 const spocs = spocsState.data[placement.name]; 2777 if (spocs && spocs.items && spocs.items.length) { 2778 const blockedResults = []; 2779 const blocks = spocs.items.filter(s => { 2780 const blocked = s.url === action.data.url; 2781 if (!blocked) { 2782 blockedResults.push(s); 2783 } 2784 return blocked; 2785 }); 2786 2787 blockedItems = [...blockedItems, ...blocks]; 2788 2789 spocsState.data = { 2790 ...spocsState.data, 2791 [placement.name]: { 2792 ...spocs, 2793 items: blockedResults, 2794 }, 2795 }; 2796 } 2797 }); 2798 2799 if (blockedItems.length) { 2800 // Update cache here so we don't need to re calculate blocks on loads from cache. 2801 await this.cache.set("spocs", { 2802 lastUpdated: spocsState.lastUpdated, 2803 spocs: spocsState.data, 2804 spocsOnDemand: this.spocsOnDemand, 2805 spocsCacheUpdateTime: this.spocsCacheUpdateTime, 2806 }); 2807 2808 // If we're blocking a spoc, we want open tabs to have 2809 // a slightly different treatment from future tabs. 2810 // AlsoToPreloaded updates the source data and preloaded tabs with a new spoc. 2811 // BroadcastToContent updates open tabs with a non spoc instead of a new spoc. 2812 this.store.dispatch( 2813 ac.AlsoToPreloaded({ 2814 type: at.DISCOVERY_STREAM_LINK_BLOCKED, 2815 data: action.data, 2816 }) 2817 ); 2818 this.store.dispatch( 2819 ac.BroadcastToContent({ 2820 type: at.DISCOVERY_STREAM_SPOC_BLOCKED, 2821 data: action.data, 2822 }) 2823 ); 2824 break; 2825 } 2826 } 2827 2828 this.store.dispatch( 2829 ac.BroadcastToContent({ 2830 type: at.DISCOVERY_STREAM_LINK_BLOCKED, 2831 data: action.data, 2832 }) 2833 ); 2834 break; 2835 } 2836 case at.UNINIT: 2837 // When this feed is shutting down: 2838 this.uninitPrefs(); 2839 this._recommendationProvider = null; 2840 break; 2841 case at.BLOCK_URL: { 2842 // If we block a story that also has a flight_id 2843 // we want to record that as blocked too. 2844 // This is because a single flight might have slightly different urls. 2845 for (const site of action.data) { 2846 const { flight_id, tile_id } = site; 2847 if (flight_id) { 2848 this.recordBlockFlightId(flight_id); 2849 } 2850 if (tile_id) { 2851 await this.recordBlockRecId(tile_id); 2852 } 2853 } 2854 break; 2855 } 2856 case at.PREF_CHANGED: 2857 await this.onPrefChangedAction(action); 2858 break; 2859 case at.TOPIC_SELECTION_IMPRESSION: 2860 this.topicSelectionImpressionEvent(); 2861 break; 2862 case at.SECTION_PERSONALIZATION_SET: 2863 await this.cache.set("sectionPersonalization", action.data); 2864 this.store.dispatch( 2865 ac.BroadcastToContent({ 2866 type: at.SECTION_PERSONALIZATION_UPDATE, 2867 data: action.data, 2868 }) 2869 ); 2870 break; 2871 case at.INFERRED_PERSONALIZATION_MODEL_UPDATE: 2872 await this.cache.set("inferredModel", action.data); 2873 break; 2874 case at.ADS_UPDATE_SPOCS: 2875 await this.updateOrRemoveSpocs(); 2876 break; 2877 } 2878 } 2879 } 2880 2881 /* This function generates a hardcoded layout each call. 2882 This is because modifying the original object would 2883 persist across pref changes and system_tick updates. 2884 2885 NOTE: There is some branching logic in the template. 2886 `spocsUrl` Changing the url for spocs is used for adding a siteId query param. 2887 `feedUrl` Where to fetch stories from. 2888 `items` How many items to include in the primary card grid. 2889 `spocPositions` Changes the position of spoc cards. 2890 `spocPlacementData` Used to set the spoc content. 2891 `hybridLayout` Changes cards to smaller more compact cards only for specific breakpoints. 2892 `hideCardBackground` Removes Pocket card background and borders. 2893 `fourCardLayout` Enable four Pocket cards per row. 2894 `newFooterSection` Changes the layout of the topics section. 2895 `compactGrid` Reduce the number of pixels between the Pocket cards. 2896 `ctaButtonSponsors` An array of sponsors we want to show a cta button on the card for. 2897 `ctaButtonVariant` Sets the variant for the cta sponsor button. 2898 */ 2899 getHardcodedLayout = ({ 2900 spocsUrl = SPOCS_URL, 2901 feedUrl, 2902 items = 21, 2903 spocPositions = [1, 5, 7, 11, 18, 20], 2904 spocPlacementData = { ad_types: [3617], zone_ids: [217758, 217995] }, 2905 widgetPositions = [], 2906 widgetData = [], 2907 hybridLayout = false, 2908 hideCardBackground = false, 2909 fourCardLayout = false, 2910 newFooterSection = false, 2911 compactGrid = false, 2912 ctaButtonSponsors = [], 2913 ctaButtonVariant = "", 2914 pocketStoriesHeadlineId = "newtab-section-header-stories", 2915 }) => ({ 2916 lastUpdate: Date.now(), 2917 spocs: { 2918 url: spocsUrl, 2919 }, 2920 layout: [ 2921 { 2922 width: 12, 2923 components: [ 2924 { 2925 type: "TopSites", 2926 header: { 2927 title: { 2928 id: "newtab-section-header-topsites", 2929 }, 2930 }, 2931 properties: {}, 2932 }, 2933 { 2934 type: "Message", 2935 header: { 2936 title: { 2937 id: pocketStoriesHeadlineId, 2938 }, 2939 subtitle: "", 2940 link_text: { 2941 id: "newtab-pocket-learn-more", 2942 }, 2943 link_url: "", 2944 icon: "chrome://global/skin/icons/pocket.svg", 2945 }, 2946 styles: { 2947 ".ds-message": "margin-block-end: -20px", 2948 }, 2949 }, 2950 { 2951 type: "CardGrid", 2952 properties: { 2953 items, 2954 hybridLayout, 2955 hideCardBackground, 2956 fourCardLayout, 2957 compactGrid, 2958 ctaButtonSponsors, 2959 ctaButtonVariant, 2960 }, 2961 widgets: { 2962 positions: widgetPositions.map(position => { 2963 return { index: position }; 2964 }), 2965 data: widgetData, 2966 }, 2967 cta_variant: "link", 2968 header: { 2969 title: "", 2970 }, 2971 placement: { 2972 name: "newtab_spocs", 2973 ad_types: spocPlacementData.ad_types, 2974 zone_ids: spocPlacementData.zone_ids, 2975 }, 2976 feed: { 2977 embed_reference: null, 2978 url: feedUrl, 2979 }, 2980 spocs: { 2981 probability: 1, 2982 positions: spocPositions.map(position => { 2983 return { index: position }; 2984 }), 2985 }, 2986 }, 2987 { 2988 type: "Navigation", 2989 newFooterSection, 2990 properties: { 2991 alignment: "left-align", 2992 extraLinks: [ 2993 { 2994 name: "Career", 2995 url: "https://getpocket.com/explore/career?utm_source=pocket-newtab", 2996 }, 2997 { 2998 name: "Technology", 2999 url: "https://getpocket.com/explore/technology?utm_source=pocket-newtab", 3000 }, 3001 ], 3002 privacyNoticeURL: { 3003 url: "https://www.mozilla.org/privacy/firefox/#recommend-relevant-content", 3004 title: { 3005 id: "newtab-section-menu-privacy-notice", 3006 }, 3007 }, 3008 }, 3009 styles: { 3010 ".ds-navigation": "margin-block-start: -10px;", 3011 }, 3012 }, 3013 ...(newFooterSection 3014 ? [ 3015 { 3016 type: "PrivacyLink", 3017 properties: { 3018 url: "https://www.mozilla.org/privacy/firefox/", 3019 title: { 3020 id: "newtab-section-menu-privacy-notice", 3021 }, 3022 }, 3023 }, 3024 ] 3025 : []), 3026 ], 3027 }, 3028 ], 3029 });