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:
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;
}