/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * This module exports a provider returning the user's newtab Top Sites. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { UrlbarProvider, UrlbarUtils, } from "resource:///modules/UrlbarUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { CONTEXTUAL_SERVICES_PING_TYPES: "resource:///modules/PartnerLinkAttribution.sys.mjs", PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs", UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", }); XPCOMUtils.defineLazyModuleGetters(lazy, { AboutNewTab: "resource:///modules/AboutNewTab.jsm", TOP_SITES_MAX_SITES_PER_ROW: "resource://activity-stream/common/Reducers.jsm", TOP_SITES_DEFAULT_ROWS: "resource://activity-stream/common/Reducers.jsm", }); // The scalar category of TopSites impression for Contextual Services const SCALAR_CATEGORY_TOPSITES = "contextual.services.topsites.impression"; // These prefs must be true for the provider to return results. They are assumed // to be booleans. We check `system.topsites` because if it is disabled we would // get stale or empty top sites data. const TOP_SITES_ENABLED_PREFS = [ "browser.urlbar.suggest.topsites", "browser.newtabpage.activity-stream.feeds.system.topsites", ]; /** * A provider that returns the Top Sites shown on about:newtab. */ class ProviderTopSites extends UrlbarProvider { constructor() { super(); this._topSitesListeners = []; let callListeners = () => this._callTopSitesListeners(); Services.obs.addObserver(callListeners, "newtab-top-sites-changed"); for (let pref of TOP_SITES_ENABLED_PREFS) { Services.prefs.addObserver(pref, callListeners); } } get PRIORITY() { // Top sites are prioritized over the UrlbarProviderPlaces provider. return 1; } /** * Unique name for the provider, used by the context to filter on providers. * Not using a unique name will cause the newest registration to win. * * @returns {string} */ get name() { return "UrlbarProviderTopSites"; } /** * The type of the provider. * * @returns {UrlbarUtils.PROVIDER_TYPE} */ get type() { return UrlbarUtils.PROVIDER_TYPE.PROFILE; } /** * Whether this provider should be invoked for the given context. * If this method returns false, the providers manager won't start a query * with this provider, to save on resources. * * @param {UrlbarQueryContext} queryContext The query context object * @returns {boolean} Whether this provider should be invoked for the search. */ isActive(queryContext) { return ( !queryContext.restrictSource && !queryContext.searchString && !queryContext.searchMode ); } /** * Gets the provider's priority. * * @param {UrlbarQueryContext} queryContext The query context object * @returns {number} The provider's priority for the given query. */ getPriority(queryContext) { return this.PRIORITY; } /** * Starts querying. Extended classes should return a Promise resolved when the * provider is done searching AND returning results. * * @param {UrlbarQueryContext} queryContext The query context object * @param {Function} addCallback Callback invoked by the provider to add a new * result. A UrlbarResult should be passed to it. * @returns {Promise} */ async startQuery(queryContext, addCallback) { // Bail if Top Sites are not enabled. We check this condition here instead // of in isActive because we still want this provider to be restricting even // if this is not true. If it wasn't restricting, we would show the results // from UrlbarProviderPlaces's empty search behaviour. We aren't interested // in those since they are very similar to Top Sites and thus might be // confusing, especially since users can configure Top Sites but cannot // configure the default empty search results. See bug 1623666. let enabled = TOP_SITES_ENABLED_PREFS.every(p => Services.prefs.getBoolPref(p, false) ); if (!enabled) { return; } let sites = lazy.AboutNewTab.getTopSites(); let instance = this.queryInstance; // Filter out empty values. Site is empty when there's a gap between tiles // on about:newtab. sites = sites.filter(site => site); if (!lazy.UrlbarPrefs.get("sponsoredTopSites")) { sites = sites.filter(site => !site.sponsored_position); } // This is done here, rather than in the global scope, because // TOP_SITES_DEFAULT_ROWS causes the import of Reducers.jsm, and we want to // do that only when actually querying for Top Sites. if (this.topSitesRows === undefined) { XPCOMUtils.defineLazyPreferenceGetter( this, "topSitesRows", "browser.newtabpage.activity-stream.topSitesRows", lazy.TOP_SITES_DEFAULT_ROWS ); } // We usually respect maxRichResults, though we never show a number of Top // Sites greater than what is visible in the New Tab Page, because the // additional ones couldn't be managed from the page. let numTopSites = Math.min( lazy.UrlbarPrefs.get("maxRichResults"), lazy.TOP_SITES_MAX_SITES_PER_ROW * this.topSitesRows ); sites = sites.slice(0, numTopSites); let sponsoredSites = []; let index = 1; sites = sites.map(link => { let site = { type: link.searchTopSite ? "search" : "url", url: link.url_urlbar || link.url, isPinned: !!link.isPinned, isSponsored: !!link.sponsored_position, // The newtab page allows the user to set custom site titles, which // are stored in `label`, so prefer it. Search top sites currently // don't have titles but `hostname` instead. title: link.label || link.title || link.hostname || "", favicon: link.smallFavicon || link.favicon || undefined, sendAttributionRequest: !!link.sendAttributionRequest, }; if (site.isSponsored) { let { sponsored_tile_id, sponsored_impression_url, sponsored_click_url, } = link; site = { ...site, sponsoredTileId: sponsored_tile_id, sponsoredImpressionUrl: sponsored_impression_url, sponsoredClickUrl: sponsored_click_url, position: index, }; sponsoredSites.push(site); } index++; return site; }); // Store Sponsored Top Sites so we can use it in `onEngagement` if (sponsoredSites.length) { this.sponsoredSites = sponsoredSites; } for (let site of sites) { switch (site.type) { case "url": { let payload = { title: site.title, url: site.url, icon: site.favicon, isPinned: site.isPinned, isSponsored: site.isSponsored, sendAttributionRequest: site.sendAttributionRequest, }; if (site.isSponsored) { payload = { ...payload, sponsoredTileId: site.sponsoredTileId, sponsoredClickUrl: site.sponsoredClickUrl, }; } let result = new lazy.UrlbarResult( UrlbarUtils.RESULT_TYPE.URL, UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, ...lazy.UrlbarResult.payloadAndSimpleHighlights( queryContext.tokens, payload ) ); let tabs; if (lazy.UrlbarPrefs.get("suggest.openpage")) { tabs = lazy.UrlbarProviderOpenTabs.getOpenTabs( queryContext.userContextId || 0, queryContext.isPrivate ); } if (tabs && tabs.includes(site.url.replace(/#.*$/, ""))) { result.type = UrlbarUtils.RESULT_TYPE.TAB_SWITCH; result.source = UrlbarUtils.RESULT_SOURCE.TABS; } else if (lazy.UrlbarPrefs.get("suggest.bookmark")) { let bookmark = await lazy.PlacesUtils.bookmarks.fetch({ url: new URL(result.payload.url), }); if (bookmark) { result.source = UrlbarUtils.RESULT_SOURCE.BOOKMARKS; } } // Our query has been cancelled. if (instance != this.queryInstance) { break; } addCallback(this, result); break; } case "search": { let engine = await lazy.UrlbarSearchUtils.engineForAlias(site.title); if (!engine && site.url) { // Look up the engine by its domain. let host; try { host = new URL(site.url).hostname; } catch (err) {} if (host) { engine = ( await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host) )[0]; } } if (!engine) { // No engine found. We skip this Top Site. break; } if (instance != this.queryInstance) { break; } let result = new lazy.UrlbarResult( UrlbarUtils.RESULT_TYPE.SEARCH, UrlbarUtils.RESULT_SOURCE.SEARCH, ...lazy.UrlbarResult.payloadAndSimpleHighlights( queryContext.tokens, { title: site.title, keyword: site.title, providesSearchMode: true, engine: engine.name, query: "", icon: site.favicon, isPinned: site.isPinned, } ) ); addCallback(this, result); break; } default: this.logger.error(`Unknown Top Site type: ${site.type}`); break; } } } /** * Called when the user starts and ends an engagement with the urlbar. We send * the impression ping for the sponsored TopSites, the impression scalar is * recorded as well. * * Note: * No telemetry recording in private browsing mode * The impression is only recorded for the "engagement" and "abandonment" * states * * @param {boolean} isPrivate True if the engagement is in a private context. * @param {string} state The state of the engagement, one of: start, * engagement, abandonment, discard. */ onEngagement(isPrivate, state) { if ( !isPrivate && this.sponsoredSites && ["engagement", "abandonment"].includes(state) ) { for (let site of this.sponsoredSites) { Services.telemetry.keyedScalarAdd( SCALAR_CATEGORY_TOPSITES, `urlbar_${site.position}`, 1 ); lazy.PartnerLinkAttribution.sendContextualServicesPing( { source: "urlbar", tile_id: site.sponsoredTileId || -1, position: site.position, reporting_url: site.sponsoredImpressionUrl, advertiser: site.title.toLocaleLowerCase(), }, lazy.CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_IMPRESSION ); } } this.sponsoredSites = null; } /** * Adds a listener function that will be called when the top sites change or * they are enabled/disabled. This class will hold a weak reference to the * listener, so you do not need to unregister it, but you or someone else must * keep a strong reference to it to keep it from being immediately garbage * collected. * * @param {Function} callback * The listener function. This class will hold a weak reference to it. */ addTopSitesListener(callback) { this._topSitesListeners.push(Cu.getWeakReference(callback)); } _callTopSitesListeners() { for (let i = 0; i < this._topSitesListeners.length; ) { let listener = this._topSitesListeners[i].get(); if (!listener) { // The listener has been GC'ed, so remove it from our list. this._topSitesListeners.splice(i, 1); } else { listener(); ++i; } } } } export var UrlbarProviderTopSites = new ProviderTopSites();