Bug 1713596 - Block all sponsored tiles from a sponsor upon the dismissal r=dao

This patch implements the sponsor level blocking for the sponsored Top Sites.

When a sponsored top site gets dismissed, Firefox will extract the sponsor (hostname) out of the URL, and persist it to the pref `browser.topsites.blockedSponsors` as a JSON array. When Firefox fetches the sponsored tiles from Contile again, it will filter all the tiles whose sponsor has been blocked before.

Differential Revision: https://phabricator.services.mozilla.com/D116413
This commit is contained in:
Nan Jiang 2021-06-04 14:58:57 +00:00
Родитель 597ec6c1d5
Коммит f1bf8c386b
9 изменённых файлов: 175 добавлений и 7 удалений

Просмотреть файл

@ -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 } : {}),
})),
}),

Просмотреть файл

@ -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
} : {})

Просмотреть файл

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

Просмотреть файл

@ -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})`

Просмотреть файл

@ -114,7 +114,7 @@ describe("<CollectionCardGrid>", () => {
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("<CollectionCardGrid>", () => {
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");

Просмотреть файл

@ -73,6 +73,7 @@ describe("<DSTextPromo>", () => {
{
url: undefined,
pocket_id: undefined,
isSponsoredTopSite: undefined,
},
]);

Просмотреть файл

@ -357,6 +357,7 @@ describe("<LinkMenu>", () => {
{
url: FAKE_SITE.url,
pocket_id: FAKE_SITE.pocket_id,
isSponsoredTopSite: undefined,
},
],
menu_action_webext_dismiss: {

Просмотреть файл

@ -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() {} } };

Просмотреть файл

@ -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,