TopSites.sys.mjs (43868B)
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 // eslint-disable-next-line mozilla/use-static-import 5 const { AppConstants } = ChromeUtils.importESModule( 6 "resource://gre/modules/AppConstants.sys.mjs" 7 ); 8 9 import { 10 getDomain, 11 TippyTopProvider, 12 } from "resource:///modules/topsites/TippyTopProvider.sys.mjs"; 13 import { Dedupe } from "resource:///modules/Dedupe.sys.mjs"; 14 import { TOP_SITES_MAX_SITES_PER_ROW } from "resource:///modules/topsites/constants.mjs"; 15 import { 16 CUSTOM_SEARCH_SHORTCUTS, 17 checkHasSearchEngine, 18 getSearchProvider, 19 } from "moz-src:///toolkit/components/search/SearchShortcuts.sys.mjs"; 20 21 const lazy = {}; 22 23 ChromeUtils.defineESModuleGetters(lazy, { 24 FilterAdult: "resource:///modules/FilterAdult.sys.mjs", 25 LinksCache: "resource:///modules/LinksCache.sys.mjs", 26 NetUtil: "resource://gre/modules/NetUtil.sys.mjs", 27 NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", 28 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 29 Region: "resource://gre/modules/Region.sys.mjs", 30 RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", 31 }); 32 33 ChromeUtils.defineLazyGetter(lazy, "log", () => { 34 const { Logger } = ChromeUtils.importESModule( 35 "resource://messaging-system/lib/Logger.sys.mjs" 36 ); 37 return new Logger("TopSites"); 38 }); 39 40 ChromeUtils.defineLazyGetter(lazy, "pageFrecencyThreshold", () => { 41 // @backward-compat { version 147 } 42 // Frecency was changed in 147 Nightly. This is a pre-cautionary measure 43 // for train-hopping. 44 if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "147.0a1") >= 0) { 45 // 30 days ago, 5 visits. The threshold avoids one non-typed visit from 46 // immediately being included in recent history to mimic the original 47 // threshold which aimed to prevent first-run visits from being included in 48 // Top Sites. 49 return lazy.PlacesUtils.history.pageFrecencyThreshold(30, 5, false); 50 } 51 // The old threshold used for classic frecency: Slightly over one visit. 52 return 101; 53 }); 54 55 export const DEFAULT_TOP_SITES = []; 56 57 const MIN_FAVICON_SIZE = 96; 58 const PINNED_FAVICON_PROPS_TO_MIGRATE = [ 59 "favicon", 60 "faviconRef", 61 "faviconSize", 62 ]; 63 64 // Preferences 65 const NO_DEFAULT_SEARCH_TILE_PREF = 66 "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile"; 67 const SEARCH_SHORTCUTS_HAVE_PINNED_PREF = 68 "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts.havePinned"; 69 // TODO: Rename this when re-subscribing to the search engines pref. 70 const SEARCH_SHORTCUTS_ENGINES = 71 "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts.searchEngines"; 72 const TOP_SITE_SEARCH_SHORTCUTS_PREF = 73 "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts"; 74 const TOP_SITES_ROWS_PREF = "browser.newtabpage.activity-stream.topSitesRows"; 75 76 // Search experiment stuff 77 const SEARCH_FILTERS = [ 78 "google", 79 "search.yahoo", 80 "yahoo", 81 "bing", 82 "ask", 83 "duckduckgo", 84 ]; 85 86 const REMOTE_SETTING_DEFAULTS_PREF = "browser.topsites.useRemoteSetting"; 87 const DEFAULT_SITES_OVERRIDE_PREF = 88 "browser.newtabpage.activity-stream.default.sites"; 89 const DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH = "browser.topsites.experiment."; 90 91 function getShortHostnameForCurrentSearch() { 92 const url = lazy.NewTabUtils.shortHostname( 93 Services.search.defaultEngine.searchUrlDomain 94 ); 95 return url; 96 } 97 98 class _TopSites { 99 #hasObservers = false; 100 /** 101 * A Promise used to determine if initialization is complete. 102 * 103 * @type {Promise} 104 */ 105 #initPromise = null; 106 #searchShortcuts = []; 107 #sites = []; 108 109 constructor() { 110 this._tippyTopProvider = new TippyTopProvider(); 111 ChromeUtils.defineLazyGetter( 112 this, 113 "_currentSearchHostname", 114 getShortHostnameForCurrentSearch 115 ); 116 this.dedupe = new Dedupe(this._dedupeKey); 117 this.frecentCache = new lazy.LinksCache( 118 lazy.NewTabUtils.activityStreamLinks, 119 "getTopSites", 120 [], 121 (oldOptions, newOptions) => 122 // Refresh if no old options or requesting more items 123 !(oldOptions.numItems >= newOptions.numItems) 124 ); 125 this.pinnedCache = new lazy.LinksCache( 126 lazy.NewTabUtils.pinnedLinks, 127 "links", 128 [...PINNED_FAVICON_PROPS_TO_MIGRATE] 129 ); 130 this._faviconProvider = new FaviconProvider(); 131 this.handlePlacesEvents = this.handlePlacesEvents.bind(this); 132 } 133 134 /** 135 * Initializes the TopSites module. 136 * 137 * @returns {Promise} 138 */ 139 async init() { 140 if (this.#initPromise) { 141 return this.#initPromise; 142 } 143 this.#initPromise = (async () => { 144 lazy.log.debug("Initializing TopSites."); 145 this.#addObservers(); 146 await this._readDefaults({ isStartup: true }); 147 // TopSites was initialized by the store calling the initialization 148 // function and then updating custom search shortcuts. Since 149 // initialization now happens upon the first retrieval of sites, we move 150 // the update custom search shortcuts here. 151 await this.updateCustomSearchShortcuts(true); 152 })(); 153 return this.#initPromise; 154 } 155 156 uninit() { 157 lazy.log.debug("Un-initializing TopSites."); 158 this.#removeObservers(); 159 this.#searchShortcuts = []; 160 this.#sites = []; 161 this.#initPromise = null; 162 this.frecentCache.expire(); 163 this.pinnedCache.expire(); 164 } 165 166 #addObservers() { 167 if (this.#hasObservers) { 168 return; 169 } 170 // If the feed was previously disabled PREFS_INITIAL_VALUES was never received 171 Services.obs.addObserver(this, "browser-search-engine-modified"); 172 Services.obs.addObserver(this, "browser-region-updated"); 173 Services.obs.addObserver(this, "newtab-linkBlocked"); 174 Services.prefs.addObserver(REMOTE_SETTING_DEFAULTS_PREF, this); 175 Services.prefs.addObserver(DEFAULT_SITES_OVERRIDE_PREF, this); 176 Services.prefs.addObserver(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH, this); 177 Services.prefs.addObserver(NO_DEFAULT_SEARCH_TILE_PREF, this); 178 Services.prefs.addObserver(SEARCH_SHORTCUTS_ENGINES, this); 179 Services.prefs.addObserver(TOP_SITES_ROWS_PREF, this); 180 Services.prefs.addObserver(TOP_SITE_SEARCH_SHORTCUTS_PREF, this); 181 lazy.PlacesUtils.observers.addListener( 182 ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"], 183 this.handlePlacesEvents 184 ); 185 this.#hasObservers = true; 186 } 187 188 #removeObservers() { 189 if (!this.#hasObservers) { 190 return; 191 } 192 Services.obs.removeObserver(this, "browser-search-engine-modified"); 193 Services.obs.removeObserver(this, "browser-region-updated"); 194 Services.obs.removeObserver(this, "newtab-linkBlocked"); 195 Services.prefs.removeObserver(REMOTE_SETTING_DEFAULTS_PREF, this); 196 Services.prefs.removeObserver(DEFAULT_SITES_OVERRIDE_PREF, this); 197 Services.prefs.removeObserver(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH, this); 198 Services.prefs.removeObserver(NO_DEFAULT_SEARCH_TILE_PREF, this); 199 Services.prefs.removeObserver(SEARCH_SHORTCUTS_ENGINES, this); 200 Services.prefs.removeObserver(TOP_SITES_ROWS_PREF, this); 201 Services.prefs.removeObserver(TOP_SITE_SEARCH_SHORTCUTS_PREF, this); 202 lazy.PlacesUtils.observers.removeListener( 203 ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"], 204 this.handlePlacesEvents 205 ); 206 this.#hasObservers = false; 207 } 208 209 _reset() { 210 // Allow automated tests to reset the internal state of the component. 211 if (Cu.isInAutomation) { 212 this.#searchShortcuts = []; 213 this.#sites = []; 214 } 215 } 216 217 observe(subj, topic, data) { 218 switch (topic) { 219 case "browser-search-engine-modified": 220 // We should update the current top sites if the search engine has been changed since 221 // the search engine that gets filtered out of top sites has changed. 222 // We also need to drop search shortcuts when their engine gets removed / hidden. 223 if ( 224 data === "engine-default" && 225 Services.prefs.getBoolPref(NO_DEFAULT_SEARCH_TILE_PREF, true) 226 ) { 227 delete this._currentSearchHostname; 228 this._currentSearchHostname = getShortHostnameForCurrentSearch(); 229 } 230 this.refresh({ broadcast: true }); 231 break; 232 case "browser-region-updated": 233 this._readDefaults(); 234 break; 235 case "newtab-linkBlocked": 236 this.frecentCache.expire(); 237 this.pinnedCache.expire(); 238 this.refresh(); 239 break; 240 case "nsPref:changed": 241 switch (data) { 242 case DEFAULT_SITES_OVERRIDE_PREF: 243 case REMOTE_SETTING_DEFAULTS_PREF: 244 this._readDefaults(); 245 break; 246 case NO_DEFAULT_SEARCH_TILE_PREF: 247 this.refresh(); 248 break; 249 case TOP_SITES_ROWS_PREF: 250 case SEARCH_SHORTCUTS_ENGINES: 251 this.refresh(); 252 break; 253 case TOP_SITE_SEARCH_SHORTCUTS_PREF: 254 if (Services.prefs.getBoolPref(TOP_SITE_SEARCH_SHORTCUTS_PREF)) { 255 this.updateCustomSearchShortcuts(); 256 } else { 257 this.unpinAllSearchShortcuts(); 258 } 259 this.refresh(); 260 break; 261 default: 262 if (data.startsWith(DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH)) { 263 this._readDefaults(); 264 } 265 break; 266 } 267 break; 268 } 269 } 270 271 handlePlacesEvents(events) { 272 for (const { 273 itemType, 274 source, 275 url, 276 isRemovedFromStore, 277 isTagging, 278 type, 279 } of events) { 280 switch (type) { 281 case "history-cleared": 282 this.frecentCache.expire(); 283 this.refresh(); 284 break; 285 case "page-removed": 286 if (isRemovedFromStore) { 287 this.frecentCache.expire(); 288 this.refresh(); 289 } 290 break; 291 case "bookmark-added": 292 // Skips items that are not bookmarks (like folders), about:* pages or 293 // default bookmarks, added when the profile is created. 294 if ( 295 isTagging || 296 itemType !== lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK || 297 source === lazy.PlacesUtils.bookmarks.SOURCES.IMPORT || 298 source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE || 299 source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP || 300 source === lazy.PlacesUtils.bookmarks.SOURCES.SYNC || 301 (!url.startsWith("http://") && !url.startsWith("https://")) 302 ) { 303 return; 304 } 305 306 // TODO: Add a timed delay in case many links are changed. 307 this.frecentCache.expire(); 308 this.refresh(); 309 break; 310 case "bookmark-removed": 311 if ( 312 isTagging || 313 (itemType === lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK && 314 source !== lazy.PlacesUtils.bookmarks.SOURCES.IMPORT && 315 source !== lazy.PlacesUtils.bookmarks.SOURCES.RESTORE && 316 source !== 317 lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP && 318 source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC) 319 ) { 320 // TODO: Add a timed delay in case many links are changed. 321 this.frecentCache.expire(); 322 this.refresh(); 323 } 324 break; 325 } 326 } 327 } 328 329 /** 330 * Returns a copied version of non-sponsored Top Sites. It will initialize 331 * the component if it hasn't been already in order to set up and cache the 332 * list, which will include pinned sites and search shortcuts. The number of 333 * Top Sites returned is based on the number shown on New Tab due to the fact 334 * it is the interface in which sites can be pinned/removed. 335 * 336 * @returns {Array<object>} 337 * A list of Top Sites. 338 */ 339 async getSites() { 340 await this.init(); 341 return structuredClone(this.#sites); 342 } 343 344 async getSearchShortcuts() { 345 await this.init(); 346 return structuredClone(this.#searchShortcuts); 347 } 348 349 _dedupeKey(site) { 350 return site && site.hostname; 351 } 352 353 /** 354 * _readDefaults - sets DEFAULT_TOP_SITES 355 */ 356 async _readDefaults({ isStartup = false } = {}) { 357 this._useRemoteSetting = false; 358 359 if (!Services.prefs.getBoolPref(REMOTE_SETTING_DEFAULTS_PREF)) { 360 let sites = Services.prefs.getStringPref(DEFAULT_SITES_OVERRIDE_PREF, ""); 361 await this.refreshDefaults(sites, { isStartup }); 362 return; 363 } 364 365 // Try using default top sites from enterprise policies or tests. The pref 366 // is locked when set via enterprise policy. Tests have no default sites 367 // unless they set them via this pref. 368 if ( 369 Services.prefs.prefIsLocked(DEFAULT_SITES_OVERRIDE_PREF) || 370 Cu.isInAutomation 371 ) { 372 let sites = Services.prefs.getStringPref(DEFAULT_SITES_OVERRIDE_PREF, ""); 373 await this.refreshDefaults(sites, { isStartup }); 374 return; 375 } 376 377 // Clear out the array of any previous defaults. 378 DEFAULT_TOP_SITES.length = 0; 379 380 // Read defaults from remote settings. 381 this._useRemoteSetting = true; 382 let remoteSettingData = await this._getRemoteConfig(); 383 384 for (let siteData of remoteSettingData) { 385 let hostname = lazy.NewTabUtils.shortURL(siteData); 386 let link = { 387 isDefault: true, 388 url: siteData.url, 389 hostname, 390 sendAttributionRequest: !!siteData.send_attribution_request, 391 }; 392 if (siteData.url_urlbar_override) { 393 link.url_urlbar = siteData.url_urlbar_override; 394 } 395 if (siteData.title) { 396 link.label = siteData.title; 397 } 398 if (siteData.search_shortcut) { 399 link = await this.topSiteToSearchTopSite(link); 400 } 401 DEFAULT_TOP_SITES.push(link); 402 } 403 404 await this.refresh({ isStartup }); 405 } 406 407 async refreshDefaults(sites, { isStartup = false } = {}) { 408 // Clear out the array of any previous defaults 409 DEFAULT_TOP_SITES.length = 0; 410 411 // Add default sites if any based on the pref 412 if (sites) { 413 for (const url of sites.split(",")) { 414 const site = { 415 isDefault: true, 416 url, 417 }; 418 site.hostname = lazy.NewTabUtils.shortURL(site); 419 DEFAULT_TOP_SITES.push(site); 420 } 421 } 422 423 await this.refresh({ isStartup }); 424 } 425 426 async _getRemoteConfig(firstTime = true) { 427 if (!this._remoteConfig) { 428 this._remoteConfig = await lazy.RemoteSettings("top-sites"); 429 this._remoteConfig.on("sync", () => { 430 this._readDefaults(); 431 }); 432 } 433 434 let result = []; 435 let failed = false; 436 try { 437 result = await this._remoteConfig.get(); 438 } catch (ex) { 439 console.error(ex); 440 failed = true; 441 } 442 if (!result.length) { 443 console.error("Received empty top sites configuration!"); 444 failed = true; 445 } 446 // If we failed, or the result is empty, try loading from the local dump. 447 if (firstTime && failed) { 448 await this._remoteConfig.db.clear(); 449 // Now call this again. 450 return this._getRemoteConfig(false); 451 } 452 453 // Sort sites based on the "order" attribute. 454 result.sort((a, b) => a.order - b.order); 455 456 result = result.filter(topsite => { 457 // Filter by region. 458 if (topsite.exclude_regions?.includes(lazy.Region.home)) { 459 return false; 460 } 461 if ( 462 topsite.include_regions?.length && 463 !topsite.include_regions.includes(lazy.Region.home) 464 ) { 465 return false; 466 } 467 468 // Filter by locale. 469 if (topsite.exclude_locales?.includes(Services.locale.appLocaleAsBCP47)) { 470 return false; 471 } 472 if ( 473 topsite.include_locales?.length && 474 !topsite.include_locales.includes(Services.locale.appLocaleAsBCP47) 475 ) { 476 return false; 477 } 478 479 // Filter by experiment. 480 // Exclude this top site if any of the specified experiments are running. 481 if ( 482 topsite.exclude_experiments?.some(experimentID => 483 Services.prefs.getBoolPref( 484 DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH + experimentID, 485 false 486 ) 487 ) 488 ) { 489 return false; 490 } 491 // Exclude this top site if none of the specified experiments are running. 492 if ( 493 topsite.include_experiments?.length && 494 topsite.include_experiments.every( 495 experimentID => 496 !Services.prefs.getBoolPref( 497 DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH + experimentID, 498 false 499 ) 500 ) 501 ) { 502 return false; 503 } 504 505 return true; 506 }); 507 508 return result; 509 } 510 511 /** 512 * shouldFilterSearchTile - is default filtering enabled and does a given hostname match the user's default search engine? 513 * 514 * @param {string} hostname a top site hostname, such as "amazon" or "foo" 515 * @returns {bool} 516 */ 517 shouldFilterSearchTile(hostname) { 518 if ( 519 Services.prefs.getBoolPref(NO_DEFAULT_SEARCH_TILE_PREF, true) && 520 (SEARCH_FILTERS.includes(hostname) || 521 hostname === this._currentSearchHostname) 522 ) { 523 return true; 524 } 525 return false; 526 } 527 528 /** 529 * _maybeInsertSearchShortcuts - if the search shortcuts experiment is running, 530 * insert search shortcuts if needed 531 * 532 * @param {Array} plainPinnedSites (from the pinnedSitesCache) 533 * @returns {boolean} Did we insert any search shortcuts? 534 */ 535 async _maybeInsertSearchShortcuts(plainPinnedSites) { 536 // Only insert shortcuts if the experiment is running 537 if (Services.prefs.getBoolPref(TOP_SITE_SEARCH_SHORTCUTS_PREF, true)) { 538 // We don't want to insert shortcuts we've previously inserted 539 const prevInsertedShortcuts = Services.prefs 540 .getStringPref(SEARCH_SHORTCUTS_HAVE_PINNED_PREF, "") 541 .split(",") 542 .filter(s => s); // Filter out empty strings 543 const newInsertedShortcuts = []; 544 545 let shouldPin = this._useRemoteSetting 546 ? DEFAULT_TOP_SITES.filter(s => s.searchTopSite).map(s => s.hostname) 547 : Services.prefs.getStringPref(SEARCH_SHORTCUTS_ENGINES, "").split(","); 548 shouldPin = shouldPin 549 .map(getSearchProvider) 550 .filter(s => s && s.shortURL !== this._currentSearchHostname); 551 552 // If we've previously inserted all search shortcuts return early 553 if ( 554 shouldPin.every(shortcut => 555 prevInsertedShortcuts.includes(shortcut.shortURL) 556 ) 557 ) { 558 return false; 559 } 560 561 const numberOfSlots = 562 Services.prefs.getIntPref(TOP_SITES_ROWS_PREF, 1) * 563 TOP_SITES_MAX_SITES_PER_ROW; 564 565 // The plainPinnedSites array is populated with pinned sites at their 566 // respective indices, and null everywhere else, but is not always the 567 // right length 568 const emptySlots = Math.max(numberOfSlots - plainPinnedSites.length, 0); 569 const pinnedSites = [...plainPinnedSites].concat( 570 Array(emptySlots).fill(null) 571 ); 572 573 const tryToInsertSearchShortcut = async shortcut => { 574 const nextAvailable = pinnedSites.indexOf(null); 575 // Only add a search shortcut if the site isn't already pinned, we 576 // haven't previously inserted it, there's space to pin it, and the 577 // search engine is available in Firefox 578 if ( 579 !pinnedSites.find( 580 s => s && lazy.NewTabUtils.shortURL(s) === shortcut.shortURL 581 ) && 582 !prevInsertedShortcuts.includes(shortcut.shortURL) && 583 nextAvailable > -1 && 584 (await checkHasSearchEngine(shortcut.keyword)) 585 ) { 586 const site = await this.topSiteToSearchTopSite({ url: shortcut.url }); 587 this._pinSiteAt(site, nextAvailable); 588 pinnedSites[nextAvailable] = site; 589 newInsertedShortcuts.push(shortcut.shortURL); 590 } 591 }; 592 593 for (let shortcut of shouldPin) { 594 await tryToInsertSearchShortcut(shortcut); 595 } 596 597 if (newInsertedShortcuts.length) { 598 Services.prefs.setStringPref( 599 SEARCH_SHORTCUTS_HAVE_PINNED_PREF, 600 prevInsertedShortcuts.concat(newInsertedShortcuts).join(",") 601 ); 602 return true; 603 } 604 } 605 606 return false; 607 } 608 609 // eslint-disable-next-line max-statements 610 async getLinksWithDefaults() { 611 // Clear the previous sites. 612 this.#sites = []; 613 614 const numItems = 615 Services.prefs.getIntPref(TOP_SITES_ROWS_PREF, 1) * 616 TOP_SITES_MAX_SITES_PER_ROW; 617 const searchShortcutsExperiment = Services.prefs.getBoolPref( 618 TOP_SITE_SEARCH_SHORTCUTS_PREF, 619 true 620 ); 621 // We must wait for search services to initialize in order to access default 622 // search engine properties without triggering a synchronous initialization 623 try { 624 await Services.search.init(); 625 } catch { 626 // We continue anyway because we want the user to see their sponsored, 627 // saved, or visited shortcut tiles even if search engines are not 628 // available. 629 } 630 631 // Get all frecent sites from history. 632 let frecent = []; 633 let cache; 634 try { 635 // Request can throw if executing the linkGetter inside LinksCache returns 636 // a null object. 637 cache = await this.frecentCache.request({ 638 // We need to overquery due to the top 5 alexa search + default search possibly being removed 639 numItems: numItems + SEARCH_FILTERS.length + 1, 640 topsiteFrecency: lazy.pageFrecencyThreshold, 641 }); 642 } catch (ex) { 643 cache = []; 644 } 645 646 for (let link of cache) { 647 // The cache can contain null values. 648 if (!link) { 649 continue; 650 } 651 const hostname = lazy.NewTabUtils.shortURL(link); 652 if (!this.shouldFilterSearchTile(hostname)) { 653 frecent.push({ 654 ...(searchShortcutsExperiment 655 ? await this.topSiteToSearchTopSite(link) 656 : link), 657 hostname, 658 }); 659 } 660 } 661 662 // Get defaults. 663 let notBlockedDefaultSites = []; 664 for (let link of DEFAULT_TOP_SITES) { 665 if (this.shouldFilterSearchTile(link.hostname)) { 666 continue; 667 } 668 // Drop blocked default sites. 669 if ( 670 lazy.NewTabUtils.blockedLinks.isBlocked({ 671 url: link.url, 672 }) 673 ) { 674 continue; 675 } 676 // If we've previously blocked a search shortcut, remove the default top site 677 // that matches the hostname 678 const searchProvider = getSearchProvider(lazy.NewTabUtils.shortURL(link)); 679 if ( 680 searchProvider && 681 lazy.NewTabUtils.blockedLinks.isBlocked({ url: searchProvider.url }) 682 ) { 683 continue; 684 } 685 notBlockedDefaultSites.push( 686 searchShortcutsExperiment 687 ? await this.topSiteToSearchTopSite(link) 688 : link 689 ); 690 } 691 692 // Get pinned links augmented with desired properties 693 let plainPinned = await this.pinnedCache.request(); 694 695 // Insert search shortcuts if we need to. 696 // _maybeInsertSearchShortcuts returns true if any search shortcuts are 697 // inserted, meaning we need to expire and refresh the pinnedCache 698 if (await this._maybeInsertSearchShortcuts(plainPinned)) { 699 this.pinnedCache.expire(); 700 plainPinned = await this.pinnedCache.request(); 701 } 702 703 const pinned = await Promise.all( 704 plainPinned.map(async link => { 705 if (!link) { 706 return link; 707 } 708 709 // Drop pinned search shortcuts when their engine has been removed / hidden. 710 if (link.searchTopSite) { 711 const searchProvider = getSearchProvider( 712 lazy.NewTabUtils.shortURL(link) 713 ); 714 if ( 715 !searchProvider || 716 !(await checkHasSearchEngine(searchProvider.keyword)) 717 ) { 718 return null; 719 } 720 } 721 722 // Copy all properties from a frecent link and add more 723 const finder = other => other.url === link.url; 724 725 const frecentSite = frecent.find(finder); 726 // If the link is a frecent site, do not copy over 'isDefault', else check 727 // if the site is a default site 728 const copy = Object.assign( 729 {}, 730 frecentSite || { isDefault: !!notBlockedDefaultSites.find(finder) }, 731 link, 732 { hostname: lazy.NewTabUtils.shortURL(link) }, 733 { searchTopSite: !!link.searchTopSite } 734 ); 735 736 // Add in favicons if we don't already have it 737 if (!copy.favicon) { 738 try { 739 lazy.NewTabUtils.activityStreamProvider._faviconBytesToDataURI( 740 await lazy.NewTabUtils.activityStreamProvider._addFavicons([copy]) 741 ); 742 743 for (const prop of PINNED_FAVICON_PROPS_TO_MIGRATE) { 744 copy.__sharedCache.updateLink(prop, copy[prop]); 745 } 746 } catch (e) { 747 // Some issue with favicon, so just continue without one 748 } 749 } 750 751 return copy; 752 }) 753 ); 754 755 // Remove any duplicates from frecent and default sites 756 const [, dedupedFrecent, dedupedDefaults] = this.dedupe.group( 757 pinned, 758 frecent, 759 notBlockedDefaultSites 760 ); 761 const dedupedUnpinned = [...dedupedFrecent, ...dedupedDefaults]; 762 763 // Remove adult sites if we need to 764 const checkedAdult = lazy.FilterAdult.filter(dedupedUnpinned); 765 766 // Insert the original pinned sites into the deduped frecent and defaults. 767 let withPinned = insertPinned(checkedAdult, pinned); 768 // Remove excess items. 769 withPinned = withPinned.slice(0, numItems); 770 771 // Now, get a tippy top icon or a rich icon for every item. 772 for (const link of withPinned) { 773 if (link) { 774 if (link.searchTopSite && !link.isDefault) { 775 this._tippyTopProvider.processSite(link); 776 } else { 777 this._fetchIcon(link); 778 } 779 780 // Remove internal properties that might be updated after dispatch 781 delete link.__sharedCache; 782 783 // Indicate that these links should get a frecency bonus when clicked 784 link.typedBonus = true; 785 } 786 } 787 788 this.#sites = withPinned; 789 790 return withPinned; 791 } 792 793 /** 794 * Refresh the top sites data for content. 795 * 796 * @param {object} options 797 * @param {bool} options.isStartup Being called while TopSitesFeed is initting. 798 */ 799 async refresh(options = {}) { 800 // Avoiding refreshing if it's already happening. 801 if (this._refreshing) { 802 return; 803 } 804 if (!this._startedUp && !options.isStartup) { 805 // Initial refresh still pending. 806 return; 807 } 808 this._refreshing = true; 809 this._startedUp = true; 810 811 if (!this._tippyTopProvider.initialized) { 812 await this._tippyTopProvider.init(); 813 } 814 815 await this.getLinksWithDefaults(); 816 this._refreshing = false; 817 Services.obs.notifyObservers(null, "topsites-refreshed", options.isStartup); 818 } 819 820 async updateCustomSearchShortcuts(isStartup = false) { 821 if ( 822 !Services.prefs.getBoolPref( 823 "browser.newtabpage.activity-stream.improvesearch.noDefaultSearchTile", 824 true 825 ) 826 ) { 827 return; 828 } 829 830 if (!this._tippyTopProvider.initialized) { 831 await this._tippyTopProvider.init(); 832 } 833 834 // Populate the state with available search shortcuts 835 let searchShortcuts = []; 836 for (const engine of await Services.search.getAppProvidedEngines()) { 837 const shortcut = CUSTOM_SEARCH_SHORTCUTS.find(s => 838 engine.aliases.includes(s.keyword) 839 ); 840 if (shortcut) { 841 let clone = { ...shortcut }; 842 this._tippyTopProvider.processSite(clone); 843 searchShortcuts.push(clone); 844 } 845 } 846 847 // TODO: Determine what the purpose of this is. 848 this.#searchShortcuts = searchShortcuts; 849 850 Services.obs.notifyObservers( 851 null, 852 "topsites-updated-custom-search-shortcuts", 853 isStartup 854 ); 855 } 856 857 async topSiteToSearchTopSite(site) { 858 const searchProvider = getSearchProvider(lazy.NewTabUtils.shortURL(site)); 859 if ( 860 !searchProvider || 861 !(await checkHasSearchEngine(searchProvider.keyword)) 862 ) { 863 return site; 864 } 865 return { 866 ...site, 867 searchTopSite: true, 868 label: searchProvider.keyword, 869 }; 870 } 871 872 /** 873 * Get an image for the link preferring tippy top, or rich favicon. 874 */ 875 async _fetchIcon(link) { 876 // Nothing to do if we already have a rich icon from the page 877 if (link.favicon && link.faviconSize >= MIN_FAVICON_SIZE) { 878 return; 879 } 880 881 // Nothing more to do if we can use a default tippy top icon 882 this._tippyTopProvider.processSite(link); 883 if (link.tippyTopIcon) { 884 return; 885 } 886 887 // Make a request for a better icon 888 this._requestRichIcon(link.url); 889 } 890 891 _requestRichIcon(url) { 892 this._faviconProvider.fetchIcon(url); 893 } 894 895 /** 896 * Inform others that top sites data has been updated due to pinned changes. 897 */ 898 _broadcastPinnedSitesUpdated() { 899 // Pinned data changed, so make sure we get latest 900 this.pinnedCache.expire(); 901 902 // Refresh to trigger deduping, etc. 903 this.refresh(); 904 } 905 906 /** 907 * Pin a site at a specific position saving only the desired keys. 908 * 909 * @param label {string} User set string of custom site name 910 */ 911 // To refactor in Bug 1891997 912 /* eslint-enable jsdoc/check-param-names */ 913 async _pinSiteAt({ label, url, searchTopSite }, index) { 914 const toPin = { url }; 915 if (label) { 916 toPin.label = label; 917 } 918 if (searchTopSite) { 919 toPin.searchTopSite = searchTopSite; 920 } 921 lazy.NewTabUtils.pinnedLinks.pin(toPin, index); 922 } 923 924 /** 925 * Handle a pin action of a site to a position. 926 */ 927 async pin(action) { 928 let { site, index } = action.data; 929 index = this._adjustPinIndexForSponsoredLinks(site, index); 930 // If valid index provided, pin at that position 931 if (index >= 0) { 932 await this._pinSiteAt(site, index); 933 this._broadcastPinnedSitesUpdated(); 934 } else { 935 // Bug 1458658. If the top site is being pinned from an 'Add a Top Site' option, 936 // then we want to make sure to unblock that link if it has previously been 937 // blocked. We know if the site has been added because the index will be -1. 938 if (index === -1) { 939 lazy.NewTabUtils.blockedLinks.unblock({ url: site.url }); 940 this.frecentCache.expire(); 941 } 942 this.insert(action); 943 } 944 } 945 946 /** 947 * Handle an unpin action of a site. 948 */ 949 unpin(action) { 950 const { site } = action.data; 951 lazy.NewTabUtils.pinnedLinks.unpin(site); 952 this._broadcastPinnedSitesUpdated(); 953 } 954 955 unpinAllSearchShortcuts() { 956 Services.prefs.clearUserPref(SEARCH_SHORTCUTS_HAVE_PINNED_PREF); 957 for (let pinnedLink of lazy.NewTabUtils.pinnedLinks.links) { 958 if (pinnedLink && pinnedLink.searchTopSite) { 959 lazy.NewTabUtils.pinnedLinks.unpin(pinnedLink); 960 } 961 } 962 this.pinnedCache.expire(); 963 } 964 965 _unpinSearchShortcut(vendor) { 966 for (let pinnedLink of lazy.NewTabUtils.pinnedLinks.links) { 967 if ( 968 pinnedLink && 969 pinnedLink.searchTopSite && 970 lazy.NewTabUtils.shortURL(pinnedLink) === vendor 971 ) { 972 lazy.NewTabUtils.pinnedLinks.unpin(pinnedLink); 973 this.pinnedCache.expire(); 974 975 const prevInsertedShortcuts = Services.prefs.getStringPref( 976 SEARCH_SHORTCUTS_HAVE_PINNED_PREF, 977 "" 978 ); 979 Services.prefs.setStringPref( 980 SEARCH_SHORTCUTS_HAVE_PINNED_PREF, 981 prevInsertedShortcuts.filter(s => s !== vendor).join(",") 982 ); 983 break; 984 } 985 } 986 } 987 988 /** 989 * Reduces the given pinning index by the number of preceding sponsored 990 * sites, to accomodate for sponsored sites pushing pinned ones to the side, 991 * effectively increasing their index again. 992 */ 993 _adjustPinIndexForSponsoredLinks(site, index) { 994 if (!this.#sites) { 995 return index; 996 } 997 // Adjust insertion index for sponsored sites since their position is 998 // fixed. 999 let adjustedIndex = index; 1000 for (let i = 0; i < index; i++) { 1001 const link = this.#sites[i]; 1002 if (link && link.sponsored_position && this.#sites[i]?.url !== site.url) { 1003 adjustedIndex--; 1004 } 1005 } 1006 return adjustedIndex; 1007 } 1008 1009 /** 1010 * Insert a site to pin at a position shifting over any other pinned sites. 1011 */ 1012 _insertPin(site, originalIndex, draggedFromIndex) { 1013 let index = this._adjustPinIndexForSponsoredLinks(site, originalIndex); 1014 1015 // Don't insert any pins past the end of the visible top sites. Otherwise, 1016 // we can end up with a bunch of pinned sites that can never be unpinned again 1017 // from the UI. 1018 const topSitesCount = 1019 Services.prefs.getIntPref(TOP_SITES_ROWS_PREF, 1) * 1020 TOP_SITES_MAX_SITES_PER_ROW; 1021 if (index >= topSitesCount) { 1022 return; 1023 } 1024 1025 let pinned = lazy.NewTabUtils.pinnedLinks.links; 1026 if (!pinned[index]) { 1027 this._pinSiteAt(site, index); 1028 } else { 1029 pinned[draggedFromIndex] = null; 1030 // Find the hole to shift the pinned site(s) towards. We shift towards the 1031 // hole left by the site being dragged. 1032 let holeIndex = index; 1033 const indexStep = index > draggedFromIndex ? -1 : 1; 1034 while (pinned[holeIndex]) { 1035 holeIndex += indexStep; 1036 } 1037 if (holeIndex >= topSitesCount || holeIndex < 0) { 1038 // There are no holes, so we will effectively unpin the last slot and shifting 1039 // towards it. This only happens when adding a new top site to an already 1040 // fully pinned grid. 1041 holeIndex = topSitesCount - 1; 1042 } 1043 1044 // Shift towards the hole. 1045 const shiftingStep = holeIndex > index ? -1 : 1; 1046 while (holeIndex !== index) { 1047 const nextIndex = holeIndex + shiftingStep; 1048 this._pinSiteAt(pinned[nextIndex], holeIndex); 1049 holeIndex = nextIndex; 1050 } 1051 this._pinSiteAt(site, index); 1052 } 1053 } 1054 1055 /** 1056 * Handle an insert (drop/add) action of a site. 1057 */ 1058 async insert(action) { 1059 let { index } = action.data; 1060 // Treat invalid pin index values (e.g., -1, undefined) as insert in the first position 1061 if (!(index > 0)) { 1062 index = 0; 1063 } 1064 1065 // Inserting a top site pins it in the specified slot, pushing over any link already 1066 // pinned in the slot (unless it's the last slot, then it replaces). 1067 this._insertPin( 1068 action.data.site, 1069 index, 1070 action.data.draggedFromIndex !== undefined 1071 ? action.data.draggedFromIndex 1072 : Services.prefs.getIntPref(TOP_SITES_ROWS_PREF, 1) * 1073 TOP_SITES_MAX_SITES_PER_ROW 1074 ); 1075 1076 this._broadcastPinnedSitesUpdated(); 1077 } 1078 1079 updatePinnedSearchShortcuts({ addedShortcuts, deletedShortcuts }) { 1080 // Unpin the deletedShortcuts. 1081 deletedShortcuts.forEach(({ url }) => { 1082 lazy.NewTabUtils.pinnedLinks.unpin({ url }); 1083 }); 1084 1085 // Pin the addedShortcuts. 1086 const numberOfSlots = 1087 Services.prefs.getIntPref(TOP_SITES_ROWS_PREF, 1) * 1088 TOP_SITES_MAX_SITES_PER_ROW; 1089 addedShortcuts.forEach(shortcut => { 1090 // Find first hole in pinnedLinks. 1091 let index = lazy.NewTabUtils.pinnedLinks.links.findIndex(link => !link); 1092 if ( 1093 index < 0 && 1094 lazy.NewTabUtils.pinnedLinks.links.length + 1 < numberOfSlots 1095 ) { 1096 // pinnedLinks can have less slots than the total available. 1097 index = lazy.NewTabUtils.pinnedLinks.links.length; 1098 } 1099 if (index >= 0) { 1100 lazy.NewTabUtils.pinnedLinks.pin(shortcut, index); 1101 } else { 1102 // No slots available, we need to do an insert in first slot and push over other pinned links. 1103 this._insertPin(shortcut, 0, numberOfSlots); 1104 } 1105 }); 1106 1107 this._broadcastPinnedSitesUpdated(); 1108 } 1109 } 1110 1111 /** 1112 * insertPinned - Inserts pinned links in their specified slots 1113 * 1114 * @param {Array} links list of links 1115 * @param {Array} pinned list of pinned links 1116 * @returns {Array} resulting list of links with pinned links inserted 1117 */ 1118 export function insertPinned(links, pinned) { 1119 // Remove any pinned links 1120 const pinnedUrls = pinned.map(link => link && link.url); 1121 let newLinks = links.filter(link => 1122 link ? !pinnedUrls.includes(link.url) : false 1123 ); 1124 newLinks = newLinks.map(link => { 1125 if (link && link.isPinned) { 1126 delete link.isPinned; 1127 delete link.pinIndex; 1128 } 1129 return link; 1130 }); 1131 1132 // Then insert them in their specified location 1133 pinned.forEach((val, index) => { 1134 if (!val) { 1135 return; 1136 } 1137 let link = Object.assign({}, val, { isPinned: true, pinIndex: index }); 1138 if (index > newLinks.length) { 1139 newLinks[index] = link; 1140 } else { 1141 newLinks.splice(index, 0, link); 1142 } 1143 }); 1144 1145 return newLinks; 1146 } 1147 1148 /** 1149 * FaviconProvider class handles the retrieval and management of favicons 1150 * for TopSites. 1151 */ 1152 export class FaviconProvider { 1153 constructor() { 1154 this._queryForRedirects = new Set(); 1155 } 1156 1157 /** 1158 * fetchIcon attempts to fetch a rich icon for the given url from two sources. 1159 * First, it looks up the tippy top feed, if it's still missing, then it queries 1160 * the places for rich icon with its most recent visit in order to deal with 1161 * the redirected visit. See Bug 1421428 for more details. 1162 */ 1163 async fetchIcon(url) { 1164 // Avoid initializing and fetching icons if prefs are turned off 1165 if (!this.shouldFetchIcons) { 1166 return; 1167 } 1168 1169 const site = await this.getSite(getDomain(url)); 1170 if (!site) { 1171 if (!this._queryForRedirects.has(url)) { 1172 this._queryForRedirects.add(url); 1173 Services.tm.idleDispatchToMainThread(() => 1174 this.fetchIconFromRedirects(url) 1175 ); 1176 } 1177 return; 1178 } 1179 1180 let iconUri = Services.io.newURI(site.image_url); 1181 // The #tippytop is to be able to identify them for telemetry. 1182 iconUri = iconUri.mutate().setRef("tippytop").finalize(); 1183 await this.#setFaviconForPage(Services.io.newURI(url), iconUri); 1184 } 1185 1186 /** 1187 * Get the site tippy top data from Remote Settings. 1188 */ 1189 async getSite(domain) { 1190 const sites = await this.tippyTop.get({ 1191 filters: { domain }, 1192 syncIfEmpty: false, 1193 }); 1194 return sites.length ? sites[0] : null; 1195 } 1196 1197 /** 1198 * Get the tippy top collection from Remote Settings. 1199 */ 1200 get tippyTop() { 1201 if (!this._tippyTop) { 1202 this._tippyTop = lazy.RemoteSettings("tippytop"); 1203 } 1204 return this._tippyTop; 1205 } 1206 1207 /** 1208 * Determine if we should be fetching and saving icons. 1209 */ 1210 get shouldFetchIcons() { 1211 return Services.prefs.getBoolPref("browser.chrome.site_icons"); 1212 } 1213 1214 /** 1215 * Get favicon info (uri and size) for a uri from Places. 1216 * 1217 * @param {nsIURI} uri 1218 * Page to check for favicon data 1219 * @returns {object} 1220 * Favicon info object. If there is no data in DB, return null. 1221 */ 1222 async getFaviconInfo(uri) { 1223 let favicon = await lazy.PlacesUtils.favicons.getFaviconForPage( 1224 uri, 1225 lazy.NewTabUtils.activityStreamProvider.THUMB_FAVICON_SIZE 1226 ); 1227 return favicon 1228 ? { iconUri: favicon.uri, faviconSize: favicon.width } 1229 : null; 1230 } 1231 1232 /** 1233 * Fetch favicon for a url by following its redirects in Places. 1234 * 1235 * This can improve the rich icon coverage for Top Sites since Places only 1236 * associates the favicon to the final url if the original one gets redirected. 1237 * Note this is not an urgent request, hence it is dispatched to the main 1238 * thread idle handler to avoid any possible performance impact. 1239 */ 1240 async fetchIconFromRedirects(url) { 1241 const visitPaths = await this.#fetchVisitPaths(url); 1242 if (visitPaths.length > 1) { 1243 const lastVisit = visitPaths.pop(); 1244 const redirectedUri = Services.io.newURI(lastVisit.url); 1245 const iconInfo = await this.getFaviconInfo(redirectedUri); 1246 if (iconInfo?.faviconSize >= MIN_FAVICON_SIZE) { 1247 try { 1248 await lazy.PlacesUtils.favicons.tryCopyFavicons( 1249 redirectedUri, 1250 Services.io.newURI(url), 1251 lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE 1252 ); 1253 } catch (ex) { 1254 console.error(`Failed to copy favicon [${ex}]`); 1255 } 1256 } 1257 } 1258 } 1259 1260 /** 1261 * Get favicon data for given URL from network. 1262 * 1263 * @param {nsIURI} faviconURI 1264 * nsIURI for the favicon. 1265 * @returns {nsIURI} data URL 1266 */ 1267 async getFaviconDataURLFromNetwork(faviconURI) { 1268 let channel = lazy.NetUtil.newChannel({ 1269 uri: faviconURI, 1270 loadingPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), 1271 securityFlags: 1272 Ci.nsILoadInfo.SEC_REQUIRE_CORS_INHERITS_SEC_CONTEXT | 1273 Ci.nsILoadInfo.SEC_ALLOW_CHROME | 1274 Ci.nsILoadInfo.SEC_DISALLOW_SCRIPT, 1275 contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE_FAVICON, 1276 }); 1277 1278 let resolver = Promise.withResolvers(); 1279 1280 lazy.NetUtil.asyncFetch(channel, async (input, status, request) => { 1281 if (!Components.isSuccessCode(status)) { 1282 resolver.resolve(); 1283 return; 1284 } 1285 1286 try { 1287 let data = lazy.NetUtil.readInputStream(input, input.available()); 1288 let { contentType } = request.QueryInterface(Ci.nsIChannel); 1289 input.close(); 1290 1291 let buffer = new Uint8ClampedArray(data); 1292 let blob = new Blob([buffer], { type: contentType }); 1293 let dataURL = await new Promise((resolve, reject) => { 1294 let reader = new FileReader(); 1295 reader.addEventListener("load", () => resolve(reader.result)); 1296 reader.addEventListener("error", reject); 1297 reader.readAsDataURL(blob); 1298 }); 1299 resolver.resolve(Services.io.newURI(dataURL)); 1300 } catch (e) { 1301 resolver.reject(e); 1302 } 1303 }); 1304 1305 return resolver.promise; 1306 } 1307 1308 /** 1309 * Set favicon for page. 1310 * 1311 * @param {nsIURI} pageURI 1312 * @param {nsIURI} faviconURI 1313 */ 1314 async #setFaviconForPage(pageURI, faviconURI) { 1315 try { 1316 // If the given faviconURI is data URL, set it as is. 1317 if (faviconURI.schemeIs("data")) { 1318 lazy.PlacesUtils.favicons 1319 .setFaviconForPage(pageURI, faviconURI, faviconURI) 1320 .catch(console.error); 1321 return; 1322 } 1323 1324 // Try to find the favicon data from DB. 1325 const faviconInfo = await this.getFaviconInfo(pageURI); 1326 if (faviconInfo?.faviconSize) { 1327 // As valid favicon data is already stored for the page, 1328 // we don't have to update. 1329 return; 1330 } 1331 1332 // Otherwise, fetch from network. 1333 lazy.PlacesUtils.favicons 1334 .setFaviconForPage( 1335 pageURI, 1336 faviconURI, 1337 await this.getFaviconDataURLFromNetwork(faviconURI) 1338 ) 1339 .catch(console.error); 1340 } catch (ex) { 1341 console.error(`Failed to set favicon for page:${ex}`); 1342 } 1343 } 1344 1345 /** 1346 * Fetches visit paths for a given URL from its most recent visit in Places. 1347 * 1348 * Note that this includes the URL itself as well as all the following 1349 * permenent&temporary redirected URLs if any. 1350 * 1351 * @param {string} url 1352 * a URL string 1353 * 1354 * @returns {Array} Returns an array containing objects as 1355 * {int} visit_id: ID of the visit in moz_historyvisits. 1356 * {String} url: URL of the redirected URL. 1357 */ 1358 async #fetchVisitPaths(url) { 1359 const query = ` 1360 WITH RECURSIVE path(visit_id) 1361 AS ( 1362 SELECT v.id 1363 FROM moz_places h 1364 JOIN moz_historyvisits v 1365 ON v.place_id = h.id 1366 WHERE h.url_hash = hash(:url) AND h.url = :url 1367 AND v.visit_date = h.last_visit_date 1368 1369 UNION 1370 1371 SELECT id 1372 FROM moz_historyvisits 1373 JOIN path 1374 ON visit_id = from_visit 1375 WHERE visit_type IN 1376 (${lazy.PlacesUtils.history.TRANSITIONS.REDIRECT_PERMANENT}, 1377 ${lazy.PlacesUtils.history.TRANSITIONS.REDIRECT_TEMPORARY}) 1378 ) 1379 SELECT visit_id, ( 1380 SELECT ( 1381 SELECT url 1382 FROM moz_places 1383 WHERE id = place_id) 1384 FROM moz_historyvisits 1385 WHERE id = visit_id) AS url 1386 FROM path 1387 `; 1388 1389 const visits = 1390 await lazy.NewTabUtils.activityStreamProvider.executePlacesQuery(query, { 1391 columns: ["visit_id", "url"], 1392 params: { url }, 1393 }); 1394 return visits; 1395 } 1396 } 1397 1398 export const TopSites = new _TopSites();