tor-browser

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

TopSitesFeed.sys.mjs (81257B)


      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 // We use importESModule here instead of static import so that
      6 // the Karma test environment won't choke on this module. This
      7 // is because the Karma test environment already stubs out
      8 // AppConstants, and overrides importESModule to be a no-op (which
      9 // can't be done for a static import statement).
     10 
     11 // eslint-disable-next-line mozilla/use-static-import
     12 const { AppConstants } = ChromeUtils.importESModule(
     13  "resource://gre/modules/AppConstants.sys.mjs"
     14 );
     15 
     16 import {
     17  actionCreators as ac,
     18  actionTypes as at,
     19 } from "resource://newtab/common/Actions.mjs";
     20 import { TippyTopProvider } from "resource:///modules/topsites/TippyTopProvider.sys.mjs";
     21 import { insertPinned } from "resource:///modules/topsites/TopSites.sys.mjs";
     22 import { TOP_SITES_MAX_SITES_PER_ROW } from "resource:///modules/topsites/constants.mjs";
     23 import { Dedupe } from "resource:///modules/Dedupe.sys.mjs";
     24 
     25 import {
     26  CUSTOM_SEARCH_SHORTCUTS,
     27  SEARCH_SHORTCUTS_EXPERIMENT,
     28  SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF,
     29  SEARCH_SHORTCUTS_HAVE_PINNED_PREF,
     30  checkHasSearchEngine,
     31  getSearchProvider,
     32 } from "moz-src:///toolkit/components/search/SearchShortcuts.sys.mjs";
     33 
     34 const lazy = {};
     35 
     36 ChromeUtils.defineESModuleGetters(lazy, {
     37  ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
     38  FilterAdult: "resource:///modules/FilterAdult.sys.mjs",
     39  LinksCache: "resource:///modules/LinksCache.sys.mjs",
     40  NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
     41  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
     42  ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
     43  PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs",
     44  PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
     45  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     46  Region: "resource://gre/modules/Region.sys.mjs",
     47  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
     48  Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
     49  Screenshots: "resource://newtab/lib/Screenshots.sys.mjs",
     50 });
     51 
     52 ChromeUtils.defineLazyGetter(lazy, "log", () => {
     53  const { Logger } = ChromeUtils.importESModule(
     54    "resource://messaging-system/lib/Logger.sys.mjs"
     55  );
     56  return new Logger("TopSitesFeed");
     57 });
     58 
     59 ChromeUtils.defineLazyGetter(lazy, "pageFrecencyThreshold", () => {
     60  // @backward-compat { version 147 }
     61  // Frecency was changed in 147 Nightly.
     62  if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "147.0a1") >= 0) {
     63    // 30 days ago, 5 visits. The threshold avoids one non-typed visit from
     64    // immediately being included in recent history to mimic the original
     65    // threshold which aimed to prevent first-run visits from being included in
     66    // Top Sites.
     67    return lazy.PlacesUtils.history.pageFrecencyThreshold(30, 5, false);
     68  }
     69  // The old threshold used for classic frecency: Slightly over one visit.
     70  return 101;
     71 });
     72 
     73 const DEFAULT_SITES_PREF = "default.sites";
     74 const SHOWN_ON_NEWTAB_PREF = "feeds.topsites";
     75 export const DEFAULT_TOP_SITES = [];
     76 const MIN_FAVICON_SIZE = 96;
     77 const CACHED_LINK_PROPS_TO_MIGRATE = ["screenshot", "customScreenshot"];
     78 const PINNED_FAVICON_PROPS_TO_MIGRATE = [
     79  "favicon",
     80  "faviconRef",
     81  "faviconSize",
     82 ];
     83 
     84 const CACHE_KEY = "contile";
     85 const ROWS_PREF = "topSitesRows";
     86 const SHOW_SPONSORED_PREF = "showSponsoredTopSites";
     87 // The default total number of sponsored top sites to fetch from Contile
     88 // and Pocket.
     89 const MAX_NUM_SPONSORED = 3;
     90 // Nimbus variable for the total number of sponsored top sites including
     91 // both Contile and Pocket sources.
     92 // The default will be `MAX_NUM_SPONSORED` if this variable is unspecified.
     93 const NIMBUS_VARIABLE_MAX_SPONSORED = "topSitesMaxSponsored";
     94 // Nimbus variable to allow more than two sponsored tiles from Contile to be
     95 //considered for Top Sites.
     96 const NIMBUS_VARIABLE_ADDITIONAL_TILES =
     97  "topSitesUseAdditionalTilesFromContile";
     98 // Nimbu variable for the total number of sponsor topsite that come from Contile
     99 // The default will be `CONTILE_MAX_NUM_SPONSORED` if variable is unspecified.
    100 const NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED = "topSitesContileMaxSponsored";
    101 
    102 const PREF_UNIFIED_ADS_TILES_ENABLED = "unifiedAds.tiles.enabled";
    103 const PREF_UNIFIED_ADS_ENDPOINT = "unifiedAds.endpoint";
    104 const PREF_UNIFIED_ADS_PLACEMENTS = "discoverystream.placements.tiles";
    105 const PREF_UNIFIED_ADS_COUNTS = "discoverystream.placements.tiles.counts";
    106 const PREF_UNIFIED_ADS_BLOCKED_LIST = "unifiedAds.blockedAds";
    107 const PREF_UNIFIED_ADS_ADSFEED_ENABLED = "unifiedAds.adsFeed.enabled";
    108 
    109 const PREF_SOV_ENABLED = "sov.enabled";
    110 const PREF_SOV_FRECENCY_EXPOSURE = "sov.frecency.exposure";
    111 const PREF_SOV_NAME = "sov.name";
    112 const PREF_SOV_AMP_ALLOCATION = "sov.amp.allocation";
    113 const PREF_SOV_FRECENCY_ALLOCATION = "sov.frecency.allocation";
    114 const DEFAULT_SOV_SLOT_COUNT = 3;
    115 
    116 // Search experiment stuff
    117 const FILTER_DEFAULT_SEARCH_PREF = "improvesearch.noDefaultSearchTile";
    118 const SEARCH_FILTERS = [
    119  "google",
    120  "search.yahoo",
    121  "yahoo",
    122  "bing",
    123  "ask",
    124  "duckduckgo",
    125 ];
    126 
    127 const REMOTE_SETTING_DEFAULTS_PREF = "browser.topsites.useRemoteSetting";
    128 const DEFAULT_SITES_OVERRIDE_PREF =
    129  "browser.newtabpage.activity-stream.default.sites";
    130 const DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH = "browser.topsites.experiment.";
    131 
    132 // Mozilla Tiles Service (Contile) prefs
    133 // Nimbus variable for the Contile integration. It falls back to the pref:
    134 // `browser.topsites.contile.enabled`.
    135 const NIMBUS_VARIABLE_CONTILE_ENABLED = "topSitesContileEnabled";
    136 const NIMBUS_VARIABLE_CONTILE_POSITIONS = "contileTopsitesPositions";
    137 const CONTILE_ENDPOINT_PREF = "browser.topsites.contile.endpoint";
    138 const CONTILE_UPDATE_INTERVAL = 15 * 60 * 1000; // 15 minutes
    139 // The maximum number of sponsored top sites to fetch from Contile.
    140 const CONTILE_MAX_NUM_SPONSORED = 3;
    141 const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
    142 const CONTILE_CACHE_VALID_FOR_PREF = "browser.topsites.contile.cacheValidFor";
    143 const CONTILE_CACHE_LAST_FETCH_PREF = "browser.topsites.contile.lastFetch";
    144 const CONTILE_CACHE_VALID_FOR_FALLBACK = 3 * 60 * 60; // 3 hours in seconds
    145 
    146 // Partners of sponsored tiles.
    147 const SPONSORED_TILE_PARTNER_AMP = "amp";
    148 const SPONSORED_TILE_PARTNER_MOZ_SALES = "moz-sales";
    149 const SPONSORED_TILE_PARTNER_FREC_BOOST = "frec-boost";
    150 const SPONSORED_TILE_PARTNERS = new Set([
    151  SPONSORED_TILE_PARTNER_AMP,
    152  SPONSORED_TILE_PARTNER_MOZ_SALES,
    153  SPONSORED_TILE_PARTNER_FREC_BOOST,
    154 ]);
    155 
    156 const DISPLAY_FAIL_REASON_OVERSOLD = "oversold";
    157 const DISPLAY_FAIL_REASON_DISMISSED = "dismissed";
    158 const DISPLAY_FAIL_REASON_UNRESOLVED = "unresolved";
    159 
    160 ChromeUtils.defineLazyGetter(lazy, "userAgent", () => {
    161  return Cc["@mozilla.org/network/protocol;1?name=http"].getService(
    162    Ci.nsIHttpProtocolHandler
    163  ).userAgent;
    164 });
    165 
    166 // Smart shortcuts
    167 import { RankShortcutsProvider } from "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs";
    168 import { FrecencyBoostProvider } from "resource://newtab/lib/FrecencyBoostProvider/FrecencyBoostProvider.mjs";
    169 
    170 const PREF_SYSTEM_SHORTCUTS_PERSONALIZATION =
    171  "discoverystream.shortcuts.personalization.enabled";
    172 
    173 function smartshortcutsEnabled(values) {
    174  const systemPref = values[PREF_SYSTEM_SHORTCUTS_PERSONALIZATION];
    175  const experimentVariable = values.trainhopConfig?.smartShortcuts?.enabled;
    176  return systemPref || experimentVariable;
    177 }
    178 const OVERSAMPLE_MULTIPLIER = 2;
    179 
    180 function getShortHostnameForCurrentSearch() {
    181  return lazy.NewTabUtils.shortHostname(
    182    Services.search.defaultEngine.searchUrlDomain
    183  );
    184 }
    185 
    186 class TopSitesTelemetry {
    187  constructor() {
    188    this.allSponsoredTiles = {};
    189    this.sponsoredTilesConfigured = 0;
    190  }
    191 
    192  _tileProviderForTiles(tiles) {
    193    // Assumption: the list of tiles is from a single provider
    194    return tiles && tiles.length ? this._tileProvider(tiles[0]) : null;
    195  }
    196 
    197  _tileProvider(tile) {
    198    return tile.partner || SPONSORED_TILE_PARTNER_AMP;
    199  }
    200 
    201  _buildPropertyKey(tile) {
    202    let provider = this._tileProvider(tile);
    203    return provider + lazy.NewTabUtils.shortURL(tile);
    204  }
    205 
    206  // Returns an array of strings indicating the property name (based on the
    207  // provider and brand) of tiles that have been filtered e.g. ["moz-salesbrand1"]
    208  // currentTiles: The list of tiles remaining and may be displayed in new tab.
    209  // this.allSponsoredTiles: The original list of tiles set via setTiles prior to any filtering
    210  // The returned list indicated the difference between these two lists (excluding any previously filtered tiles).
    211  _getFilteredTiles(currentTiles) {
    212    let notPreviouslyFilteredTiles = Object.assign(
    213      {},
    214      ...Object.entries(this.allSponsoredTiles)
    215        .filter(
    216          ([, v]) =>
    217            v.display_fail_reason === null ||
    218            v.display_fail_reason === undefined
    219        )
    220        .map(([k, v]) => ({ [k]: v }))
    221    );
    222 
    223    // Get the property names of the newly filtered list.
    224    let remainingTiles = currentTiles.map(el => {
    225      return this._buildPropertyKey(el);
    226    });
    227 
    228    // Get the property names of the tiles that were filtered.
    229    let tilesToUpdate = Object.keys(notPreviouslyFilteredTiles).filter(
    230      element => !remainingTiles.includes(element)
    231    );
    232    return tilesToUpdate;
    233  }
    234 
    235  setSponsoredTilesConfigured() {
    236    const maxSponsored =
    237      lazy.NimbusFeatures.pocketNewtab.getVariable(
    238        NIMBUS_VARIABLE_MAX_SPONSORED
    239      ) ?? MAX_NUM_SPONSORED;
    240 
    241    this.sponsoredTilesConfigured = maxSponsored;
    242    Glean.topsites.sponsoredTilesConfigured.set(maxSponsored);
    243  }
    244 
    245  clearTilesForProvider(provider) {
    246    Object.entries(this.allSponsoredTiles)
    247      .filter(([k]) => k.startsWith(provider))
    248      .map(([k]) => delete this.allSponsoredTiles[k]);
    249  }
    250 
    251  _getAdvertiser(tile) {
    252    let label = tile.label || null;
    253    let title = tile.title || null;
    254 
    255    return label ?? title ?? lazy.NewTabUtils.shortURL(tile);
    256  }
    257 
    258  setTiles(tiles) {
    259    // Assumption: the list of tiles is from a single provider,
    260    // should be called once per tile source.
    261    if (tiles && tiles.length) {
    262      let tile_provider = this._tileProviderForTiles(tiles);
    263      this.clearTilesForProvider(tile_provider);
    264 
    265      for (let sponsoredTile of tiles) {
    266        this.allSponsoredTiles[this._buildPropertyKey(sponsoredTile)] = {
    267          advertiser: this._getAdvertiser(sponsoredTile).toLowerCase(),
    268          provider: tile_provider,
    269          display_position: null,
    270          display_fail_reason: null,
    271        };
    272      }
    273    }
    274  }
    275 
    276  _setDisplayFailReason(filteredTiles, reason) {
    277    for (let tile of filteredTiles) {
    278      if (tile in this.allSponsoredTiles) {
    279        let tileToUpdate = this.allSponsoredTiles[tile];
    280        tileToUpdate.display_position = null;
    281        tileToUpdate.display_fail_reason = reason;
    282      }
    283    }
    284  }
    285 
    286  determineFilteredTilesAndSetToOversold(nonOversoldTiles) {
    287    let filteredTiles = this._getFilteredTiles(nonOversoldTiles);
    288    this._setDisplayFailReason(filteredTiles, DISPLAY_FAIL_REASON_OVERSOLD);
    289  }
    290 
    291  determineFilteredTilesAndSetToDismissed(nonDismissedTiles) {
    292    let filteredTiles = this._getFilteredTiles(nonDismissedTiles);
    293    this._setDisplayFailReason(filteredTiles, DISPLAY_FAIL_REASON_DISMISSED);
    294  }
    295 
    296  _setTilePositions(currentTiles) {
    297    // This function performs many loops over a small dataset.  The size of
    298    // dataset is limited by the number of sponsored tiles displayed on
    299    // the newtab instance.
    300    if (this.allSponsoredTiles) {
    301      let tilePositionsAssigned = [];
    302      // processing the currentTiles parameter, assigns a position to the
    303      // corresponding property in this.allSponsoredTiles
    304      currentTiles.forEach(item => {
    305        let tile = this.allSponsoredTiles[this._buildPropertyKey(item)];
    306        if (
    307          tile &&
    308          (tile.display_fail_reason === undefined ||
    309            tile.display_fail_reason === null)
    310        ) {
    311          tile.display_position = item.sponsored_position;
    312          // Track assigned tile slots.
    313          tilePositionsAssigned.push(item.sponsored_position);
    314        }
    315      });
    316 
    317      // Need to check if any objects in this.allSponsoredTiles do not
    318      // have either a display_fail_reason or a display_position set.
    319      // This can happen if the tiles list was updated before the
    320      // metric is written to Glean.
    321      // See https://bugzilla.mozilla.org/show_bug.cgi?id=1877197
    322      let tilesMissingPosition = [];
    323      Object.keys(this.allSponsoredTiles).forEach(property => {
    324        let tile = this.allSponsoredTiles[property];
    325        if (!tile.display_fail_reason && !tile.display_position) {
    326          tilesMissingPosition.push(property);
    327        }
    328      });
    329 
    330      if (tilesMissingPosition.length) {
    331        // Determine if any available slots exist based on max number of tiles
    332        // and the list of tiles already used and assign to a tile with missing
    333        // value.
    334        for (let i = 1; i <= this.sponsoredTilesConfigured; i++) {
    335          if (!tilePositionsAssigned.includes(i)) {
    336            let tileProperty = tilesMissingPosition.shift();
    337            this.allSponsoredTiles[tileProperty].display_position = i;
    338          }
    339        }
    340      }
    341 
    342      // At this point we might still have a few unresolved states.  These
    343      // rows will be tagged with a display_fail_reason `unresolved`.
    344      this._detectErrorConditionAndSetUnresolved();
    345    }
    346  }
    347 
    348  // Checks the data for inconsistent state and updates the display_fail_reason
    349  _detectErrorConditionAndSetUnresolved() {
    350    Object.keys(this.allSponsoredTiles).forEach(property => {
    351      let tile = this.allSponsoredTiles[property];
    352      if (
    353        (!tile.display_fail_reason && !tile.display_position) ||
    354        (tile.display_fail_reason && tile.display_position)
    355      ) {
    356        tile.display_position = null;
    357        tile.display_fail_reason = DISPLAY_FAIL_REASON_UNRESOLVED;
    358      }
    359    });
    360  }
    361 
    362  finalizeNewtabPingFields(currentTiles) {
    363    this._setTilePositions(currentTiles);
    364    Glean.topsites.sponsoredTilesReceived.set(
    365      JSON.stringify({
    366        sponsoredTilesReceived: Object.values(this.allSponsoredTiles),
    367      })
    368    );
    369  }
    370 }
    371 
    372 export class ContileIntegration {
    373  constructor(topSitesFeed) {
    374    this._topSitesFeed = topSitesFeed;
    375    this._lastPeriodicUpdate = 0;
    376    this._sites = [];
    377    // The Share-of-Voice object managed by Shepherd and sent via Contile.
    378    this._sov = null;
    379    this.cache = this.PersistentCache(CACHE_KEY, true);
    380  }
    381 
    382  get sites() {
    383    return this._sites;
    384  }
    385 
    386  get sov() {
    387    return this._sov;
    388  }
    389 
    390  periodicUpdate() {
    391    let now = Date.now();
    392    if (now - this._lastPeriodicUpdate >= CONTILE_UPDATE_INTERVAL) {
    393      this._lastPeriodicUpdate = now;
    394      this.refresh();
    395    }
    396  }
    397 
    398  async refresh() {
    399    let updateDefaultSites = await this._fetchSites();
    400    await this._topSitesFeed.allocatePositions();
    401    if (updateDefaultSites) {
    402      this._topSitesFeed._readDefaults();
    403    }
    404  }
    405 
    406  /**
    407   * Clear Contile Cache.
    408   */
    409  _resetContileCache() {
    410    Services.prefs.clearUserPref(CONTILE_CACHE_LAST_FETCH_PREF);
    411    Services.prefs.clearUserPref(CONTILE_CACHE_VALID_FOR_PREF);
    412 
    413    // This can be async, but in this case we don't need to wait.
    414    this.cache.set("contile", []);
    415  }
    416 
    417  /**
    418   * Filter the tiles whose sponsor is on the Top Sites sponsor blocklist.
    419   *
    420   * @param {Array} tiles
    421   *   An array of the tile objects
    422   */
    423  _filterBlockedSponsors(tiles) {
    424    const blocklist = JSON.parse(
    425      Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]")
    426    );
    427    return tiles.filter(
    428      tile => !blocklist.includes(lazy.NewTabUtils.shortURL(tile))
    429    );
    430  }
    431 
    432  /**
    433   * Calculate the time Contile response is valid for based on cache-control header
    434   *
    435   * @param {string} cacheHeader
    436   *   string value of the Contile resposne cache-control header
    437   */
    438  _extractCacheValidFor(cacheHeader) {
    439    const unifiedAdsTilesEnabled =
    440      this._topSitesFeed.store.getState().Prefs.values[
    441        PREF_UNIFIED_ADS_TILES_ENABLED
    442      ];
    443 
    444    // Note: Cache-control only applies to direct Contile API calls
    445    if (!cacheHeader && !unifiedAdsTilesEnabled) {
    446      lazy.log.warn("Contile response cache control header is empty");
    447      return 0;
    448    }
    449    const [, staleIfError] = cacheHeader.match(/stale-if-error=\s*([0-9]+)/i);
    450    const [, maxAge] = cacheHeader.match(/max-age=\s*([0-9]+)/i);
    451    const validFor =
    452      Number.parseInt(staleIfError, 10) + Number.parseInt(maxAge, 10);
    453    return isNaN(validFor) ? 0 : validFor;
    454  }
    455 
    456  /**
    457   * Load Tiles from Contile Cache Prefs
    458   */
    459  async _loadTilesFromCache() {
    460    lazy.log.info("Contile client is trying to load tiles from local cache.");
    461    const now = Math.round(Date.now() / 1000);
    462    const lastFetch = Services.prefs.getIntPref(
    463      CONTILE_CACHE_LAST_FETCH_PREF,
    464      0
    465    );
    466    const validFor = Services.prefs.getIntPref(
    467      CONTILE_CACHE_VALID_FOR_PREF,
    468      CONTILE_CACHE_VALID_FOR_FALLBACK
    469    );
    470    this._topSitesFeed._telemetryUtility.setSponsoredTilesConfigured();
    471    if (now <= lastFetch + validFor) {
    472      try {
    473        const cachedData = (await this.cache.get()) || {};
    474        let cachedTiles = cachedData.contile;
    475        this._topSitesFeed._telemetryUtility.setTiles(cachedTiles);
    476        cachedTiles = this._filterBlockedSponsors(cachedTiles);
    477        this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToDismissed(
    478          cachedTiles
    479        );
    480        this._sites = cachedTiles;
    481        lazy.log.info("Local cache loaded.");
    482        return true;
    483      } catch (error) {
    484        lazy.log.warn(`Failed to load tiles from local cache: ${error}.`);
    485        return false;
    486      }
    487    }
    488 
    489    return false;
    490  }
    491 
    492  /**
    493   * Determine number of Tiles to get from Contile
    494   */
    495  _getMaxNumFromContile() {
    496    return (
    497      lazy.NimbusFeatures.pocketNewtab.getVariable(
    498        NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED
    499      ) ?? CONTILE_MAX_NUM_SPONSORED
    500    );
    501  }
    502 
    503  /**
    504   * Normalize new Unified Ads API response into
    505   * previous Contile ads response
    506   */
    507  _normalizeTileData(data) {
    508    const formattedTileData = [];
    509    const responseTilesData = Object.values(data);
    510 
    511    for (const tileData of responseTilesData) {
    512      if (tileData?.length) {
    513        // eslint-disable-next-line prefer-destructuring
    514        const tile = tileData[0];
    515 
    516        const formattedData = {
    517          id: tile.block_key,
    518          block_key: tile.block_key,
    519          name: tile.name,
    520          url: tile.url,
    521          click_url: tile.callbacks.click,
    522          image_url: tile.image_url,
    523          impression_url: tile.callbacks.impression,
    524          image_size: 200,
    525          attribution: tile.attributions || null,
    526        };
    527 
    528        formattedTileData.push(formattedData);
    529      }
    530    }
    531 
    532    return { tiles: formattedTileData };
    533  }
    534 
    535  sovEnabled() {
    536    const { values } = this._topSitesFeed.store.getState().Prefs;
    537    const trainhopSovEnabled = values?.trainhopConfig?.sov?.enabled;
    538    return trainhopSovEnabled || values?.[PREF_SOV_ENABLED];
    539  }
    540 
    541  csvToInts(val) {
    542    if (!val) {
    543      return [];
    544    }
    545 
    546    return val
    547      .split(",")
    548      .map(s => s.trim())
    549      .filter(item => item)
    550      .map(item => parseInt(item, 10));
    551  }
    552 
    553  /**
    554   * Builds a Share of Voice (SOV) config.
    555   *
    556   * @example input data from prefs/trainhopConfig
    557   * // name: "SOV-20251122215625"
    558   * // amp:  "100, 100, 100"
    559   * // frec: "0, 0, 0"
    560   *
    561   * @returns {{
    562   *   name: string,
    563   *   allocations: Array<{
    564   *     position: number,
    565   *     allocation: Array<{
    566   *       partner: string,
    567   *       percentage: number,
    568   *     }>,
    569   *   }>,
    570   * }}
    571   */
    572  generateSov() {
    573    const { values } = this._topSitesFeed.store.getState().Prefs;
    574    const trainhopSovConfig = values?.trainhopConfig?.sov || {};
    575    const name = trainhopSovConfig.name || values[PREF_SOV_NAME];
    576    const amp = this.csvToInts(
    577      trainhopSovConfig.amp || values[PREF_SOV_AMP_ALLOCATION]
    578    );
    579    const frec = this.csvToInts(
    580      trainhopSovConfig.frec || values[PREF_SOV_FRECENCY_ALLOCATION]
    581    );
    582 
    583    const allocations = Array.from(
    584      { length: DEFAULT_SOV_SLOT_COUNT },
    585      (val, i) => ({
    586        position: i + 1, // 1-based
    587        allocation: [
    588          { partner: SPONSORED_TILE_PARTNER_AMP, percentage: amp[i] || 0 },
    589          {
    590            partner: SPONSORED_TILE_PARTNER_FREC_BOOST,
    591            percentage: frec[i] || 0,
    592          },
    593        ],
    594      })
    595    );
    596 
    597    return { name, allocations };
    598  }
    599 
    600  // eslint-disable-next-line max-statements
    601  async _fetchSites() {
    602    if (
    603      !lazy.NimbusFeatures.newtab.getVariable(
    604        NIMBUS_VARIABLE_CONTILE_ENABLED
    605      ) ||
    606      !this._topSitesFeed.store.getState().Prefs.values[SHOW_SPONSORED_PREF]
    607    ) {
    608      if (this._sites.length) {
    609        this._sites = [];
    610        return true;
    611      }
    612      return false;
    613    }
    614 
    615    let response;
    616    let body;
    617 
    618    const state = this._topSitesFeed.store.getState();
    619 
    620    const unifiedAdsTilesEnabled =
    621      state.Prefs.values[PREF_UNIFIED_ADS_TILES_ENABLED];
    622 
    623    const adsFeedEnabled = state.Prefs.values[PREF_UNIFIED_ADS_ADSFEED_ENABLED];
    624 
    625    const debugServiceName = unifiedAdsTilesEnabled ? "MARS" : "Contile";
    626 
    627    try {
    628      // Fetch Data via TopSitesFeed.sys.mjs
    629      if (!adsFeedEnabled) {
    630        // Fetch tiles via UAPI service directly from TopSitesFeed.sys.mjs
    631        if (unifiedAdsTilesEnabled) {
    632          let fetchPromise;
    633          const marsOhttpEnabled = Services.prefs.getBoolPref(
    634            "browser.newtabpage.activity-stream.unifiedAds.ohttp.enabled",
    635            false
    636          );
    637          const ohttpRelayURL = Services.prefs.getStringPref(
    638            "browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL",
    639            ""
    640          );
    641          const ohttpConfigURL = Services.prefs.getStringPref(
    642            "browser.newtabpage.activity-stream.discoverystream.ohttp.configURL",
    643            ""
    644          );
    645          const headers = new Headers();
    646          headers.append("content-type", "application/json");
    647 
    648          const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT];
    649 
    650          // We need some basic data that we can pass along to the ohttp request.
    651          // We purposefully don't use ohttp on this request. We also expect to
    652          // mostly hit the HTTP cache rather than the network with these requests.
    653          if (marsOhttpEnabled) {
    654            const preflightResponse = await this._topSitesFeed.fetch(
    655              `${endpointBaseUrl}v1/ads-preflight`,
    656              {
    657                method: "GET",
    658              }
    659            );
    660            const preFlight = await preflightResponse.json();
    661 
    662            if (preFlight) {
    663              // If we don't get a normalized_ua, it means it matched the default userAgent.
    664              headers.append(
    665                "X-User-Agent",
    666                preFlight.normalized_ua || lazy.userAgent
    667              );
    668              headers.append("X-Geoname-ID", preFlight.geoname_id);
    669              headers.append("X-Geo-Location", preFlight.geo_location);
    670            }
    671          }
    672 
    673          let blockedSponsors =
    674            this._topSitesFeed.store.getState().Prefs.values[
    675              PREF_UNIFIED_ADS_BLOCKED_LIST
    676            ];
    677 
    678          // Overwrite URL to Unified Ads endpoint
    679          const fetchUrl = `${endpointBaseUrl}v1/ads`;
    680 
    681          const placementsArray = state.Prefs.values[
    682            PREF_UNIFIED_ADS_PLACEMENTS
    683          ]?.split(`,`)
    684            .map(s => s.trim())
    685            .filter(item => item);
    686          const countsArray = state.Prefs.values[
    687            PREF_UNIFIED_ADS_COUNTS
    688          ]?.split(`,`)
    689            .map(s => s.trim())
    690            .filter(item => item)
    691            .map(item => parseInt(item, 10));
    692 
    693          const controller = new AbortController();
    694          const { signal } = controller;
    695 
    696          const options = {
    697            method: "POST",
    698            headers,
    699            body: JSON.stringify({
    700              context_id: await lazy.ContextId.request(),
    701              placements: placementsArray.map((placement, index) => ({
    702                placement,
    703                count: countsArray[index],
    704              })),
    705              blocks: blockedSponsors.split(","),
    706            }),
    707            credentials: "omit",
    708            signal,
    709          };
    710 
    711          if (marsOhttpEnabled && ohttpConfigURL && ohttpRelayURL) {
    712            const config =
    713              await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL);
    714            if (!config) {
    715              console.error(
    716                new Error(
    717                  `OHTTP was configured for ${fetchUrl} but we couldn't fetch a valid config`
    718                )
    719              );
    720              return null;
    721            }
    722 
    723            // ObliviousHTTP.ohttpRequest only accepts a key/value object, and not
    724            // a Headers instance. We normalize any headers to a key/value object.
    725            //
    726            // We use instanceof here since isInstance isn't available for
    727            // Headers, it seems.
    728            // eslint-disable-next-line mozilla/use-isInstance
    729            if (options.headers && options.headers instanceof Headers) {
    730              options.headers = Object.fromEntries(options.headers);
    731            }
    732 
    733            fetchPromise = lazy.ObliviousHTTP.ohttpRequest(
    734              ohttpRelayURL,
    735              config,
    736              fetchUrl,
    737              options
    738            );
    739          } else {
    740            fetchPromise = this._topSitesFeed.fetch(fetchUrl, options);
    741          }
    742 
    743          response = await fetchPromise;
    744        } else {
    745          // (Default) Fetch tiles via Contile service from TopSitesFeed.sys.mjs
    746          const fetchUrl = Services.prefs.getStringPref(CONTILE_ENDPOINT_PREF);
    747 
    748          let options = {
    749            credentials: "omit",
    750          };
    751 
    752          response = await this._topSitesFeed.fetch(fetchUrl, options);
    753        }
    754 
    755        // Catch Response Error
    756        if (response && !response.ok) {
    757          lazy.log.warn(
    758            `${debugServiceName} endpoint returned unexpected status: ${response.status}`
    759          );
    760          if (response.status === 304 || response.status >= 500) {
    761            return await this._loadTilesFromCache();
    762          }
    763        }
    764 
    765        // Set Cache Prefs
    766        const lastFetch = Math.round(Date.now() / 1000);
    767        Services.prefs.setIntPref(CONTILE_CACHE_LAST_FETCH_PREF, lastFetch);
    768        this._topSitesFeed._telemetryUtility.setSponsoredTilesConfigured();
    769 
    770        // Contile returns 204 indicating there is no content at the moment.
    771        // If this happens, it will clear `this._sites` reset the cached tiles
    772        // to an empty array.
    773        if (response && response.status === 204) {
    774          this._topSitesFeed._telemetryUtility.clearTilesForProvider(
    775            SPONSORED_TILE_PARTNER_AMP
    776          );
    777          if (this._sites.length) {
    778            this._sites = [];
    779            await this.cache.set("contile", this._sites);
    780            return true;
    781          }
    782          return false;
    783        }
    784      }
    785 
    786      // Default behavior when ads fetched via TopSitesFeed
    787      if (response && response.status === 200) {
    788        body = await response.json();
    789      }
    790 
    791      // If using UAPI, normalize the data
    792      if (unifiedAdsTilesEnabled) {
    793        if (adsFeedEnabled) {
    794          // IMPORTANT: Ignore all previous fetch logic and get ads data from AdsFeed
    795          const { tiles } = state.Ads;
    796          body = { tiles };
    797        } else {
    798          // Converts UAPI response into normalized tiles[] array
    799          body = this._normalizeTileData(body);
    800        }
    801      }
    802 
    803      // Logic below runs the same regardless of ad source
    804      if (body?.sov) {
    805        this._sov = JSON.parse(atob(body.sov));
    806      } else if (this.sovEnabled()) {
    807        this._sov = this.generateSov();
    808      }
    809 
    810      if (body?.tiles && Array.isArray(body.tiles)) {
    811        const useAdditionalTiles = lazy.NimbusFeatures.newtab.getVariable(
    812          NIMBUS_VARIABLE_ADDITIONAL_TILES
    813        );
    814 
    815        const maxNumFromContile = this._getMaxNumFromContile();
    816 
    817        let { tiles } = body;
    818        this._topSitesFeed._telemetryUtility.setTiles(tiles);
    819        if (
    820          useAdditionalTiles !== undefined &&
    821          !useAdditionalTiles &&
    822          tiles.length > maxNumFromContile
    823        ) {
    824          tiles.length = maxNumFromContile;
    825          this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToOversold(
    826            tiles
    827          );
    828        }
    829        tiles = this._filterBlockedSponsors(tiles);
    830        this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToDismissed(
    831          tiles
    832        );
    833        if (tiles.length > maxNumFromContile) {
    834          lazy.log.info(`Remove unused links from ${debugServiceName}`);
    835          tiles.length = maxNumFromContile;
    836          this._topSitesFeed._telemetryUtility.determineFilteredTilesAndSetToOversold(
    837            tiles
    838          );
    839        }
    840        this._sites = tiles;
    841 
    842        await this.cache.set("contile", this._sites);
    843 
    844        if (!unifiedAdsTilesEnabled) {
    845          Services.prefs.setIntPref(
    846            CONTILE_CACHE_VALID_FOR_PREF,
    847            this._extractCacheValidFor(
    848              response.headers.get("cache-control") ||
    849                response.headers.get("Cache-Control")
    850            )
    851          );
    852        } else {
    853          Services.prefs.setIntPref(
    854            CONTILE_CACHE_VALID_FOR_PREF,
    855            CONTILE_CACHE_VALID_FOR_FALLBACK
    856          );
    857        }
    858 
    859        return true;
    860      }
    861    } catch (error) {
    862      lazy.log.warn(
    863        `Failed to fetch data from ${debugServiceName} server: ${error.message}`
    864      );
    865      return await this._loadTilesFromCache();
    866    }
    867    return false;
    868  }
    869 }
    870 
    871 /**
    872 * Creating a thin wrapper around PersistentCache.
    873 * This makes it easier for us to write automated tests that simulate responses.
    874 */
    875 ContileIntegration.prototype.PersistentCache = (...args) => {
    876  return new lazy.PersistentCache(...args);
    877 };
    878 
    879 export class TopSitesFeed {
    880  constructor() {
    881    this._telemetryUtility = new TopSitesTelemetry();
    882    this._contile = new ContileIntegration(this);
    883    this._tippyTopProvider = new TippyTopProvider();
    884    ChromeUtils.defineLazyGetter(
    885      this,
    886      "_currentSearchHostname",
    887      getShortHostnameForCurrentSearch
    888    );
    889    this.ranker = new RankShortcutsProvider();
    890 
    891    this.dedupe = new Dedupe(this._dedupeKey);
    892    this.frecentCache = new lazy.LinksCache(
    893      lazy.NewTabUtils.activityStreamLinks,
    894      "getTopSites",
    895      CACHED_LINK_PROPS_TO_MIGRATE,
    896      (oldOptions, newOptions) =>
    897        // Refresh if no old options or requesting more items
    898        !(oldOptions.numItems >= newOptions.numItems)
    899    );
    900    this.frecencyBoostProvider = new FrecencyBoostProvider(this.frecentCache);
    901    this.pinnedCache = new lazy.LinksCache(
    902      lazy.NewTabUtils.pinnedLinks,
    903      "links",
    904      [...CACHED_LINK_PROPS_TO_MIGRATE, ...PINNED_FAVICON_PROPS_TO_MIGRATE]
    905    );
    906    lazy.PageThumbs.addExpirationFilter(this);
    907    this._nimbusChangeListener = this._nimbusChangeListener.bind(this);
    908  }
    909 
    910  _nimbusChangeListener(event, reason) {
    911    // The Nimbus API current doesn't specify the changed variable(s) in the
    912    // listener callback, so we have to refresh unconditionally on every change
    913    // of the `newtab` feature. It should be a manageable overhead given the
    914    // current update cadence (6 hours) of Nimbus.
    915    //
    916    // Skip the experiment and rollout loading reasons since this feature has
    917    // `isEarlyStartup` enabled, the feature variables are already available
    918    // before the experiment or rollout loads.
    919    if (
    920      !["feature-experiment-loaded", "feature-rollout-loaded"].includes(reason)
    921    ) {
    922      this._contile.refresh();
    923    }
    924  }
    925 
    926  init() {
    927    // If the feed was previously disabled PREFS_INITIAL_VALUES was never received
    928    this._readDefaults({ isStartup: true });
    929    this._contile.refresh();
    930    Services.obs.addObserver(this, "browser-search-engine-modified");
    931    Services.obs.addObserver(this, "browser-region-updated");
    932    Services.prefs.addObserver(REMOTE_SETTING_DEFAULTS_PREF, this);
    933    Services.prefs.addObserver(DEFAULT_SITES_OVERRIDE_PREF, this);
    934    Services.prefs.addObserver(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH, this);
    935    lazy.NimbusFeatures.newtab.onUpdate(this._nimbusChangeListener);
    936    this.frecencyBoostProvider.init();
    937  }
    938 
    939  uninit() {
    940    lazy.PageThumbs.removeExpirationFilter(this);
    941    Services.obs.removeObserver(this, "browser-search-engine-modified");
    942    Services.obs.removeObserver(this, "browser-region-updated");
    943    Services.prefs.removeObserver(REMOTE_SETTING_DEFAULTS_PREF, this);
    944    Services.prefs.removeObserver(DEFAULT_SITES_OVERRIDE_PREF, this);
    945    Services.prefs.removeObserver(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH, this);
    946    lazy.NimbusFeatures.newtab.offUpdate(this._nimbusChangeListener);
    947    this.frecencyBoostProvider.uninit();
    948  }
    949 
    950  observe(subj, topic, data) {
    951    switch (topic) {
    952      case "browser-search-engine-modified":
    953        // We should update the current top sites if the search engine has been changed since
    954        // the search engine that gets filtered out of top sites has changed.
    955        // We also need to drop search shortcuts when their engine gets removed / hidden.
    956        if (
    957          data === "engine-default" &&
    958          this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF]
    959        ) {
    960          delete this._currentSearchHostname;
    961          this._currentSearchHostname = getShortHostnameForCurrentSearch();
    962        }
    963        this.refresh({ broadcast: true });
    964        break;
    965      case "browser-region-updated":
    966        this._readDefaults();
    967        break;
    968      case "nsPref:changed":
    969        if (
    970          data === REMOTE_SETTING_DEFAULTS_PREF ||
    971          data === DEFAULT_SITES_OVERRIDE_PREF ||
    972          data.startsWith(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH)
    973        ) {
    974          this._readDefaults();
    975        }
    976        break;
    977    }
    978  }
    979 
    980  _dedupeKey(site) {
    981    return site && site.hostname;
    982  }
    983 
    984  /**
    985   * _readContile - sets DEFAULT_TOP_SITES with contile
    986   */
    987  _readContile() {
    988    // Keep the number of positions in the array in sync with CONTILE_MAX_NUM_SPONSORED.
    989    // sponsored_position is a 1-based index, and contilePositions is a 0-based index,
    990    // so we need to add 1 to each of these.
    991    // Also currently this does not work with SOV.
    992    let contilePositions = lazy.NimbusFeatures.pocketNewtab
    993      .getVariable(NIMBUS_VARIABLE_CONTILE_POSITIONS)
    994      ?.split(",")
    995      .map(item => parseInt(item, 10) + 1)
    996      .filter(item => !Number.isNaN(item));
    997    if (!contilePositions || contilePositions.length === 0) {
    998      contilePositions = [1, 2];
    999    }
   1000 
   1001    let hasContileTiles = false;
   1002 
   1003    let contilePositionIndex = 0;
   1004    // We need to loop through potential spocs and set their positions.
   1005    // If we run out of spocs or positions, we stop.
   1006    // First, we need to know which array is shortest. This is our exit condition.
   1007    const minLength = Math.min(
   1008      contilePositions.length,
   1009      this._contile.sites.length
   1010    );
   1011    // Loop until we run out of spocs or positions.
   1012    for (let i = 0; i < minLength; i++) {
   1013      let site = this._contile.sites[i];
   1014      let hostname = lazy.NewTabUtils.shortURL(site);
   1015      let link = {
   1016        isDefault: true,
   1017        url: site.url,
   1018        hostname,
   1019        sendAttributionRequest: false,
   1020        label: site.name,
   1021        show_sponsored_label: hostname !== "yandex",
   1022        sponsored_position: contilePositions[contilePositionIndex++],
   1023        sponsored_click_url: site.click_url,
   1024        sponsored_impression_url: site.impression_url,
   1025        sponsored_tile_id: site.id,
   1026        partner: SPONSORED_TILE_PARTNER_AMP,
   1027        block_key: site.id,
   1028        attribution: site.attribution,
   1029      };
   1030      if (site.image_url && site.image_size >= MIN_FAVICON_SIZE) {
   1031        // Only use the image from Contile if it's hi-res, otherwise, fallback
   1032        // to the built-in favicons.
   1033        link.favicon = site.image_url;
   1034        link.faviconSize = site.image_size;
   1035      }
   1036      DEFAULT_TOP_SITES.push(link);
   1037    }
   1038    hasContileTiles = contilePositionIndex > 0;
   1039    // This is to catch where we receive 3 tiles but reduce to 2 early in the filtering, before blocked list applied.
   1040    this._telemetryUtility.determineFilteredTilesAndSetToOversold(
   1041      DEFAULT_TOP_SITES
   1042    );
   1043 
   1044    return hasContileTiles;
   1045  }
   1046 
   1047  /**
   1048   * _readDefaults - sets DEFAULT_TOP_SITES
   1049   */
   1050  async _readDefaults({ isStartup = false } = {}) {
   1051    this._useRemoteSetting = false;
   1052 
   1053    if (!Services.prefs.getBoolPref(REMOTE_SETTING_DEFAULTS_PREF)) {
   1054      this.refreshDefaults(
   1055        this.store.getState().Prefs.values[DEFAULT_SITES_PREF],
   1056        { isStartup }
   1057      );
   1058      return;
   1059    }
   1060 
   1061    // Try using default top sites from enterprise policies or tests. The pref
   1062    // is locked when set via enterprise policy. Tests have no default sites
   1063    // unless they set them via this pref.
   1064    if (
   1065      Services.prefs.prefIsLocked(DEFAULT_SITES_OVERRIDE_PREF) ||
   1066      Cu.isInAutomation
   1067    ) {
   1068      let sites = Services.prefs.getStringPref(DEFAULT_SITES_OVERRIDE_PREF, "");
   1069      this.refreshDefaults(sites, { isStartup });
   1070      return;
   1071    }
   1072 
   1073    // Clear out the array of any previous defaults.
   1074    DEFAULT_TOP_SITES.length = 0;
   1075 
   1076    // Read defaults from contile.
   1077    const contileEnabled = lazy.NimbusFeatures.newtab.getVariable(
   1078      NIMBUS_VARIABLE_CONTILE_ENABLED
   1079    );
   1080 
   1081    let hasContileTiles = false;
   1082 
   1083    if (contileEnabled) {
   1084      hasContileTiles = this._readContile();
   1085    }
   1086 
   1087    // Read defaults from remote settings.
   1088    this._useRemoteSetting = true;
   1089    let remoteSettingData = await this._getRemoteConfig();
   1090 
   1091    const sponsoredBlocklist = JSON.parse(
   1092      Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]")
   1093    );
   1094 
   1095    for (let siteData of remoteSettingData) {
   1096      let hostname = lazy.NewTabUtils.shortURL(siteData);
   1097      // Drop default sites when Contile already provided a sponsored one with
   1098      // the same host name.
   1099      if (
   1100        contileEnabled &&
   1101        DEFAULT_TOP_SITES.findIndex(site => site.hostname === hostname) > -1
   1102      ) {
   1103        continue;
   1104      }
   1105      // Also drop those sponsored sites that were blocked by the user before
   1106      // with the same hostname.
   1107      if (
   1108        siteData.sponsored_position &&
   1109        sponsoredBlocklist.includes(hostname)
   1110      ) {
   1111        continue;
   1112      }
   1113      let link = {
   1114        isDefault: true,
   1115        url: siteData.url,
   1116        hostname,
   1117        sendAttributionRequest: !!siteData.send_attribution_request,
   1118      };
   1119      if (siteData.url_urlbar_override) {
   1120        link.url_urlbar = siteData.url_urlbar_override;
   1121      }
   1122      if (siteData.title) {
   1123        link.label = siteData.title;
   1124      }
   1125      if (siteData.search_shortcut) {
   1126        link = await this.topSiteToSearchTopSite(link);
   1127      } else if (siteData.sponsored_position) {
   1128        if (contileEnabled && hasContileTiles) {
   1129          continue;
   1130        }
   1131        const {
   1132          sponsored_position,
   1133          sponsored_tile_id,
   1134          sponsored_impression_url,
   1135          sponsored_click_url,
   1136          block_key,
   1137        } = siteData;
   1138        link = {
   1139          sponsored_position,
   1140          sponsored_tile_id,
   1141          sponsored_impression_url,
   1142          sponsored_click_url,
   1143          block_key,
   1144          show_sponsored_label: link.hostname !== "yandex",
   1145          ...link,
   1146        };
   1147      }
   1148      DEFAULT_TOP_SITES.push(link);
   1149    }
   1150 
   1151    this.refresh({ broadcast: true, isStartup });
   1152  }
   1153 
   1154  refreshDefaults(sites, { isStartup = false } = {}) {
   1155    // Clear out the array of any previous defaults
   1156    DEFAULT_TOP_SITES.length = 0;
   1157 
   1158    // Add default sites if any based on the pref
   1159    if (sites) {
   1160      for (const url of sites.split(",")) {
   1161        const site = {
   1162          isDefault: true,
   1163          url,
   1164        };
   1165        site.hostname = lazy.NewTabUtils.shortURL(site);
   1166        DEFAULT_TOP_SITES.push(site);
   1167      }
   1168    }
   1169 
   1170    this.refresh({ broadcast: true, isStartup });
   1171  }
   1172 
   1173  async _getRemoteConfig(firstTime = true) {
   1174    if (!this._remoteConfig) {
   1175      this._remoteConfig = await lazy.RemoteSettings("top-sites");
   1176      this._remoteConfig.on("sync", () => {
   1177        this._readDefaults();
   1178      });
   1179    }
   1180 
   1181    let result = [];
   1182    let failed = false;
   1183    try {
   1184      result = await this._remoteConfig.get();
   1185    } catch (ex) {
   1186      console.error(ex);
   1187      failed = true;
   1188    }
   1189    if (!result.length) {
   1190      console.error("Received empty top sites configuration!");
   1191      failed = true;
   1192    }
   1193    // If we failed, or the result is empty, try loading from the local dump.
   1194    if (firstTime && failed) {
   1195      await this._remoteConfig.db.clear();
   1196      // Now call this again.
   1197      return this._getRemoteConfig(false);
   1198    }
   1199 
   1200    // Sort sites based on the "order" attribute.
   1201    result.sort((a, b) => a.order - b.order);
   1202 
   1203    result = result.filter(topsite => {
   1204      // Filter by region.
   1205      if (topsite.exclude_regions?.includes(lazy.Region.home)) {
   1206        return false;
   1207      }
   1208      if (
   1209        topsite.include_regions?.length &&
   1210        !topsite.include_regions.includes(lazy.Region.home)
   1211      ) {
   1212        return false;
   1213      }
   1214 
   1215      // Filter by locale.
   1216      if (topsite.exclude_locales?.includes(Services.locale.appLocaleAsBCP47)) {
   1217        return false;
   1218      }
   1219      if (
   1220        topsite.include_locales?.length &&
   1221        !topsite.include_locales.includes(Services.locale.appLocaleAsBCP47)
   1222      ) {
   1223        return false;
   1224      }
   1225 
   1226      // Filter by experiment.
   1227      // Exclude this top site if any of the specified experiments are running.
   1228      if (
   1229        topsite.exclude_experiments?.some(experimentID =>
   1230          Services.prefs.getBoolPref(
   1231            DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH + experimentID,
   1232            false
   1233          )
   1234        )
   1235      ) {
   1236        return false;
   1237      }
   1238      // Exclude this top site if none of the specified experiments are running.
   1239      if (
   1240        topsite.include_experiments?.length &&
   1241        topsite.include_experiments.every(
   1242          experimentID =>
   1243            !Services.prefs.getBoolPref(
   1244              DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH + experimentID,
   1245              false
   1246            )
   1247        )
   1248      ) {
   1249        return false;
   1250      }
   1251 
   1252      return true;
   1253    });
   1254 
   1255    return result;
   1256  }
   1257 
   1258  filterForThumbnailExpiration(callback) {
   1259    const { rows } = this.store.getState().TopSites;
   1260    callback(
   1261      rows.reduce((acc, site) => {
   1262        acc.push(site.url);
   1263        if (site.customScreenshotURL) {
   1264          acc.push(site.customScreenshotURL);
   1265        }
   1266        return acc;
   1267      }, [])
   1268    );
   1269  }
   1270 
   1271  /**
   1272   * shouldFilterSearchTile - is default filtering enabled and does a given hostname match the user's default search engine?
   1273   *
   1274   * @param {string} hostname a top site hostname, such as "amazon" or "foo"
   1275   * @returns {bool}
   1276   */
   1277  shouldFilterSearchTile(hostname) {
   1278    if (
   1279      this.store.getState().Prefs.values[FILTER_DEFAULT_SEARCH_PREF] &&
   1280      (SEARCH_FILTERS.includes(hostname) ||
   1281        hostname === this._currentSearchHostname)
   1282    ) {
   1283      return true;
   1284    }
   1285    return false;
   1286  }
   1287 
   1288  /**
   1289   * If the search shortcuts experiment is running, insert search shortcuts if
   1290   * needed.
   1291   *
   1292   * @param {Array} plainPinnedSites (from the pinnedSitesCache)
   1293   * @returns {boolean} Did we insert any search shortcuts?
   1294   */
   1295  async _maybeInsertSearchShortcuts(plainPinnedSites) {
   1296    // Only insert shortcuts if the experiment is running
   1297    if (this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) {
   1298      // We don't want to insert shortcuts we've previously inserted
   1299      const prevInsertedShortcuts = this.store
   1300        .getState()
   1301        .Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF].split(",")
   1302        .filter(s => s); // Filter out empty strings
   1303      const newInsertedShortcuts = [];
   1304 
   1305      let shouldPin = this._useRemoteSetting
   1306        ? DEFAULT_TOP_SITES.filter(s => s.searchTopSite).map(s => s.hostname)
   1307        : this.store
   1308            .getState()
   1309            .Prefs.values[SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF].split(",");
   1310      shouldPin = shouldPin
   1311        .map(getSearchProvider)
   1312        .filter(s => s && s.shortURL !== this._currentSearchHostname);
   1313 
   1314      // If we've previously inserted all search shortcuts return early
   1315      if (
   1316        shouldPin.every(shortcut =>
   1317          prevInsertedShortcuts.includes(shortcut.shortURL)
   1318        )
   1319      ) {
   1320        return false;
   1321      }
   1322 
   1323      const numberOfSlots =
   1324        this.store.getState().Prefs.values[ROWS_PREF] *
   1325        TOP_SITES_MAX_SITES_PER_ROW;
   1326 
   1327      // The plainPinnedSites array is populated with pinned sites at their
   1328      // respective indices, and null everywhere else, but is not always the
   1329      // right length
   1330      const emptySlots = Math.max(numberOfSlots - plainPinnedSites.length, 0);
   1331      const pinnedSites = [...plainPinnedSites].concat(
   1332        Array(emptySlots).fill(null)
   1333      );
   1334 
   1335      const tryToInsertSearchShortcut = async shortcut => {
   1336        const nextAvailable = pinnedSites.indexOf(null);
   1337        // Only add a search shortcut if the site isn't already pinned, we
   1338        // haven't previously inserted it, there's space to pin it, and the
   1339        // search engine is available in Firefox
   1340        if (
   1341          !pinnedSites.find(
   1342            s => s && lazy.NewTabUtils.shortURL(s) === shortcut.shortURL
   1343          ) &&
   1344          !prevInsertedShortcuts.includes(shortcut.shortURL) &&
   1345          nextAvailable > -1 &&
   1346          (await checkHasSearchEngine(shortcut.keyword))
   1347        ) {
   1348          const site = await this.topSiteToSearchTopSite({ url: shortcut.url });
   1349          this._pinSiteAt(site, nextAvailable);
   1350          pinnedSites[nextAvailable] = site;
   1351          newInsertedShortcuts.push(shortcut.shortURL);
   1352        }
   1353      };
   1354 
   1355      for (let shortcut of shouldPin) {
   1356        await tryToInsertSearchShortcut(shortcut);
   1357      }
   1358 
   1359      if (newInsertedShortcuts.length) {
   1360        this.store.dispatch(
   1361          ac.SetPref(
   1362            SEARCH_SHORTCUTS_HAVE_PINNED_PREF,
   1363            prevInsertedShortcuts.concat(newInsertedShortcuts).join(",")
   1364          )
   1365        );
   1366        return true;
   1367      }
   1368    }
   1369 
   1370    return false;
   1371  }
   1372 
   1373  /**
   1374   * This thin wrapper around global.fetch makes it easier for us to write
   1375   * automated tests that simulate responses from this fetch.
   1376   */
   1377  fetch(...args) {
   1378    return fetch(...args);
   1379  }
   1380 
   1381  /**
   1382   * Fetch topsites spocs that are frecency boosted.
   1383   *
   1384   * @returns {Array} An array of sponsored tile objects.
   1385   */
   1386  async fetchFrecencyBoostedSpocs() {
   1387    let candidates = [];
   1388    if (
   1389      this._contile.sovEnabled() &&
   1390      this.store.getState().Prefs.values[SHOW_SPONSORED_PREF]
   1391    ) {
   1392      const { values } = this.store.getState().Prefs;
   1393      const numItems = values?.trainhopConfig?.sov?.numItems;
   1394      const randomSponsorEnabled = values?.trainhopConfig?.sov?.random_sponsor;
   1395 
   1396      if (!randomSponsorEnabled) {
   1397        candidates = await this.frecencyBoostProvider.fetch(numItems);
   1398        // If we have a matched set of candidates,
   1399        // we can check if it's an exposure event.
   1400        if (candidates.length) {
   1401          this.frecencyBoostedSpocsExposureEvent();
   1402        }
   1403      }
   1404 
   1405      if (!candidates.length) {
   1406        const randomTile =
   1407          await this.frecencyBoostProvider.retrieveRandomFrecencyTile();
   1408        if (randomTile) {
   1409          candidates = [randomTile];
   1410        }
   1411      }
   1412    }
   1413    return candidates;
   1414  }
   1415 
   1416  /**
   1417   * Updates frecency boosted topsites spocs cache.
   1418   */
   1419  async updateFrecencyBoostedSpocs() {
   1420    const { values } = this.store.getState().Prefs;
   1421    const numItems = values?.trainhopConfig?.sov?.numItems;
   1422    await this.frecencyBoostProvider.update(numItems);
   1423  }
   1424 
   1425  /**
   1426   * Flip exposure event pref,
   1427   * if the user is in a SOV experiment,
   1428   * for both control and treatment,
   1429   * and had frecency boosted spocs because of it.
   1430   */
   1431  frecencyBoostedSpocsExposureEvent() {
   1432    const { values } = this.store.getState().Prefs;
   1433    const trainhopSovEnabled = values?.trainhopConfig?.sov?.enabled;
   1434 
   1435    if (trainhopSovEnabled) {
   1436      this.store.dispatch(ac.SetPref(PREF_SOV_FRECENCY_EXPOSURE, true));
   1437    }
   1438  }
   1439 
   1440  /**
   1441   * Fetch topsites spocs from the DiscoveryStream feed.
   1442   *
   1443   * @returns {Array} An array of sponsored tile objects.
   1444   */
   1445  fetchDiscoveryStreamSpocs() {
   1446    let sponsored = [];
   1447    const { DiscoveryStream } = this.store.getState();
   1448    if (DiscoveryStream) {
   1449      const discoveryStreamSpocs =
   1450        DiscoveryStream.spocs.data["sponsored-topsites"]?.items || [];
   1451      // Find the first component of a type and remove it from layout
   1452      const findSponsoredTopsitesPositions = name => {
   1453        for (const row of DiscoveryStream.layout) {
   1454          for (const component of row.components) {
   1455            if (component.placement?.name === name) {
   1456              return component.spocs.positions;
   1457            }
   1458          }
   1459        }
   1460        return null;
   1461      };
   1462 
   1463      // Get positions from layout for now. This could be improved if we store position data in state.
   1464      const discoveryStreamSpocPositions =
   1465        findSponsoredTopsitesPositions("sponsored-topsites");
   1466 
   1467      if (discoveryStreamSpocPositions?.length) {
   1468        function reformatImageURL(url, width, height) {
   1469          // Change the image URL to request a size tailored for the parent container width
   1470          // Also: force JPEG, quality 60, no upscaling, no EXIF data
   1471          // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html
   1472          // For now we wrap this in single quotes because this is being used in a url() css rule, and otherwise would cause a parsing error.
   1473          return `'https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(
   1474            url
   1475          )}'`;
   1476        }
   1477 
   1478        // We need to loop through potential spocs and set their positions.
   1479        // If we run out of spocs or positions, we stop.
   1480        // First, we need to know which array is shortest. This is our exit condition.
   1481        const minLength = Math.min(
   1482          discoveryStreamSpocPositions.length,
   1483          discoveryStreamSpocs.length
   1484        );
   1485        // Loop until we run out of spocs or positions.
   1486        for (let i = 0; i < minLength; i++) {
   1487          const positionIndex = discoveryStreamSpocPositions[i].index;
   1488          const spoc = discoveryStreamSpocs[i];
   1489          const link = {
   1490            favicon: reformatImageURL(spoc.raw_image_src, 96, 96),
   1491            faviconSize: 96,
   1492            type: "SPOC",
   1493            label: spoc.title || spoc.sponsor,
   1494            title: spoc.title || spoc.sponsor,
   1495            url: spoc.url,
   1496            flightId: spoc.flight_id,
   1497            id: spoc.id,
   1498            guid: spoc.id,
   1499            shim: spoc.shim,
   1500            // For now we are assuming position based on intended position.
   1501            // Actual position can shift based on other content.
   1502            // We send the intended position in the ping.
   1503            pos: positionIndex,
   1504            // Set this so that SPOC topsites won't be shown in the URL bar.
   1505            // See Bug 1822027. Note that `sponsored_position` is 1-based.
   1506            sponsored_position: positionIndex + 1,
   1507            // This is used for topsites deduping.
   1508            hostname: lazy.NewTabUtils.shortURL({ url: spoc.url }),
   1509            partner: SPONSORED_TILE_PARTNER_MOZ_SALES,
   1510          };
   1511          sponsored.push(link);
   1512        }
   1513      }
   1514    }
   1515    return sponsored;
   1516  }
   1517 
   1518  // eslint-disable-next-line max-statements
   1519  async getLinksWithDefaults(isStartup = false) {
   1520    const prefValues = this.store.getState().Prefs.values;
   1521    // switch on top_sites thompson sampling experiment
   1522    const overSampleMultiplier =
   1523      prefValues?.trainhopConfig?.smartShortcuts?.over_sample_multiplier ??
   1524      OVERSAMPLE_MULTIPLIER;
   1525    const numFetch =
   1526      (smartshortcutsEnabled(this.store.getState().Prefs.values)
   1527        ? overSampleMultiplier
   1528        : 1) *
   1529      (prefValues[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW);
   1530    const numItems = prefValues[ROWS_PREF] * TOP_SITES_MAX_SITES_PER_ROW;
   1531    const searchShortcutsExperiment = prefValues[SEARCH_SHORTCUTS_EXPERIMENT];
   1532    // We must wait for search services to initialize in order to access default
   1533    // search engine properties without triggering a synchronous initialization
   1534    try {
   1535      await Services.search.init();
   1536    } catch {
   1537      // We continue anyway because we want the user to see their sponsored,
   1538      // saved, or visited shortcut tiles even if search engines are not
   1539      // available.
   1540    }
   1541 
   1542    // Get all frecent sites from history.
   1543    let frecent = [];
   1544    const cache = await this.frecentCache.request({
   1545      // We need to overquery due to the top 5 alexa search + default search possibly being removed
   1546      numItems: numFetch + SEARCH_FILTERS.length + 1,
   1547      topsiteFrecency: lazy.pageFrecencyThreshold,
   1548    });
   1549    for (let link of cache) {
   1550      const hostname = lazy.NewTabUtils.shortURL(link);
   1551      if (!this.shouldFilterSearchTile(hostname)) {
   1552        frecent.push({
   1553          ...(searchShortcutsExperiment
   1554            ? await this.topSiteToSearchTopSite(link)
   1555            : link),
   1556          hostname,
   1557        });
   1558        // LinksCache can return the previous cached result
   1559        // if it's equal to or greater than the requested amount.
   1560        // In this case we can just take what we need.
   1561        if (frecent.length >= numFetch) {
   1562          break;
   1563        }
   1564      }
   1565    }
   1566 
   1567    // Get defaults.
   1568    let contileSponsored = [];
   1569    let notBlockedDefaultSites = [];
   1570 
   1571    for (let link of DEFAULT_TOP_SITES) {
   1572      // For sponsored Yandex links, default filtering is reversed: we only
   1573      // show them if Yandex is the default search engine.
   1574      if (link.sponsored_position && link.hostname === "yandex") {
   1575        if (link.hostname !== this._currentSearchHostname) {
   1576          continue;
   1577        }
   1578      } else if (this.shouldFilterSearchTile(link.hostname)) {
   1579        continue;
   1580      }
   1581      // Drop blocked default sites.
   1582      if (
   1583        lazy.NewTabUtils.blockedLinks.isBlocked({
   1584          url: link.url,
   1585        })
   1586      ) {
   1587        continue;
   1588      }
   1589      // If we've previously blocked a search shortcut, remove the default top site
   1590      // that matches the hostname
   1591      const searchProvider = getSearchProvider(lazy.NewTabUtils.shortURL(link));
   1592      if (
   1593        searchProvider &&
   1594        lazy.NewTabUtils.blockedLinks.isBlocked({ url: searchProvider.url })
   1595      ) {
   1596        continue;
   1597      }
   1598      if (link.sponsored_position) {
   1599        if (!prefValues[SHOW_SPONSORED_PREF]) {
   1600          continue;
   1601        }
   1602        contileSponsored[link.sponsored_position - 1] = link;
   1603 
   1604        // Unpin search shortcut if present for the sponsored link to be shown
   1605        // instead.
   1606        this._unpinSearchShortcut(link.hostname);
   1607      } else {
   1608        notBlockedDefaultSites.push(
   1609          searchShortcutsExperiment
   1610            ? await this.topSiteToSearchTopSite(link)
   1611            : link
   1612        );
   1613      }
   1614    }
   1615    this._telemetryUtility.determineFilteredTilesAndSetToDismissed(
   1616      contileSponsored
   1617    );
   1618 
   1619    const discoverySponsored = this.fetchDiscoveryStreamSpocs();
   1620    const frecencyBoostedSponsored = await this.fetchFrecencyBoostedSpocs();
   1621    this._telemetryUtility.setTiles(discoverySponsored);
   1622 
   1623    // Get pinned links augmented with desired properties
   1624    let plainPinned = await this.pinnedCache.request();
   1625 
   1626    // Insert search shortcuts if we need to.
   1627    // _maybeInsertSearchShortcuts returns true if any search shortcuts are
   1628    // inserted, meaning we need to expire and refresh the pinnedCache
   1629    if (await this._maybeInsertSearchShortcuts(plainPinned)) {
   1630      this.pinnedCache.expire();
   1631      plainPinned = await this.pinnedCache.request();
   1632    }
   1633 
   1634    const pinned = await Promise.all(
   1635      plainPinned.map(async link => {
   1636        if (!link) {
   1637          return link;
   1638        }
   1639 
   1640        // Drop pinned search shortcuts when their engine has been removed / hidden.
   1641        if (link.searchTopSite) {
   1642          const searchProvider = getSearchProvider(
   1643            lazy.NewTabUtils.shortURL(link)
   1644          );
   1645          if (
   1646            !searchProvider ||
   1647            !(await checkHasSearchEngine(searchProvider.keyword))
   1648          ) {
   1649            return null;
   1650          }
   1651        }
   1652 
   1653        // Copy all properties from a frecent link and add more
   1654        const finder = other => other.url === link.url;
   1655 
   1656        // Remove frecent link's screenshot if pinned link has a custom one
   1657        const frecentSite = frecent.find(finder);
   1658        if (frecentSite && link.customScreenshotURL) {
   1659          delete frecentSite.screenshot;
   1660        }
   1661        // If the link is a frecent site, do not copy over 'isDefault', else check
   1662        // if the site is a default site
   1663        const copy = Object.assign(
   1664          {},
   1665          frecentSite || { isDefault: !!notBlockedDefaultSites.find(finder) },
   1666          link,
   1667          { hostname: lazy.NewTabUtils.shortURL(link) },
   1668          { searchTopSite: !!link.searchTopSite }
   1669        );
   1670 
   1671        // Add in favicons if we don't already have it
   1672        if (!copy.favicon) {
   1673          try {
   1674            lazy.NewTabUtils.activityStreamProvider._faviconBytesToDataURI(
   1675              await lazy.NewTabUtils.activityStreamProvider._addFavicons([copy])
   1676            );
   1677 
   1678            for (const prop of PINNED_FAVICON_PROPS_TO_MIGRATE) {
   1679              copy.__sharedCache.updateLink(prop, copy[prop]);
   1680            }
   1681          } catch (e) {
   1682            // Some issue with favicon, so just continue without one
   1683          }
   1684        }
   1685 
   1686        return copy;
   1687      })
   1688    );
   1689 
   1690    // Remove any duplicates from frecent and default sites
   1691    const [
   1692      ,
   1693      dedupedContileSponsored,
   1694      dedupedDiscoverySponsored,
   1695      dedupedFrecent,
   1696      dedupedFrecencyBoostedSponsored,
   1697      dedupedDefaults,
   1698    ] = this.dedupe.group(
   1699      pinned,
   1700      contileSponsored,
   1701      discoverySponsored,
   1702      frecent,
   1703      frecencyBoostedSponsored,
   1704      notBlockedDefaultSites
   1705    );
   1706 
   1707    const dedupedUnpinned = [...dedupedFrecent, ...dedupedDefaults];
   1708 
   1709    const dedupedSponsored = this._mergeSponsoredLinks({
   1710      [SPONSORED_TILE_PARTNER_AMP]: dedupedContileSponsored,
   1711      [SPONSORED_TILE_PARTNER_MOZ_SALES]: dedupedDiscoverySponsored,
   1712      [SPONSORED_TILE_PARTNER_FREC_BOOST]: dedupedFrecencyBoostedSponsored,
   1713    });
   1714 
   1715    this._maybeCapSponsoredLinks(dedupedSponsored);
   1716 
   1717    // This will set all extra tiles to oversold, including moz-sales.
   1718    this._telemetryUtility.determineFilteredTilesAndSetToOversold(
   1719      dedupedSponsored
   1720    );
   1721 
   1722    // Remove adult sites if we need to
   1723    const checkedAdult = lazy.FilterAdult.filter(dedupedUnpinned);
   1724 
   1725    // Sample topsites via thompson sampling, if in experiment
   1726    let sampledSites;
   1727    if (smartshortcutsEnabled(this.store.getState().Prefs.values)) {
   1728      sampledSites = await this.ranker.rankTopSites(
   1729        checkedAdult,
   1730        prefValues,
   1731        isStartup,
   1732        dedupedSponsored.length
   1733      );
   1734    } else {
   1735      sampledSites = checkedAdult;
   1736    }
   1737 
   1738    // Insert the original pinned sites into the deduped frecent and defaults.
   1739    let withPinned = insertPinned(sampledSites, pinned);
   1740 
   1741    // Insert sponsored sites at their desired position.
   1742    dedupedSponsored.forEach(link => {
   1743      if (!link) {
   1744        return;
   1745      }
   1746      let index = link.sponsored_position - 1;
   1747      if (index >= withPinned.length) {
   1748        withPinned[index] = link;
   1749      } else if (withPinned[index]?.sponsored_position) {
   1750        // We currently want DiscoveryStream spocs to replace existing spocs.
   1751        withPinned[index] = link;
   1752      } else {
   1753        withPinned.splice(index, 0, link);
   1754      }
   1755    });
   1756    // Remove excess items after we inserted sponsored ones.
   1757    withPinned = withPinned.slice(0, numItems);
   1758 
   1759    // Now, get a tippy top icon, a rich icon, or screenshot for every item
   1760    for (const link of withPinned) {
   1761      if (link) {
   1762        // If there is a custom screenshot this is the only image we display
   1763        if (link.customScreenshotURL) {
   1764          this._fetchScreenshot(link, link.customScreenshotURL, isStartup);
   1765        } else if (link.searchTopSite && !link.isDefault) {
   1766          this._tippyTopProvider.processSite(link);
   1767        } else {
   1768          this._fetchIcon(link, isStartup);
   1769        }
   1770 
   1771        // Remove internal properties that might be updated after dispatch
   1772        delete link.__sharedCache;
   1773 
   1774        // Indicate that these links should get a frecency bonus when clicked
   1775        link.typedBonus = true;
   1776      }
   1777    }
   1778 
   1779    this._linksWithDefaults = withPinned;
   1780 
   1781    this._telemetryUtility.finalizeNewtabPingFields(dedupedSponsored);
   1782    return withPinned;
   1783  }
   1784 
   1785  /**
   1786   * Cap sponsored links if they're more than the specified maximum.
   1787   *
   1788   * @param {Array} links An array of sponsored links. Capping will be performed in-place.
   1789   */
   1790  _maybeCapSponsoredLinks(links) {
   1791    // Set maximum sponsored top sites
   1792    const maxSponsored =
   1793      lazy.NimbusFeatures.pocketNewtab.getVariable(
   1794        NIMBUS_VARIABLE_MAX_SPONSORED
   1795      ) ?? MAX_NUM_SPONSORED;
   1796    if (links.length > maxSponsored) {
   1797      links.length = maxSponsored;
   1798    }
   1799  }
   1800 
   1801  /**
   1802   * Merge sponsored links from all the partners using SOV if present.
   1803   * For each tile position, the user is assigned to one partner via stable sampling.
   1804   * If the chosen partner doesn't have a tile to serve, another tile from a different
   1805   * partner is used as the replacement.
   1806   *
   1807   * @param {object} sponsoredLinks An object with sponsored links from all the partners.
   1808   * @returns {Array} An array of merged sponsored links.
   1809   */
   1810  _mergeSponsoredLinks(sponsoredLinks) {
   1811    const { positions: allocatedPositions, ready: sovReady } =
   1812      this.store.getState().TopSites.sov || {};
   1813    if (!this._contile.sov || !sovReady) {
   1814      return Object.values(sponsoredLinks).flat();
   1815    }
   1816 
   1817    // AMP links might have empty slots, remove them as SOV doesn't need those.
   1818    sponsoredLinks[SPONSORED_TILE_PARTNER_AMP] =
   1819      sponsoredLinks[SPONSORED_TILE_PARTNER_AMP].filter(Boolean);
   1820 
   1821    let sponsored = [];
   1822 
   1823    for (const allocation of allocatedPositions) {
   1824      let link = null;
   1825      const { assignedPartner } = allocation;
   1826      if (assignedPartner) {
   1827        const candidates = sponsoredLinks[assignedPartner] || [];
   1828        while (candidates.length) {
   1829          // Unknown partners are allowed so that new partners can be added to Shepherd
   1830          // sooner without waiting for client changes.
   1831          const candidate = candidates?.shift();
   1832          if (!candidate) {
   1833            continue;
   1834          }
   1835          const candLabel = candidate.label?.trim().toLowerCase();
   1836          // Deduplicate against sponsored links that have already been added.
   1837          if (candLabel) {
   1838            const duplicateSponsor = sponsored.some(
   1839              s => s.label?.trim().toLowerCase() === candLabel
   1840            );
   1841            if (duplicateSponsor) {
   1842              continue; // skip this candidate, try next
   1843            }
   1844          }
   1845          link = candidate;
   1846          break;
   1847        }
   1848      }
   1849 
   1850      if (!link) {
   1851        // If the chosen partner doesn't have a tile for this position, choose any
   1852        // one from another group. For simplicity, we do _not_ do resampling here
   1853        // against the remaining partners.
   1854        for (const partner of SPONSORED_TILE_PARTNERS) {
   1855          if (
   1856            partner === assignedPartner ||
   1857            sponsoredLinks[partner].length === 0
   1858          ) {
   1859            continue;
   1860          }
   1861          link = sponsoredLinks[partner].shift();
   1862          break;
   1863        }
   1864 
   1865        if (!link) {
   1866          // No more links to be added across all the partners, just return.
   1867          return sponsored;
   1868        }
   1869      }
   1870 
   1871      // Update the position fields. Note that postion is also 1-based in SOV.
   1872      link.sponsored_position = allocation.position;
   1873      if (link.pos !== undefined) {
   1874        // Pocket `pos` is 0-based.
   1875        link.pos = allocation.position - 1;
   1876      }
   1877      sponsored.push(link);
   1878    }
   1879 
   1880    // add the remaining contile sponsoredLinks when nimbus variable present
   1881    if (
   1882      lazy.NimbusFeatures.pocketNewtab.getVariable(
   1883        NIMBUS_VARIABLE_CONTILE_MAX_NUM_SPONSORED
   1884      )
   1885    ) {
   1886      return sponsored.concat(sponsoredLinks[SPONSORED_TILE_PARTNER_AMP]);
   1887    }
   1888 
   1889    return sponsored;
   1890  }
   1891 
   1892  /**
   1893   * Refresh the top sites data for content.
   1894   *
   1895   * @param {bool} options.broadcast Should the update be broadcasted.
   1896   * @param {bool} options.isStartup Being called while TopSitesFeed is initting.
   1897   */
   1898  async refresh(options = {}) {
   1899    if (!this._startedUp && !options.isStartup) {
   1900      // Initial refresh still pending.
   1901      return;
   1902    }
   1903    this._startedUp = true;
   1904 
   1905    if (!this._tippyTopProvider.initialized) {
   1906      await this._tippyTopProvider.init();
   1907    }
   1908 
   1909    const links = await this.getLinksWithDefaults({
   1910      isStartup: options.isStartup,
   1911    });
   1912    const newAction = { type: at.TOP_SITES_UPDATED, data: { links } };
   1913 
   1914    if (options.isStartup) {
   1915      newAction.meta = {
   1916        isStartup: true,
   1917      };
   1918    }
   1919 
   1920    if (options.broadcast) {
   1921      // Broadcast an update to all open content pages
   1922      this.store.dispatch(ac.BroadcastToContent(newAction));
   1923    } else {
   1924      // Don't broadcast only update the state and update the preloaded tab.
   1925      this.store.dispatch(ac.AlsoToPreloaded(newAction));
   1926    }
   1927  }
   1928 
   1929  // Allocate ad positions to partners based on SOV via stable randomization.
   1930  async allocatePositions() {
   1931    // If the fetch to get sov fails for whatever reason, we can just return here.
   1932    // Code that uses this falls back to flattening allocations instead if this has failed.
   1933    if (!this._contile.sov) {
   1934      return;
   1935    }
   1936 
   1937    // This sample input should ensure we return the same result for this allocation,
   1938    // even if called from other parts of the code.
   1939    let contextId = await lazy.ContextId.request();
   1940    const sampleInput = `${contextId}-${this._contile.sov.name}`;
   1941    const allocatedPositions = [];
   1942    for (const allocation of this._contile.sov.allocations) {
   1943      const allocatedPosition = {
   1944        position: allocation.position,
   1945      };
   1946      allocatedPositions.push(allocatedPosition);
   1947      const ratios = allocation.allocation.map(alloc => alloc.percentage);
   1948      if (ratios.length) {
   1949        const index = await lazy.Sampling.ratioSample(sampleInput, ratios);
   1950        allocatedPosition.assignedPartner =
   1951          allocation.allocation[index].partner;
   1952      }
   1953    }
   1954 
   1955    this.store.dispatch(
   1956      ac.OnlyToMain({
   1957        type: at.SOV_UPDATED,
   1958        data: {
   1959          ready: !!allocatedPositions.length,
   1960          positions: allocatedPositions,
   1961        },
   1962      })
   1963    );
   1964  }
   1965 
   1966  async updateCustomSearchShortcuts(isStartup = false) {
   1967    if (!this.store.getState().Prefs.values[SEARCH_SHORTCUTS_EXPERIMENT]) {
   1968      return;
   1969    }
   1970 
   1971    if (!this._tippyTopProvider.initialized) {
   1972      await this._tippyTopProvider.init();
   1973    }
   1974 
   1975    // Populate the state with available search shortcuts
   1976    let searchShortcuts = [];
   1977    for (const engine of await Services.search.getAppProvidedEngines()) {
   1978      const shortcut = CUSTOM_SEARCH_SHORTCUTS.find(s =>
   1979        engine.aliases.includes(s.keyword)
   1980      );
   1981      if (shortcut) {
   1982        let clone = { ...shortcut };
   1983        this._tippyTopProvider.processSite(clone);
   1984        searchShortcuts.push(clone);
   1985      }
   1986    }
   1987 
   1988    this.store.dispatch(
   1989      ac.BroadcastToContent({
   1990        type: at.UPDATE_SEARCH_SHORTCUTS,
   1991        data: { searchShortcuts },
   1992        meta: {
   1993          isStartup,
   1994        },
   1995      })
   1996    );
   1997  }
   1998 
   1999  async topSiteToSearchTopSite(site) {
   2000    const searchProvider = getSearchProvider(lazy.NewTabUtils.shortURL(site));
   2001    if (
   2002      !searchProvider ||
   2003      !(await checkHasSearchEngine(searchProvider.keyword))
   2004    ) {
   2005      return site;
   2006    }
   2007    return {
   2008      ...site,
   2009      searchTopSite: true,
   2010      label: searchProvider.keyword,
   2011    };
   2012  }
   2013 
   2014  /**
   2015   * Get an image for the link preferring tippy top, rich favicon, screenshots.
   2016   */
   2017  async _fetchIcon(link, isStartup = false) {
   2018    // Nothing to do if we already have a rich icon from the page
   2019    if (link.favicon && link.faviconSize >= MIN_FAVICON_SIZE) {
   2020      return;
   2021    }
   2022 
   2023    // Nothing more to do if we can use a default tippy top icon
   2024    this._tippyTopProvider.processSite(link);
   2025    if (link.tippyTopIcon) {
   2026      return;
   2027    }
   2028 
   2029    // Make a request for a better icon
   2030    this._requestRichIcon(link.url);
   2031 
   2032    // Also request a screenshot if we don't have one yet
   2033    await this._fetchScreenshot(link, link.url, isStartup);
   2034  }
   2035 
   2036  /**
   2037   * Fetch, cache and broadcast a screenshot for a specific topsite.
   2038   *
   2039   * @param link cached topsite object
   2040   * @param url where to fetch the image from
   2041   * @param isStartup Whether the screenshot is fetched while initting TopSitesFeed.
   2042   */
   2043  async _fetchScreenshot(link, url, isStartup = false) {
   2044    // We shouldn't bother caching screenshots if they won't be shown.
   2045    if (
   2046      link.screenshot ||
   2047      !this.store.getState().Prefs.values[SHOWN_ON_NEWTAB_PREF]
   2048    ) {
   2049      return;
   2050    }
   2051    await lazy.Screenshots.maybeCacheScreenshot(
   2052      link,
   2053      url,
   2054      "screenshot",
   2055      screenshot =>
   2056        this.store.dispatch(
   2057          ac.BroadcastToContent({
   2058            data: { screenshot, url: link.url },
   2059            type: at.SCREENSHOT_UPDATED,
   2060            meta: {
   2061              isStartup,
   2062            },
   2063          })
   2064        )
   2065    );
   2066  }
   2067 
   2068  /**
   2069   * Dispatch screenshot preview to target or notify if request failed.
   2070   *
   2071   * @param customScreenshotURL {string} The URL used to capture the screenshot
   2072   * @param target {string} Id of content process where to dispatch the result
   2073   */
   2074  async getScreenshotPreview(url, target) {
   2075    const preview = (await lazy.Screenshots.getScreenshotForURL(url)) || "";
   2076    this.store.dispatch(
   2077      ac.OnlyToOneContent(
   2078        {
   2079          data: { url, preview },
   2080          type: at.PREVIEW_RESPONSE,
   2081        },
   2082        target
   2083      )
   2084    );
   2085  }
   2086 
   2087  _requestRichIcon(url) {
   2088    this.store.dispatch({
   2089      type: at.RICH_ICON_MISSING,
   2090      data: { url },
   2091    });
   2092  }
   2093 
   2094  /**
   2095   * Inform others that top sites data has been updated due to pinned changes.
   2096   */
   2097  _broadcastPinnedSitesUpdated() {
   2098    // Pinned data changed, so make sure we get latest
   2099    this.pinnedCache.expire();
   2100 
   2101    // Refresh to update pinned sites with screenshots, trigger deduping, etc.
   2102    this.refresh({ broadcast: true });
   2103  }
   2104 
   2105  /**
   2106   * Pin a site at a specific position saving only the desired keys.
   2107   *
   2108   * @param customScreenshotURL {string} User set URL of preview image for site
   2109   * @param label {string} User set string of custom site name
   2110   */
   2111  async _pinSiteAt({ customScreenshotURL, label, url, searchTopSite }, index) {
   2112    const toPin = { url };
   2113    if (label) {
   2114      toPin.label = label;
   2115    }
   2116    if (customScreenshotURL) {
   2117      toPin.customScreenshotURL = customScreenshotURL;
   2118    }
   2119    if (searchTopSite) {
   2120      toPin.searchTopSite = searchTopSite;
   2121    }
   2122    lazy.NewTabUtils.pinnedLinks.pin(toPin, index);
   2123 
   2124    await this._clearLinkCustomScreenshot({ customScreenshotURL, url });
   2125  }
   2126 
   2127  async _clearLinkCustomScreenshot(site) {
   2128    // If screenshot url changed or was removed we need to update the cached link obj
   2129    if (site.customScreenshotURL !== undefined) {
   2130      const pinned = await this.pinnedCache.request();
   2131      const link = pinned.find(pin => pin && pin.url === site.url);
   2132      if (link && link.customScreenshotURL !== site.customScreenshotURL) {
   2133        link.__sharedCache.updateLink("screenshot", undefined);
   2134      }
   2135    }
   2136  }
   2137 
   2138  /**
   2139   * Handle a pin action of a site to a position.
   2140   */
   2141  async pin(action) {
   2142    let { site, index } = action.data;
   2143    index = this._adjustPinIndexForSponsoredLinks(site, index);
   2144    // If valid index provided, pin at that position
   2145    if (index >= 0) {
   2146      await this._pinSiteAt(site, index);
   2147      this._broadcastPinnedSitesUpdated();
   2148    } else {
   2149      // Bug 1458658. If the top site is being pinned from an 'Add a Top Site' option,
   2150      // then we want to make sure to unblock that link if it has previously been
   2151      // blocked. We know if the site has been added because the index will be -1.
   2152      if (index === -1) {
   2153        lazy.NewTabUtils.blockedLinks.unblock({ url: site.url });
   2154        this.frecentCache.expire();
   2155      }
   2156      this.insert(action);
   2157    }
   2158  }
   2159 
   2160  /**
   2161   * Handle an unpin action of a site.
   2162   */
   2163  unpin(action) {
   2164    const { site } = action.data;
   2165    lazy.NewTabUtils.pinnedLinks.unpin(site);
   2166    this._broadcastPinnedSitesUpdated();
   2167  }
   2168 
   2169  unpinAllSearchShortcuts() {
   2170    Services.prefs.clearUserPref(
   2171      `browser.newtabpage.activity-stream.${SEARCH_SHORTCUTS_HAVE_PINNED_PREF}`
   2172    );
   2173    for (let pinnedLink of lazy.NewTabUtils.pinnedLinks.links) {
   2174      if (pinnedLink && pinnedLink.searchTopSite) {
   2175        lazy.NewTabUtils.pinnedLinks.unpin(pinnedLink);
   2176      }
   2177    }
   2178    this.pinnedCache.expire();
   2179  }
   2180 
   2181  _unpinSearchShortcut(vendor) {
   2182    for (let pinnedLink of lazy.NewTabUtils.pinnedLinks.links) {
   2183      if (
   2184        pinnedLink &&
   2185        pinnedLink.searchTopSite &&
   2186        lazy.NewTabUtils.shortURL(pinnedLink) === vendor
   2187      ) {
   2188        lazy.NewTabUtils.pinnedLinks.unpin(pinnedLink);
   2189        this.pinnedCache.expire();
   2190 
   2191        const prevInsertedShortcuts = this.store
   2192          .getState()
   2193          .Prefs.values[SEARCH_SHORTCUTS_HAVE_PINNED_PREF].split(",");
   2194        this.store.dispatch(
   2195          ac.SetPref(
   2196            SEARCH_SHORTCUTS_HAVE_PINNED_PREF,
   2197            prevInsertedShortcuts.filter(s => s !== vendor).join(",")
   2198          )
   2199        );
   2200        break;
   2201      }
   2202    }
   2203  }
   2204 
   2205  /**
   2206   * Reduces the given pinning index by the number of preceding sponsored
   2207   * sites, to accomodate for sponsored sites pushing pinned ones to the side,
   2208   * effectively increasing their index again.
   2209   */
   2210  _adjustPinIndexForSponsoredLinks(site, index) {
   2211    if (!this._linksWithDefaults) {
   2212      return index;
   2213    }
   2214    // Adjust insertion index for sponsored sites since their position is
   2215    // fixed.
   2216    let adjustedIndex = index;
   2217    for (let i = 0; i < index; i++) {
   2218      const link = this._linksWithDefaults[i];
   2219      if (
   2220        link &&
   2221        link.sponsored_position &&
   2222        this._linksWithDefaults[i]?.url !== site.url
   2223      ) {
   2224        adjustedIndex--;
   2225      }
   2226    }
   2227    return adjustedIndex;
   2228  }
   2229 
   2230  /**
   2231   * Insert a site to pin at a position shifting over any other pinned sites.
   2232   */
   2233  _insertPin(site, originalIndex, draggedFromIndex) {
   2234    let index = this._adjustPinIndexForSponsoredLinks(site, originalIndex);
   2235 
   2236    // Don't insert any pins past the end of the visible top sites. Otherwise,
   2237    // we can end up with a bunch of pinned sites that can never be unpinned again
   2238    // from the UI.
   2239    const topSitesCount =
   2240      this.store.getState().Prefs.values[ROWS_PREF] *
   2241      TOP_SITES_MAX_SITES_PER_ROW;
   2242    if (index >= topSitesCount) {
   2243      return;
   2244    }
   2245 
   2246    let pinned = lazy.NewTabUtils.pinnedLinks.links;
   2247    if (!pinned[index]) {
   2248      this._pinSiteAt(site, index);
   2249    } else {
   2250      pinned[draggedFromIndex] = null;
   2251      // Find the hole to shift the pinned site(s) towards. We shift towards the
   2252      // hole left by the site being dragged.
   2253      let holeIndex = index;
   2254      const indexStep = index > draggedFromIndex ? -1 : 1;
   2255      while (pinned[holeIndex]) {
   2256        holeIndex += indexStep;
   2257      }
   2258      if (holeIndex >= topSitesCount || holeIndex < 0) {
   2259        // There are no holes, so we will effectively unpin the last slot and shifting
   2260        // towards it. This only happens when adding a new top site to an already
   2261        // fully pinned grid.
   2262        holeIndex = topSitesCount - 1;
   2263      }
   2264 
   2265      // Shift towards the hole.
   2266      const shiftingStep = holeIndex > index ? -1 : 1;
   2267      while (holeIndex !== index) {
   2268        const nextIndex = holeIndex + shiftingStep;
   2269        this._pinSiteAt(pinned[nextIndex], holeIndex);
   2270        holeIndex = nextIndex;
   2271      }
   2272      this._pinSiteAt(site, index);
   2273    }
   2274  }
   2275 
   2276  /**
   2277   * Handle an insert (drop/add) action of a site.
   2278   */
   2279  async insert(action) {
   2280    let { index } = action.data;
   2281    // Treat invalid pin index values (e.g., -1, undefined) as insert in the first position
   2282    if (!(index > 0)) {
   2283      index = 0;
   2284    }
   2285 
   2286    // Inserting a top site pins it in the specified slot, pushing over any link already
   2287    // pinned in the slot (unless it's the last slot, then it replaces).
   2288    this._insertPin(
   2289      action.data.site,
   2290      index,
   2291      action.data.draggedFromIndex !== undefined
   2292        ? action.data.draggedFromIndex
   2293        : this.store.getState().Prefs.values[ROWS_PREF] *
   2294            TOP_SITES_MAX_SITES_PER_ROW
   2295    );
   2296 
   2297    await this._clearLinkCustomScreenshot(action.data.site);
   2298    this._broadcastPinnedSitesUpdated();
   2299  }
   2300 
   2301  updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }) {
   2302    // Unpin the deletedShortcuts.
   2303    deletedShortcuts.forEach(({ url }) => {
   2304      lazy.NewTabUtils.pinnedLinks.unpin({ url });
   2305    });
   2306 
   2307    // Pin the addedShortcuts.
   2308    const numberOfSlots =
   2309      this.store.getState().Prefs.values[ROWS_PREF] *
   2310      TOP_SITES_MAX_SITES_PER_ROW;
   2311    addedShortcuts.forEach(shortcut => {
   2312      // Find first hole in pinnedLinks.
   2313      let index = lazy.NewTabUtils.pinnedLinks.links.findIndex(link => !link);
   2314      if (
   2315        index < 0 &&
   2316        lazy.NewTabUtils.pinnedLinks.links.length + 1 < numberOfSlots
   2317      ) {
   2318        // pinnedLinks can have less slots than the total available.
   2319        index = lazy.NewTabUtils.pinnedLinks.links.length;
   2320      }
   2321      if (index >= 0) {
   2322        lazy.NewTabUtils.pinnedLinks.pin(shortcut, index);
   2323      } else {
   2324        // No slots available, we need to do an insert in first slot and push over other pinned links.
   2325        this._insertPin(shortcut, 0, numberOfSlots);
   2326      }
   2327    });
   2328 
   2329    this._broadcastPinnedSitesUpdated();
   2330  }
   2331 
   2332  onAction(action) {
   2333    switch (action.type) {
   2334      case at.INIT:
   2335        this.init();
   2336        this.updateCustomSearchShortcuts(true /* isStartup */);
   2337        break;
   2338      case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK:
   2339      case at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE:
   2340      case at.SYSTEM_TICK:
   2341        this.refresh({ broadcast: false });
   2342        this._contile.periodicUpdate();
   2343        // We don't need to await on this,
   2344        // we can let this update in the background.
   2345        void this.updateFrecencyBoostedSpocs();
   2346        break;
   2347      // All these actions mean we need new top sites
   2348      case at.PLACES_HISTORY_CLEARED:
   2349      case at.PLACES_LINKS_DELETED:
   2350        this.frecentCache.expire();
   2351        this.refresh({ broadcast: true });
   2352        break;
   2353      case at.PLACES_LINKS_CHANGED:
   2354        this.frecentCache.expire();
   2355        this.refresh({ broadcast: false });
   2356        break;
   2357      case at.PLACES_LINK_BLOCKED:
   2358        this.frecentCache.expire();
   2359        this.pinnedCache.expire();
   2360        this.refresh({ broadcast: true });
   2361        break;
   2362      case at.PREF_CHANGED:
   2363        switch (action.data.name) {
   2364          case DEFAULT_SITES_PREF:
   2365            if (!this._useRemoteSetting) {
   2366              this.refreshDefaults(action.data.value);
   2367            }
   2368            break;
   2369          case ROWS_PREF:
   2370          case FILTER_DEFAULT_SEARCH_PREF:
   2371          case SEARCH_SHORTCUTS_SEARCH_ENGINES_PREF:
   2372            this.refresh({ broadcast: true });
   2373            break;
   2374          case SHOW_SPONSORED_PREF:
   2375          case PREF_UNIFIED_ADS_TILES_ENABLED:
   2376            if (
   2377              lazy.NimbusFeatures.newtab.getVariable(
   2378                NIMBUS_VARIABLE_CONTILE_ENABLED
   2379              )
   2380            ) {
   2381              this._contile.refresh();
   2382            } else {
   2383              this.refresh({ broadcast: true });
   2384            }
   2385            if (!action.data.value) {
   2386              this._contile._resetContileCache();
   2387            }
   2388 
   2389            break;
   2390          case SEARCH_SHORTCUTS_EXPERIMENT:
   2391            if (action.data.value) {
   2392              this.updateCustomSearchShortcuts();
   2393            } else {
   2394              this.unpinAllSearchShortcuts();
   2395            }
   2396            this.refresh({ broadcast: true });
   2397            break;
   2398          case PREF_UNIFIED_ADS_ADSFEED_ENABLED:
   2399            this._contile.refresh();
   2400            break;
   2401        }
   2402        break;
   2403      case at.PREFS_INITIAL_VALUES:
   2404        if (!this._useRemoteSetting) {
   2405          this.refreshDefaults(action.data[DEFAULT_SITES_PREF]);
   2406        }
   2407        break;
   2408      case at.TOP_SITES_PIN:
   2409        this.pin(action);
   2410        break;
   2411      case at.TOP_SITES_UNPIN:
   2412        this.unpin(action);
   2413        break;
   2414      case at.TOP_SITES_INSERT:
   2415        this.insert(action);
   2416        break;
   2417      case at.PREVIEW_REQUEST:
   2418        this.getScreenshotPreview(action.data.url, action.meta.fromTarget);
   2419        break;
   2420      case at.UPDATE_PINNED_SEARCH_SHORTCUTS:
   2421        this.updatePinnedSearchShortcuts(action.data);
   2422        break;
   2423      case at.ADS_UPDATE_TILES:
   2424        this._contile.refresh();
   2425        break;
   2426      case at.UNINIT:
   2427        this.uninit();
   2428        break;
   2429    }
   2430  }
   2431 }