diff --git a/browser/components/newtab/content-src/lib/link-menu-options.js b/browser/components/newtab/content-src/lib/link-menu-options.js index cb45c0df2dc8..e0ede337f92c 100644 --- a/browser/components/newtab/content-src/lib/link-menu-options.js +++ b/browser/components/newtab/content-src/lib/link-menu-options.js @@ -86,6 +86,8 @@ export const LinkMenuOptions = { url: site.original_url || site.open_url || site.url, // pocket_id is only for pocket stories being in highlights, and then dismissed. pocket_id: site.pocket_id, + // used by PlacesFeed and TopSitesFeed for sponsored top sites blocking. + isSponsoredTopSite: site.sponsored_position, ...(site.flight_id ? { flight_id: site.flight_id } : {}), })), }), diff --git a/browser/components/newtab/data/content/activity-stream.bundle.js b/browser/components/newtab/data/content/activity-stream.bundle.js index 03251bd0e713..2b9e7694ac15 100644 --- a/browser/components/newtab/data/content/activity-stream.bundle.js +++ b/browser/components/newtab/data/content/activity-stream.bundle.js @@ -4521,6 +4521,8 @@ const LinkMenuOptions = { url: site.original_url || site.open_url || site.url, // pocket_id is only for pocket stories being in highlights, and then dismissed. pocket_id: site.pocket_id, + // used by PlacesFeed and TopSitesFeed for sponsored top sites blocking. + isSponsoredTopSite: site.sponsored_position, ...(site.flight_id ? { flight_id: site.flight_id } : {}) diff --git a/browser/components/newtab/lib/PlacesFeed.jsm b/browser/components/newtab/lib/PlacesFeed.jsm index bb5d7ec4162b..cc705765e82d 100644 --- a/browser/components/newtab/lib/PlacesFeed.jsm +++ b/browser/components/newtab/lib/PlacesFeed.jsm @@ -8,6 +8,9 @@ const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { actionCreators: ac, actionTypes: at } = ChromeUtils.import( "resource://activity-stream/common/Actions.jsm" ); +const { shortURL } = ChromeUtils.import( + "resource://activity-stream/lib/ShortURL.jsm" +); ChromeUtils.defineModuleGetter( this, @@ -28,6 +31,11 @@ ChromeUtils.defineModuleGetter( const LINK_BLOCKED_EVENT = "newtab-linkBlocked"; const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events +// The pref to store the blocked sponsors of the sponsored Top Sites. +// The value of this pref is an array (JSON serialized) of hostnames of the +// blocked sponsors. +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; + /** * Observer - a wrapper around history/bookmark observers to add the QueryInterface. */ @@ -438,6 +446,24 @@ class PlacesFeed { urlBar.addEventListener("paste", checkFirstChange); } + /** + * Add the hostnames of the given urls to the Top Sites sponsor blocklist. + * + * @param {array} urls + * An array of the objects structured as `{ url }` + */ + addToBlockedTopSitesSponsors(urls) { + const blockedPref = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + const merged = new Set([...blockedPref, ...urls.map(url => shortURL(url))]); + + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + JSON.stringify([...merged]) + ); + } + onAction(action) { switch (action.type) { case at.INIT: @@ -457,10 +483,17 @@ class PlacesFeed { } case at.BLOCK_URL: { if (action.data) { + let sponsoredTopSites = []; action.data.forEach(site => { - const { url, pocket_id } = site; + const { url, pocket_id, isSponsoredTopSite } = site; NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); + if (isSponsoredTopSite) { + sponsoredTopSites.push({ url }); + } }); + if (sponsoredTopSites.length) { + this.addToBlockedTopSitesSponsors(sponsoredTopSites); + } } break; } diff --git a/browser/components/newtab/lib/TopSitesFeed.jsm b/browser/components/newtab/lib/TopSitesFeed.jsm index 3bb0a5bf2088..70a7780d608b 100644 --- a/browser/components/newtab/lib/TopSitesFeed.jsm +++ b/browser/components/newtab/lib/TopSitesFeed.jsm @@ -110,6 +110,7 @@ const DEFAULT_SITES_EXPERIMENTS_PREF_BRANCH = "browser.topsites.experiment."; const CONTILE_ENABLED_PREF = "browser.topsites.contile.enabled"; const CONTILE_ENDPOINT_PREF = "browser.topsites.contile.endpoint"; const CONTILE_UPDATE_INTERVAL = 15 * 60 * 1000; // 15 minutes +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; function getShortURLForCurrentSearch() { const url = shortURL({ url: Services.search.defaultEngine.searchForm }); @@ -142,6 +143,19 @@ class ContileIntegration { } } + /** + * Filter the tiles whose sponsor is on the Top Sites sponsor blocklist. + * + * @param {array} tiles + * An array of the tile objects + */ + _filterBlockedSponsors(tiles) { + const blocklist = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + return tiles.filter(tile => !blocklist.includes(shortURL(tile))); + } + async _fetchSites() { if ( !Services.prefs.getBoolPref(CONTILE_ENABLED_PREF) || @@ -172,6 +186,7 @@ class ContileIntegration { const body = await response.json(); if (body?.tiles && Array.isArray(body.tiles)) { let { tiles } = body; + tiles = this._filterBlockedSponsors(tiles); if (tiles.length > MAX_NUM_SPONSORED) { Cu.reportError( `Contile provided more links than permitted. (${tiles.length} received, limit is ${MAX_NUM_SPONSORED})` diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx index a0e9d69d189f..fd09626190d1 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/CollectionCardGrid.test.jsx @@ -114,7 +114,7 @@ describe("", () => { assert.ok(!wrapper.exists(".ds-collection-card-grid")); }); - it("should dispath telemety events on dismiss", () => { + it("should dispatch telemety events on dismiss", () => { wrapper.instance().onDismissClick(); const firstCall = dispatchStub.getCall(0); @@ -123,9 +123,9 @@ describe("", () => { assert.equal(firstCall.args[0].type, "BLOCK_URL"); assert.deepEqual(firstCall.args[0].data, [ - { url: "123", pocket_id: undefined }, - { url: "456", pocket_id: undefined }, - { url: "789", pocket_id: undefined }, + { url: "123", pocket_id: undefined, isSponsoredTopSite: undefined }, + { url: "456", pocket_id: undefined, isSponsoredTopSite: undefined }, + { url: "789", pocket_id: undefined, isSponsoredTopSite: undefined }, ]); assert.equal(secondCall.args[0].type, "TELEMETRY_USER_EVENT"); diff --git a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx index e1e4b6bedf98..5bdef31dd075 100644 --- a/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/DiscoveryStreamComponents/DSTextPromo.test.jsx @@ -73,6 +73,7 @@ describe("", () => { { url: undefined, pocket_id: undefined, + isSponsoredTopSite: undefined, }, ]); diff --git a/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx b/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx index 5b214f0dbc49..4770e8a173e7 100644 --- a/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx +++ b/browser/components/newtab/test/unit/content-src/components/LinkMenu.test.jsx @@ -357,6 +357,7 @@ describe("", () => { { url: FAKE_SITE.url, pocket_id: FAKE_SITE.pocket_id, + isSponsoredTopSite: undefined, }, ], menu_action_webext_dismiss: { diff --git a/browser/components/newtab/test/unit/lib/PlacesFeed.test.js b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js index 89becde4793b..caeaf93b318c 100644 --- a/browser/components/newtab/test/unit/lib/PlacesFeed.test.js +++ b/browser/components/newtab/test/unit/lib/PlacesFeed.test.js @@ -1,7 +1,6 @@ import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; import { GlobalOverrider } from "test/unit/utils"; -import { PlacesFeed } from "lib/PlacesFeed.jsm"; -const { BookmarksObserver, PlacesObserver } = PlacesFeed; +import injector from "inject!lib/PlacesFeed.jsm"; const FAKE_BOOKMARK = { bookmarkGuid: "xi31", @@ -20,10 +19,16 @@ const SOURCES = { const BLOCKED_EVENT = "newtab-linkBlocked"; // The event dispatched in NewTabUtils when a link is blocked; +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; + describe("PlacesFeed", () => { + let PlacesFeed; + let BookmarksObserver; + let PlacesObserver; let globals; let sandbox; let feed; + let shortURLStub; beforeEach(() => { globals = new GlobalOverrider(); sandbox = globals.sandbox; @@ -55,6 +60,11 @@ describe("PlacesFeed", () => { sandbox.spy(global.Services.obs, "addObserver"); sandbox.spy(global.Services.obs, "removeObserver"); sandbox.spy(global.Cu, "reportError"); + shortURLStub = sandbox + .stub() + .callsFake(site => + site.url.replace(/(.com|.ca)/, "").replace("https://", "") + ); global.Services.io.newURI = spec => ({ mutate: () => ({ @@ -77,6 +87,11 @@ describe("PlacesFeed", () => { }; }, }; + ({ PlacesFeed } = injector({ + "lib/ShortURL.jsm": { shortURL: shortURLStub }, + })); + BookmarksObserver = PlacesFeed.BookmarksObserver; + PlacesObserver = PlacesFeed.PlacesObserver; feed = new PlacesFeed(); feed.store = { dispatch: sinon.spy() }; }); @@ -101,6 +116,50 @@ describe("PlacesFeed", () => { assert.calledOnce(feed.store.dispatch); assert.equal(feed.store.dispatch.firstCall.args[0].type, action.type); }); + + describe("#addToBlockedTopSitesSponsors", () => { + let spy; + beforeEach(() => { + sandbox + .stub(global.Services.prefs, "getStringPref") + .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF) + .returns(`["foo","bar"]`); + spy = sandbox.spy(global.Services.prefs, "setStringPref"); + }); + afterEach(() => { + sandbox.restore(); + }); + + it("should add the blocked sponsors to the blocklist", () => { + feed.addToBlockedTopSitesSponsors([ + { url: "test.com" }, + { url: "test1.com" }, + ]); + + assert.calledOnce(spy); + const [, sponsors] = spy.firstCall.args; + assert.deepEqual( + new Set(["foo", "bar", "test", "test1"]), + new Set(JSON.parse(sponsors)) + ); + }); + + it("should not add duplicate sponsors to the blocklist", () => { + feed.addToBlockedTopSitesSponsors([ + { url: "foo.com" }, + { url: "bar.com" }, + { url: "test.com" }, + ]); + + assert.calledOnce(spy); + const [, sponsors] = spy.firstCall.args; + assert.deepEqual( + new Set(["foo", "bar", "test"]), + new Set(JSON.parse(sponsors)) + ); + }); + }); + describe("#onAction", () => { it("should add bookmark, history, places, blocked observers on INIT", () => { feed.onAction({ type: at.INIT }); @@ -161,6 +220,16 @@ describe("PlacesFeed", () => { pocket_id: 1234, }); }); + it("should update the blocked top sites sponsors", () => { + sandbox.stub(feed, "addToBlockedTopSitesSponsors"); + feed.onAction({ + type: at.BLOCK_URL, + data: [{ url: "foo.com", pocket_id: 1234, isSponsoredTopSite: 1 }], + }); + assert.calledWith(feed.addToBlockedTopSitesSponsors, [ + { url: "foo.com" }, + ]); + }); it("should bookmark a url on BOOKMARK_URL", () => { const data = { url: "pear.com", title: "A pear" }; const _target = { browser: { ownerGlobal() {} } }; diff --git a/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js b/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js index c3d5c5cdfb98..ff374d87f9f2 100644 --- a/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js +++ b/browser/components/newtab/test/unit/lib/TopSitesFeed.test.js @@ -29,6 +29,7 @@ const SEARCH_SHORTCUTS_HAVE_PINNED_PREF = const SHOWN_ON_NEWTAB_PREF = "feeds.topsites"; const SHOW_SPONSORED_PREF = "showSponsoredTopSites"; const CONTILE_ENABLED_PREF = "browser.topsites.contile.enabled"; +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; function FakeTippyTopProvider() {} FakeTippyTopProvider.prototype = { @@ -2026,6 +2027,10 @@ describe("Top Sites Feed", () => { .stub(global.Services.prefs, "getBoolPref") .withArgs(CONTILE_ENABLED_PREF) .returns(true); + sandbox + .stub(global.Services.prefs, "getStringPref") + .withArgs(TOP_SITES_BLOCKED_SPONSORS_PREF) + .returns(`["foo","bar"]`); }); afterEach(() => { sandbox.restore(); @@ -2062,6 +2067,46 @@ describe("Top Sites Feed", () => { assert.equal(feed._contile.sites.length, 2); }); + it("should filter the blocked sponsors", async () => { + fetchStub.resolves({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + tiles: [ + { + url: "https://www.test.com", + image_url: "images/test-com.png", + click_url: "https://www.test-click.com", + impression_url: "https://www.test-impression.com", + name: "test", + }, + { + url: "https://foo.com", + image_url: "images/foo-com.png", + click_url: "https://www.foo-click.com", + impression_url: "https://www.foo-impression.com", + name: "foo", + }, + { + url: "https://bar.com", + image_url: "images/bar-com.png", + click_url: "https://www.bar-click.com", + impression_url: "https://www.bar-impression.com", + name: "bar", + }, + ], + }), + }); + + const fetched = await feed._contile._fetchSites(); + + assert.ok(fetched); + // Both "foo" and "bar" should be filtered + assert.equal(feed._contile.sites.length, 1); + assert.equal(feed._contile.sites[0].url, "https://www.test.com"); + }); + it("should handle errors properly from Contile", async () => { fetchStub.resolves({ ok: false,