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 }