tor-browser

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

commit ab4eceb2b570842ab371311bf9380f2371c2a838
parent 7a900dbac9a10eb387737f8de472d884423265e8
Author: scottdowne <sdowne@mozilla.com>
Date:   Tue,  6 Jan 2026 21:09:55 +0000

Bug 2008356 - Newtab frecency boosted topsites moving into new standalone module. r=home-newtab-reviewers,ini

Differential Revision: https://phabricator.services.mozilla.com/D277761

Diffstat:
Abrowser/extensions/newtab/lib/FrecencyBoostProvider/FrecencyBoostProvider.mjs | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/lib/TopSitesFeed.sys.mjs | 178++++++++-----------------------------------------------------------------------
Mbrowser/extensions/newtab/test/xpcshell/test_TopSitesFeed_frecencyDeduping.js | 5++++-
Mbrowser/extensions/newtab/test/xpcshell/test_TopSitesFeed_frecencyRanking.js | 7++++++-
4 files changed, 229 insertions(+), 164 deletions(-)

diff --git a/browser/extensions/newtab/lib/FrecencyBoostProvider/FrecencyBoostProvider.mjs b/browser/extensions/newtab/lib/FrecencyBoostProvider/FrecencyBoostProvider.mjs @@ -0,0 +1,203 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// AppConstants, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + Utils: "resource://services-settings/Utils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("FrecencyBoostProvider"); +}); + +ChromeUtils.defineLazyGetter(lazy, "pageFrecencyThreshold", () => { + // @backward-compat { version 147 } + // Frecency was changed in 147 Nightly. + if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "147.0a1") >= 0) { + // 30 days ago, 5 visits. The threshold avoids one non-typed visit from + // immediately being included in recent history to mimic the original + // threshold which aimed to prevent first-run visits from being included in + // Top Sites. + return lazy.PlacesUtils.history.pageFrecencyThreshold(30, 5, false); + } + // The old threshold used for classic frecency: Slightly over one visit. + return 101; +}); + +const RS_FALLBACK_BASE_URL = + "https://firefox-settings-attachments.cdn.mozilla.net/"; +const SPONSORED_TILE_PARTNER_FREC_BOOST = "frec-boost"; + +export class FrecencyBoostProvider { + constructor(frecentCache) { + this.frecentCache = frecentCache; + this._frecencyBoostedSponsors = new Map(); + this._frecencyBoostRS = null; + } + + get _frecencyBoostRemoteSettings() { + if (!this._frecencyBoostRS) { + this._frecencyBoostRS = lazy.RemoteSettings( + "newtab-frecency-boosted-sponsors" + ); + } + return this._frecencyBoostRS; + } + + /** + * Import all sponsors from Remote Settings and save their favicons. + * This is called lazily when frecency boosted spocs are first requested. + * We fetch all favicons regardless of whether the user has visited these sites. + */ + async _importFrecencyBoostedSponsors() { + const records = await this._frecencyBoostRemoteSettings.get(); + + const userRegion = lazy.Region.home || ""; + const regionRecords = records.filter( + record => record.region === userRegion + ); + + await Promise.all( + regionRecords.map(record => + this._importFrecencyBoostedSponsor(record).catch(error => { + lazy.log.warn( + `Failed to import sponsor ${record.title || "unknown"}`, + error + ); + }) + ) + ); + } + + /** + * Import a single sponsor record and fetch its favicon as data URI. + * + * @param {object} record - Remote Settings record with title, domain, redirect_url, and attachment + */ + async _importFrecencyBoostedSponsor(record) { + const { title, domain, redirect_url, attachment } = record; + const faviconDataURI = await this._fetchSponsorFaviconAsDataURI(attachment); + const hostname = lazy.NewTabUtils.shortURL({ url: domain }); + + const sponsorData = { + title, + domain, + hostname, + redirectURL: redirect_url, + faviconDataURI, + }; + + this._frecencyBoostedSponsors.set(hostname, sponsorData); + } + + /** + * Fetch favicon from Remote Settings attachment and return as data URI. + * + * @param {object} attachment - Remote Settings attachment object + * @returns {Promise<string|null>} Favicon data URI, or null on error + */ + async _fetchSponsorFaviconAsDataURI(attachment) { + let baseAttachmentURL = RS_FALLBACK_BASE_URL; + try { + baseAttachmentURL = await lazy.Utils.baseAttachmentsURL(); + } catch (error) { + lazy.log.warn( + `Error fetching remote settings base url from CDN. Falling back to ${RS_FALLBACK_BASE_URL}`, + error + ); + } + + const faviconURL = baseAttachmentURL + attachment.location; + const response = await fetch(faviconURL); + + const blob = await response.blob(); + const dataURI = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => resolve(reader.result)); + reader.addEventListener("error", reject); + reader.readAsDataURL(blob); + }); + + return dataURI; + } + + /** + * Build frecency-boosted spocs from a list of sponsor domains by checking Places history. + * Checks if domains exist in history, dedupes against organic topsites, + * and returns all matches sorted by frecency. + * + * @param {Array} sponsors - List of sponsor domain objects with hostname and title + * @param {Integer} numItems - Number of frecency items to check against. + * @returns {Array} Array of sponsored tile objects sorted by frecency, or empty array + */ + async buildFrecencyBoostedSpocs(sponsorsToCheck, numItems) { + if (!sponsorsToCheck.length) { + return []; + } + + const topsiteFrecency = lazy.pageFrecencyThreshold; + + // Get all frecent sites from history. + const frecent = await this.frecentCache.request({ + numItems, + topsiteFrecency, + }); + + const candidates = []; + frecent.forEach(site => { + const normalizedSiteUrl = lazy.NewTabUtils.shortURL(site); + for (const domainObj of sponsorsToCheck) { + if ( + normalizedSiteUrl !== domainObj.hostname || + lazy.NewTabUtils.blockedLinks.isBlocked({ url: domainObj.domain }) + ) { + continue; + } + + candidates.push({ + hostname: domainObj.hostname, + url: domainObj.redirectURL, + label: domainObj.title, + partner: SPONSORED_TILE_PARTNER_FREC_BOOST, + type: "frecency-boost", + frecency: site.frecency, + show_sponsored_label: true, + favicon: domainObj.faviconDataURI, + faviconSize: 96, + }); + } + }); + + candidates.sort((a, b) => b.frecency - a.frecency); + return candidates; + } + + async getLinks(numItems) { + if (this._frecencyBoostedSponsors.size === 0) { + await this._importFrecencyBoostedSponsors(); + } + + const domainList = Array.from(this._frecencyBoostedSponsors.values()); + return this.buildFrecencyBoostedSpocs(domainList, numItems); + } +} diff --git a/browser/extensions/newtab/lib/TopSitesFeed.sys.mjs b/browser/extensions/newtab/lib/TopSitesFeed.sys.mjs @@ -47,7 +47,6 @@ ChromeUtils.defineESModuleGetters(lazy, { RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", Screenshots: "resource://newtab/lib/Screenshots.sys.mjs", - Utils: "resource://services-settings/Utils.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "log", () => { @@ -159,9 +158,6 @@ const DISPLAY_FAIL_REASON_OVERSOLD = "oversold"; const DISPLAY_FAIL_REASON_DISMISSED = "dismissed"; const DISPLAY_FAIL_REASON_UNRESOLVED = "unresolved"; -const RS_FALLBACK_BASE_URL = - "https://firefox-settings-attachments.cdn.mozilla.net/"; - ChromeUtils.defineLazyGetter(lazy, "userAgent", () => { return Cc["@mozilla.org/network/protocol;1?name=http"].getService( Ci.nsIHttpProtocolHandler @@ -170,6 +166,7 @@ ChromeUtils.defineLazyGetter(lazy, "userAgent", () => { // Smart shortcuts import { RankShortcutsProvider } from "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs"; +import { FrecencyBoostProvider } from "resource://newtab/lib/FrecencyBoostProvider/FrecencyBoostProvider.mjs"; const PREF_SYSTEM_SHORTCUTS_PERSONALIZATION = "discoverystream.shortcuts.personalization.enabled"; @@ -885,8 +882,6 @@ export class TopSitesFeed { this._telemetryUtility = new TopSitesTelemetry(); this._contile = new ContileIntegration(this); this._tippyTopProvider = new TippyTopProvider(); - this._frecencyBoostedSponsors = new Map(); - this._frecencyBoostRS = null; ChromeUtils.defineLazyGetter( this, "_currentSearchHostname", @@ -903,6 +898,7 @@ export class TopSitesFeed { // Refresh if no old options or requesting more items !(oldOptions.numItems >= newOptions.numItems) ); + this.frecencyBoostProvider = new FrecencyBoostProvider(this.frecentCache); this.pinnedCache = new lazy.LinksCache( lazy.NewTabUtils.pinnedLinks, "links", @@ -1381,172 +1377,30 @@ export class TopSitesFeed { return fetch(...args); } - get _frecencyBoostRemoteSettings() { - if (!this._frecencyBoostRS) { - this._frecencyBoostRS = lazy.RemoteSettings( - "newtab-frecency-boosted-sponsors" - ); - } - return this._frecencyBoostRS; - } - - /** - * Import all sponsors from Remote Settings and save their favicons. - * This is called lazily when frecency boosted spocs are first requested. - * We fetch all favicons regardless of whether the user has visited these sites. - */ - async _importFrecencyBoostedSponsors() { - const records = await this._frecencyBoostRemoteSettings.get(); - - const userRegion = lazy.Region.home || ""; - const regionRecords = records.filter( - record => record.region === userRegion - ); - - await Promise.all( - regionRecords.map(record => - this._importFrecencyBoostedSponsor(record).catch(error => { - lazy.log.warn( - `Failed to import sponsor ${record.title || "unknown"}`, - error - ); - }) - ) - ); - } - - /** - * Import a single sponsor record and fetch its favicon as data URI. - * - * @param {object} record - Remote Settings record with title, domain, redirect_url, and attachment - */ - async _importFrecencyBoostedSponsor(record) { - const { title, domain, redirect_url, attachment } = record; - const faviconDataURI = await this._fetchSponsorFaviconAsDataURI(attachment); - const hostname = lazy.NewTabUtils.shortURL({ url: domain }); - - const sponsorData = { - title, - domain, - hostname, - redirectURL: redirect_url, - faviconDataURI, - }; - - this._frecencyBoostedSponsors.set(hostname, sponsorData); - } - - /** - * Fetch favicon from Remote Settings attachment and return as data URI. - * - * @param {object} attachment - Remote Settings attachment object - * @returns {Promise<string|null>} Favicon data URI, or null on error - */ - async _fetchSponsorFaviconAsDataURI(attachment) { - let baseAttachmentURL = RS_FALLBACK_BASE_URL; - try { - baseAttachmentURL = await lazy.Utils.baseAttachmentsURL(); - } catch (error) { - lazy.log.warn( - `Error fetching remote settings base url from CDN. Falling back to ${RS_FALLBACK_BASE_URL}`, - error - ); - } - - const faviconURL = baseAttachmentURL + attachment.location; - const response = await fetch(faviconURL); - - const blob = await response.blob(); - const dataURI = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", () => resolve(reader.result)); - reader.addEventListener("error", reject); - reader.readAsDataURL(blob); - }); - - return dataURI; - } - - /** - * Build frecency-boosted spocs from a list of sponsor domains by checking Places history. - * Checks if domains exist in history, dedupes against organic topsites, - * and returns all matches sorted by frecency. - * - * @param {Array} sponsors - List of sponsor domain objects with hostname and title - * @returns {Array} Array of sponsored tile objects sorted by frecency, or empty array - */ - async buildFrecencyBoostedSpocs(sponsorsToCheck = []) { - if (!sponsorsToCheck.length) { - return []; - } - - const { values } = this.store.getState().Prefs; - const numItems = - values?.trainhopConfig?.sov?.numItems || DEFAULT_SOV_NUM_ITEMS; - const topsiteFrecency = lazy.pageFrecencyThreshold; - - // Get all frecent sites from history. - const frecent = await this.frecentCache.request({ - numItems, - topsiteFrecency, - }); - - const candidates = []; - frecent.forEach(site => { - const normalizedSiteUrl = lazy.NewTabUtils.shortURL(site); - for (const domainObj of sponsorsToCheck) { - if ( - normalizedSiteUrl !== domainObj.hostname || - lazy.NewTabUtils.blockedLinks.isBlocked({ url: domainObj.domain }) - ) { - continue; - } - - candidates.push({ - hostname: domainObj.hostname, - url: domainObj.redirectURL, - label: domainObj.title, - partner: SPONSORED_TILE_PARTNER_FREC_BOOST, - type: "frecency-boost", - frecency: site.frecency, - show_sponsored_label: true, - favicon: domainObj.faviconDataURI, - faviconSize: 96, - }); - } - }); - - // If we have a matched set of candidates, - // we can check if it's an exposure event. - if (candidates.length) { - this.frecencyBoostedSpocsExposureEvent(); - } - - candidates.sort((a, b) => b.frecency - a.frecency); - return candidates; - } - /** * Fetch topsites spocs that are frecency boosted. * * @returns {Array} An array of sponsored tile objects. */ async fetchFrecencyBoostedSpocs() { + let candidates = []; if ( - !this._contile.sovEnabled() || - !this.store.getState().Prefs.values[SHOW_SPONSORED_PREF] + this._contile.sovEnabled() && + this.store.getState().Prefs.values[SHOW_SPONSORED_PREF] ) { - return []; - } - - if (this._frecencyBoostedSponsors.size === 0) { - await this._importFrecencyBoostedSponsors(); - } + const { values } = this.store.getState().Prefs; + const numItems = + values?.trainhopConfig?.sov?.numItems || DEFAULT_SOV_NUM_ITEMS; - const domainList = Array.from(this._frecencyBoostedSponsors.values()); + candidates = await this.frecencyBoostProvider.getLinks(numItems); - // Find all matches from the sponsor domains, sorted by frecency - return this.buildFrecencyBoostedSpocs(domainList); + // If we have a matched set of candidates, + // we can check if it's an exposure event. + if (candidates.length) { + this.frecencyBoostedSpocsExposureEvent(); + } + } + return candidates; } /** diff --git a/browser/extensions/newtab/test/xpcshell/test_TopSitesFeed_frecencyDeduping.js b/browser/extensions/newtab/test/xpcshell/test_TopSitesFeed_frecencyDeduping.js @@ -47,6 +47,7 @@ function getTopSitesFeedForTest(sandbox, { frecent = [], contile = [] } = {}) { }, cache: frecent, }; + feed.frecencyBoostProvider.frecentCache = feed.frecentCache; feed._contile = { sov: true, @@ -77,7 +78,9 @@ function getTopSitesFeedForTest(sandbox, { frecent = [], contile = [] } = {}) { ], ]); - sandbox.stub(feed, "_frecencyBoostedSponsors").value(frecencyBoostedSponsors); + sandbox + .stub(feed.frecencyBoostProvider, "_frecencyBoostedSponsors") + .value(frecencyBoostedSponsors); // We need to refresh, because TopSitesFeed's // DEFAULT_TOP_SITES acts like a singleton. diff --git a/browser/extensions/newtab/test/xpcshell/test_TopSitesFeed_frecencyRanking.js b/browser/extensions/newtab/test/xpcshell/test_TopSitesFeed_frecencyRanking.js @@ -31,12 +31,15 @@ function getTopSitesFeedForTest(sandbox, { frecent = [] } = {}) { }, }, }; + feed.frecentCache = { request() { return this.cache; }, cache: frecent, }; + feed.frecencyBoostProvider.frecentCache = feed.frecentCache; + const frecencyBoostedSponsors = new Map([ [ "domain1", @@ -90,7 +93,9 @@ function getTopSitesFeedForTest(sandbox, { frecent = [] } = {}) { ], ]); - sandbox.stub(feed, "_frecencyBoostedSponsors").value(frecencyBoostedSponsors); + sandbox + .stub(feed.frecencyBoostProvider, "_frecencyBoostedSponsors") + .value(frecencyBoostedSponsors); return feed; }