tor-browser

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

commit 706c0c30068f984b28a3701c575dc5bd128ec944
parent 007eaddbe00b09a792be8bb3c6b0a9b610021e09
Author: Nathan Barrett <nbarrett@mozilla.com>
Date:   Wed, 12 Nov 2025 22:22:12 +0000

Bug 1997956 - add fallback section layout configuration r=thecount,home-newtab-reviewers

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

Diffstat:
Mbrowser/extensions/newtab/lib/ActivityStream.sys.mjs | 7+++++++
Mbrowser/extensions/newtab/lib/DiscoveryStreamFeed.sys.mjs | 20++++++++++++++++++++
Abrowser/extensions/newtab/lib/SectionsLayoutManager.sys.mjs | 605+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/test/unit/lib/DiscoveryStreamFeed.test.js | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 738 insertions(+), 0 deletions(-)

diff --git a/browser/extensions/newtab/lib/ActivityStream.sys.mjs b/browser/extensions/newtab/lib/ActivityStream.sys.mjs @@ -1308,6 +1308,13 @@ export const PREFS_CONFIG = new Map([ }, ], [ + "discoverystream.sections.clientLayout.enabled", + { + title: "Enables client side layout for recommended stories", + value: false, + }, + ], + [ "support.url", { title: "Link to HNT's support page", diff --git a/browser/extensions/newtab/lib/DiscoveryStreamFeed.sys.mjs b/browser/extensions/newtab/lib/DiscoveryStreamFeed.sys.mjs @@ -5,6 +5,7 @@ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ContextId: "moz-src:///browser/modules/ContextId.sys.mjs", + DEFAULT_SECTION_LAYOUT: "resource://newtab/lib/SectionsLayoutManager.sys.mjs", NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs", @@ -123,6 +124,8 @@ const PREF_VISIBLE_SECTIONS = "discoverystream.sections.interestPicker.visibleSections"; const PREF_PRIVATE_PING_ENABLED = "telemetry.privatePing.enabled"; const PREF_SURFACE_ID = "telemetry.surfaceId"; +const PREF_CLIENT_LAYOUT_ENABLED = + "discoverystream.sections.clientLayout.enabled"; let getHardcodedLayout; @@ -1877,6 +1880,9 @@ export class DiscoveryStreamFeed { })); if (sectionsEnabled) { + const useClientLayout = + this.store.getState().Prefs.values[PREF_CLIENT_LAYOUT_ENABLED]; + for (const [sectionKey, sectionData] of Object.entries( feedResponse.feeds )) { @@ -1903,6 +1909,7 @@ export class DiscoveryStreamFeed { isTimeSensitive: item.isTimeSensitive, }); } + sections.push({ sectionKey, title: sectionData.title, @@ -1915,6 +1922,19 @@ export class DiscoveryStreamFeed { }); } } + + if (useClientLayout || sections.some(s => !s.layout)) { + sections.sort((a, b) => a.receivedRank - b.receivedRank); + + sections.forEach((section, index) => { + if (useClientLayout || !section.layout) { + section.layout = + lazy.DEFAULT_SECTION_LAYOUT[ + index % lazy.DEFAULT_SECTION_LAYOUT.length + ]; + } + }); + } } const { data: scoredItems, personalized } = await this.scoreItems( diff --git a/browser/extensions/newtab/lib/SectionsLayoutManager.sys.mjs b/browser/extensions/newtab/lib/SectionsLayoutManager.sys.mjs @@ -0,0 +1,605 @@ +/* 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/. */ + +export const DEFAULT_SECTION_LAYOUT = [ + { + name: "7-double-row-2-ad", + responsiveLayouts: [ + { + columnCount: 4, + tiles: [ + { + size: "large", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 2, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: false, + }, + { + size: "medium", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 5, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 4, + hasAd: true, + hasExcerpt: true, + }, + { + size: "medium", + position: 6, + hasAd: false, + hasExcerpt: true, + }, + ], + }, + { + columnCount: 3, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 2, + hasAd: true, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 5, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 4, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 6, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + { + columnCount: 2, + tiles: [ + { + size: "large", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "medium", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 4, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 5, + hasAd: true, + hasExcerpt: true, + }, + { + size: "medium", + position: 6, + hasAd: false, + hasExcerpt: true, + }, + ], + }, + { + columnCount: 1, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "medium", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 4, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 5, + hasAd: true, + hasExcerpt: true, + }, + { + size: "medium", + position: 6, + hasAd: false, + hasExcerpt: true, + }, + ], + }, + ], + }, + { + name: "6-small-medium-1-ad", + responsiveLayouts: [ + { + columnCount: 4, + tiles: [ + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 4, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 5, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + { + columnCount: 3, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 2, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 3, + hasAd: true, + hasExcerpt: true, + }, + { + size: "medium", + position: 4, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 5, + hasAd: false, + hasExcerpt: true, + }, + ], + }, + { + columnCount: 2, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 4, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 5, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + { + columnCount: 1, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 4, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 5, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + ], + }, + { + name: "4-large-small-medium-1-ad", + responsiveLayouts: [ + { + columnCount: 4, + tiles: [ + { + size: "large", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + { + columnCount: 3, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + { + columnCount: 2, + tiles: [ + { + size: "large", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + { + columnCount: 1, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + ], + }, + { + name: "4-medium-small-1-ad", + responsiveLayouts: [ + { + columnCount: 4, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 2, + hasAd: false, + hasExcerpt: true, + }, + { size: "medium", position: 3, hasAd: true, hasExcerpt: true }, + ], + }, + { + columnCount: 3, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + { + columnCount: 2, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + { + columnCount: 1, + tiles: [ + { + size: "medium", + position: 0, + hasAd: false, + hasExcerpt: true, + }, + { + size: "medium", + position: 1, + hasAd: true, + hasExcerpt: true, + }, + { + size: "small", + position: 2, + hasAd: false, + hasExcerpt: false, + }, + { + size: "small", + position: 3, + hasAd: false, + hasExcerpt: false, + }, + ], + }, + ], + }, +]; diff --git a/browser/extensions/newtab/test/unit/lib/DiscoveryStreamFeed.test.js b/browser/extensions/newtab/test/unit/lib/DiscoveryStreamFeed.test.js @@ -10,6 +10,7 @@ import { RecommendationProvider } from "lib/RecommendationProvider.sys.mjs"; import { reducers } from "common/Reducers.sys.mjs"; import { PersistentCache } from "lib/PersistentCache.sys.mjs"; +import { DEFAULT_SECTION_LAYOUT } from "lib/SectionsLayoutManager.sys.mjs"; const CONFIG_PREF_NAME = "discoverystream.config"; const ENDPOINTS_PREF_NAME = "discoverystream.endpoints"; @@ -3743,6 +3744,111 @@ describe("DiscoveryStreamFeed", () => { assert.deepEqual(feedData, expectedData); }); + + describe("client layout for sections", () => { + beforeEach(() => { + setPref("discoverystream.sections.enabled", true); + globals.set("DEFAULT_SECTION_LAYOUT", DEFAULT_SECTION_LAYOUT); + const fakeCache = {}; + sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache)); + sandbox.stub(feed, "rotate").callsFake(val => val); + sandbox + .stub(feed, "scoreItems") + .callsFake(val => ({ data: val, filtered: [], personalized: false })); + sandbox.stub(feed, "fetchFromEndpoint").resolves({ + recommendedAt: 1755834072383, + surfaceId: "NEW_TAB_EN_US", + data: [], + feeds: { + "section-1": { + title: "Section 1", + subtitle: "Subtitle 1", + receivedFeedRank: 1, + layout: { name: "original-layout" }, + iab: "iab-category", + isInitiallyVisible: true, + recommendations: [], + }, + "section-2": { + title: "Section 2", + subtitle: "Subtitle 2", + receivedFeedRank: 2, + layout: { name: "another-layout" }, + iab: "iab-category-2", + isInitiallyVisible: true, + recommendations: [], + }, + }, + }); + }); + it("should return default layout when sections.clientLayout.enabled is false and server returns a layout object", async () => { + const feedData = await feed.getComponentFeed("url"); + assert.equal(feedData.data.sections.length, 2); + assert.equal( + feedData.data.sections[0].layout.name, + "original-layout", + "First section should use original layout from server" + ); + assert.equal( + feedData.data.sections[1].layout.name, + "another-layout", + "Second section should use second default layout" + ); + }); + it("should apply client layout when sections.clientLayout.enabled is true", async () => { + setPref("discoverystream.sections.clientLayout.enabled", true); + const feedData = await feed.getComponentFeed("url"); + + assert.equal( + feedData.data.sections[0].layout.name, + "7-double-row-2-ad", + "First section should use first default layout" + ); + assert.equal( + feedData.data.sections[1].layout.name, + "6-small-medium-1-ad", + "Second section should use second default layout" + ); + }); + it("should apply client layout when any section has a missing layout property", async () => { + feed.fetchFromEndpoint.resolves({ + recommendedAt: 1755834072383, + surfaceId: "NEW_TAB_EN_US", + data: [], + feeds: { + "section-1": { + title: "Section 1", + subtitle: "Subtitle 1", + receivedFeedRank: 1, + iab: "iab-category", + isInitiallyVisible: true, + recommendations: [], + }, + "section-2": { + title: "Section 2", + subtitle: "Subtitle 2", + receivedFeedRank: 2, + layout: { name: "another-layout" }, + iab: "iab-category-2", + isInitiallyVisible: true, + recommendations: [], + }, + }, + }); + const feedData = await feed.getComponentFeed("url"); + + assert.equal( + feedData.data.sections[0].layout.name, + "7-double-row-2-ad", + "First section without layout should use client default layout" + ); + assert.equal( + feedData.data.sections[1].layout.name, + "another-layout", + "Second section with layout should keep its original layout" + ); + }); + }); }); describe("#getContextualAdsPlacements", () => {