tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 });