UrlbarProviderTopSites.sys.mjs (13730B)
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 /** 6 * This module exports a provider returning the user's newtab Top Sites. 7 */ 8 9 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; 10 11 import { 12 UrlbarProvider, 13 UrlbarUtils, 14 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"; 15 16 const lazy = {}; 17 18 ChromeUtils.defineESModuleGetters(lazy, { 19 AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", 20 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 21 TopSites: "resource:///modules/topsites/TopSites.sys.mjs", 22 TOP_SITES_DEFAULT_ROWS: "resource:///modules/topsites/constants.mjs", 23 TOP_SITES_MAX_SITES_PER_ROW: "resource:///modules/topsites/constants.mjs", 24 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 25 UrlbarProviderOpenTabs: 26 "moz-src:///browser/components/urlbar/UrlbarProviderOpenTabs.sys.mjs", 27 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 28 UrlbarSearchUtils: 29 "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", 30 }); 31 32 // These prefs must be true for the provider to return results. They are assumed 33 // to be booleans. We check `system.topsites` because if it is disabled we would 34 // get stale or empty top sites data. 35 const TOP_SITES_ENABLED_PREFS = [ 36 "browser.urlbar.suggest.topsites", 37 "browser.newtabpage.activity-stream.feeds.system.topsites", 38 ]; 39 40 // Helper function to compare 2 URLs without refs. 41 function sameUrlIgnoringRef(url1, url2) { 42 if (!url1 || !url2) { 43 return false; 44 } 45 46 let cleanUrl1 = url1.replace(/#.*$/, ""); 47 let cleanUrl2 = url2.replace(/#.*$/, ""); 48 49 return cleanUrl1 == cleanUrl2; 50 } 51 52 /** 53 * A provider that returns the Top Sites shown on about:newtab. 54 */ 55 export class UrlbarProviderTopSites extends UrlbarProvider { 56 constructor() { 57 super(); 58 } 59 60 static get PRIORITY() { 61 // Top sites are prioritized over the UrlbarProviderPlaces provider. 62 return 1; 63 } 64 65 /** 66 * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>} 67 */ 68 get type() { 69 return UrlbarUtils.PROVIDER_TYPE.PROFILE; 70 } 71 72 /** 73 * Whether this provider should be invoked for the given context. 74 * If this method returns false, the providers manager won't start a query 75 * with this provider, to save on resources. 76 * 77 * @param {UrlbarQueryContext} queryContext The query context object 78 */ 79 async isActive(queryContext) { 80 return ( 81 !queryContext.restrictSource && 82 !queryContext.searchString && 83 !queryContext.searchMode 84 ); 85 } 86 87 /** 88 * Gets the provider's priority. 89 * 90 * @returns {number} The provider's priority for the given query. 91 */ 92 getPriority() { 93 return UrlbarProviderTopSites.PRIORITY; 94 } 95 96 /** 97 * Starts querying. 98 * 99 * @param {UrlbarQueryContext} queryContext 100 * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback 101 * Callback invoked by the provider to add a new result. 102 */ 103 async startQuery(queryContext, addCallback) { 104 // Bail if Top Sites are not enabled. We check this condition here instead 105 // of in isActive because we still want this provider to be restricting even 106 // if this is not true. If it wasn't restricting, we would show the results 107 // from UrlbarProviderPlaces's empty search behaviour. We aren't interested 108 // in those since they are very similar to Top Sites and thus might be 109 // confusing, especially since users can configure Top Sites but cannot 110 // configure the default empty search results. See bug 1623666. 111 let enabled = TOP_SITES_ENABLED_PREFS.every(p => 112 Services.prefs.getBoolPref(p, false) 113 ); 114 if (!enabled) { 115 return; 116 } 117 118 let sites; 119 if (Services.prefs.getBoolPref("browser.topsites.component.enabled")) { 120 sites = await lazy.TopSites.getSites(); 121 } else { 122 sites = lazy.AboutNewTab.getTopSites(); 123 } 124 125 let instance = this.queryInstance; 126 127 // Filter out empty values. Site is empty when there's a gap between tiles 128 // on about:newtab. 129 sites = sites.filter(site => site); 130 131 if (!lazy.UrlbarPrefs.get("sponsoredTopSites")) { 132 sites = sites.filter(site => !site.sponsored_position); 133 } 134 135 // This is done here, rather than in the global scope, because 136 // TOP_SITES_DEFAULT_ROWS causes import of topsites constants.mjs, and we want to 137 // do that only when actually querying for Top Sites. 138 if (UrlbarProviderTopSites.topSitesRows === undefined) { 139 XPCOMUtils.defineLazyPreferenceGetter( 140 UrlbarProviderTopSites, 141 "topSitesRows", 142 "browser.newtabpage.activity-stream.topSitesRows", 143 lazy.TOP_SITES_DEFAULT_ROWS 144 ); 145 } 146 147 // We usually respect maxRichResults, though we never show a number of Top 148 // Sites greater than what is visible in the New Tab Page, because the 149 // additional ones couldn't be managed from the page. 150 let numTopSites = Math.min( 151 lazy.UrlbarPrefs.get("maxRichResults"), 152 lazy.TOP_SITES_MAX_SITES_PER_ROW * UrlbarProviderTopSites.topSitesRows 153 ); 154 sites = sites.slice(0, numTopSites); 155 156 sites = sites.map(link => { 157 let site = { 158 type: link.searchTopSite ? "search" : "url", 159 url: link.url_urlbar || link.url, 160 isPinned: !!link.isPinned, 161 isSponsored: !!link.sponsored_position, 162 // The newtab page allows the user to set custom site titles, which 163 // are stored in `label`, so prefer it. Search top sites currently 164 // don't have titles but `hostname` instead. 165 title: link.label || link.title || link.hostname || "", 166 favicon: link.smallFavicon || link.favicon || undefined, 167 sendAttributionRequest: !!link.sendAttributionRequest, 168 }; 169 if (site.isSponsored) { 170 let { 171 sponsored_tile_id, 172 sponsored_impression_url, 173 sponsored_click_url, 174 } = link; 175 site = { 176 ...site, 177 sponsoredTileId: sponsored_tile_id, 178 sponsoredImpressionUrl: sponsored_impression_url, 179 sponsoredClickUrl: sponsored_click_url, 180 }; 181 } 182 return site; 183 }); 184 185 let tabUrlsToContextIds = new Map(); 186 if (lazy.UrlbarPrefs.get("suggest.openpage")) { 187 if (lazy.UrlbarPrefs.get("switchTabs.searchAllContainers")) { 188 lazy.UrlbarProviderOpenTabs.getOpenTabUrls( 189 queryContext.isPrivate 190 ).forEach((userContextAndGroupIds, url) => { 191 let userContextIds = new Set(); 192 for (let [userContextId] of userContextAndGroupIds) { 193 userContextIds.add(userContextId); 194 } 195 tabUrlsToContextIds.set(url, userContextIds); 196 }); 197 } else { 198 for (let [ 199 url, 200 userContextId, 201 ] of lazy.UrlbarProviderOpenTabs.getOpenTabUrlsForUserContextId( 202 queryContext.userContextId, 203 queryContext.isPrivate 204 )) { 205 let userContextIds = tabUrlsToContextIds.get(url); 206 if (!userContextIds) { 207 userContextIds = new Set(); 208 } 209 userContextIds.add(userContextId); 210 tabUrlsToContextIds.set(url, userContextIds); 211 } 212 } 213 } 214 215 for (let site of sites) { 216 switch (site.type) { 217 case "url": { 218 let payload = { 219 title: site.title, 220 url: site.url, 221 icon: site.favicon, 222 isPinned: site.isPinned, 223 isSponsored: site.isSponsored, 224 }; 225 226 // Fuzzy match both the URL as-is, and the URL without ref, then 227 // generate a merged Set. 228 if (tabUrlsToContextIds) { 229 let tabUserContextIds = new Set([ 230 ...(tabUrlsToContextIds.get(site.url) ?? []), 231 ...(tabUrlsToContextIds.get(site.url.replace(/#.*$/, "")) ?? []), 232 ]); 233 if (tabUserContextIds.size) { 234 let switchToTabResultAdded = false; 235 for (let userContextId of tabUserContextIds) { 236 // Normally we could skip the whole for loop, but if searchAllContainers 237 // is set then the current page userContextId may differ, then we should 238 // allow switching to other ones. 239 if ( 240 sameUrlIgnoringRef(queryContext.currentPage, site.url) && 241 (!lazy.UrlbarPrefs.get("switchTabs.searchAllContainers") || 242 queryContext.userContextId == userContextId) 243 ) { 244 // Don't suggest switching to the current tab. 245 continue; 246 } 247 payload.userContextId = userContextId; 248 let result = new lazy.UrlbarResult({ 249 type: UrlbarUtils.RESULT_TYPE.TAB_SWITCH, 250 source: UrlbarUtils.RESULT_SOURCE.TABS, 251 payload, 252 }); 253 addCallback(this, result); 254 switchToTabResultAdded = true; 255 } 256 // Avoid adding url result if Switch to Tab result was added. 257 if (switchToTabResultAdded) { 258 break; 259 } 260 } 261 } 262 263 if (site.isSponsored) { 264 payload.sponsoredTileId = site.sponsoredTileId; 265 payload.sponsoredClickUrl = site.sponsoredClickUrl; 266 } 267 payload.sendAttributionRequest = site.sendAttributionRequest; 268 269 /** @type {Values<typeof UrlbarUtils.RESULT_SOURCE>} */ 270 let resultSource = UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL; 271 if (lazy.UrlbarPrefs.get("suggest.bookmark")) { 272 let bookmark = await lazy.PlacesUtils.bookmarks.fetch({ 273 url: new URL(payload.url), 274 }); 275 // Check if query has been cancelled. 276 if (instance != this.queryInstance) { 277 break; 278 } 279 if (bookmark) { 280 resultSource = UrlbarUtils.RESULT_SOURCE.BOOKMARKS; 281 } 282 } 283 284 let result = new lazy.UrlbarResult({ 285 type: UrlbarUtils.RESULT_TYPE.URL, 286 source: resultSource, 287 payload, 288 }); 289 addCallback(this, result); 290 break; 291 } 292 case "search": { 293 let engine = await lazy.UrlbarSearchUtils.engineForAlias(site.title); 294 295 if (!engine && site.url) { 296 // Look up the engine by its domain. 297 let host = URL.parse(site.url)?.hostname; 298 if (host) { 299 engine = ( 300 await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host) 301 )[0]; 302 } 303 } 304 305 if (!engine) { 306 // No engine found. We skip this Top Site. 307 break; 308 } 309 310 if (instance != this.queryInstance) { 311 break; 312 } 313 314 let result = new lazy.UrlbarResult({ 315 type: UrlbarUtils.RESULT_TYPE.SEARCH, 316 source: UrlbarUtils.RESULT_SOURCE.SEARCH, 317 payload: { 318 keyword: site.title, 319 providesSearchMode: true, 320 engine: engine.name, 321 query: "", 322 icon: site.favicon, 323 isPinned: site.isPinned, 324 }, 325 }); 326 addCallback(this, result); 327 break; 328 } 329 default: 330 this.logger.error(`Unknown Top Site type: ${site.type}`); 331 break; 332 } 333 } 334 } 335 336 onImpression(state, queryContext, controller, providerVisibleResults) { 337 if (queryContext.isPrivate) { 338 return; 339 } 340 341 providerVisibleResults.forEach(({ index, result }) => { 342 if (result?.payload.isSponsored) { 343 Glean.contextualServicesTopsites.impression[`urlbar_${index}`].add(1); 344 } 345 }); 346 } 347 348 /** 349 * Once initialized, contains an array of weak 350 * references of top sites listener functions. 351 * 352 * @type {?{get: Function}[]} 353 */ 354 static #topSitesListeners = null; 355 356 /** 357 * Adds a listener function that will be called when the top sites change or 358 * they are enabled/disabled. This class will hold a weak reference to the 359 * listener, so you do not need to unregister it, but you or someone else must 360 * keep a strong reference to it to keep it from being immediately garbage 361 * collected. 362 * 363 * @param {Function} callback 364 * The listener function. This class will hold a weak reference to it. 365 */ 366 static addTopSitesListener(callback) { 367 // Lazily init observers. 368 if (!UrlbarProviderTopSites.#topSitesListeners) { 369 UrlbarProviderTopSites.#topSitesListeners = []; 370 let callListeners = UrlbarProviderTopSites.#callTopSitesListeners; 371 if (Services.prefs.getBoolPref("browser.topsites.component.enabled")) { 372 Services.obs.addObserver(callListeners, "topsites-refreshed"); 373 } else { 374 Services.obs.addObserver(callListeners, "newtab-top-sites-changed"); 375 } 376 for (let pref of TOP_SITES_ENABLED_PREFS) { 377 Services.prefs.addObserver(pref, callListeners); 378 } 379 } 380 UrlbarProviderTopSites.#topSitesListeners.push( 381 Cu.getWeakReference(callback) 382 ); 383 } 384 385 static #callTopSitesListeners() { 386 for (let i = 0; i < UrlbarProviderTopSites.#topSitesListeners.length; ) { 387 let listener = UrlbarProviderTopSites.#topSitesListeners[i].get(); 388 if (!listener) { 389 // The listener has been GC'ed, so remove it from our list. 390 UrlbarProviderTopSites.#topSitesListeners.splice(i, 1); 391 } else { 392 listener(); 393 ++i; 394 } 395 } 396 } 397 398 /** 399 * The number of top site rows to display by default. 400 * 401 * @type {number|undefined} 402 */ 403 static topSitesRows; 404 }