399 lines
11 KiB
JavaScript
399 lines
11 KiB
JavaScript
/* 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/. */
|
|
|
|
const lazy = {
|
|
Utils: "resource://services-settings/Utils.sys.mjs",
|
|
};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
|
|
ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
|
|
PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
|
|
});
|
|
|
|
import {
|
|
actionTypes as at,
|
|
actionCreators as ac,
|
|
} from "resource://newtab/common/Actions.mjs";
|
|
|
|
// Prefs for AdsFeeds to run
|
|
const PREF_UNIFIED_ADS_ADSFEED_ENABLED = "unifiedAds.adsFeed.enabled";
|
|
const PREF_UNIFIED_ADS_ADSFEED_TILES_ENABLED =
|
|
"unifiedAds.adsFeed.tiles.enabled";
|
|
const PREF_UNIFIED_ADS_ADSFEED_SPOCS_ENABLED =
|
|
"unifiedAds.adsFeed.spocs.enabled";
|
|
|
|
const PREF_UNIFIED_ADS_BLOCKED_LIST = "unifiedAds.blockedAds";
|
|
const PREF_UNIFIED_ADS_COUNTS = "discoverystream.placements.tiles.counts";
|
|
const PREF_UNIFIED_ADS_ENDPOINT = "unifiedAds.endpoint";
|
|
const PREF_UNIFIED_ADS_PLACEMENTS = "discoverystream.placements.tiles";
|
|
|
|
// Prefs to enable UAPI ad types
|
|
const PREF_UNIFIED_ADS_SPOCS_ENABLED = "unifiedAds.spocs.enabled";
|
|
const PREF_UNIFIED_ADS_TILES_ENABLED = "unifiedAds.tiles.enabled";
|
|
|
|
// Primary pref that is toggled when enabling top site sponsored tiles
|
|
const PREF_FEED_TOPSITES = "feeds.topsites";
|
|
const PREF_SHOW_SPONSORED_TOPSITES = "showSponsoredTopSites";
|
|
|
|
// Primary pref that is toggled when enabling sponsored stories
|
|
const PREF_FEED_SECTIONS_TOPSTORIES = "feeds.section.topstories";
|
|
const PREF_SHOW_SPONSORED = "showSponsored";
|
|
const PREF_SYSTEM_SHOW_SPONSORED = "system.showSponsored";
|
|
|
|
const CACHE_KEY = "ads_feed";
|
|
const ADS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
|
|
|
|
export class AdsFeed {
|
|
constructor() {
|
|
this.enabled = false;
|
|
this.loaded = false;
|
|
this.lastUpdated = null;
|
|
this.tiles = [];
|
|
this.cache = this.PersistentCache(CACHE_KEY, true);
|
|
}
|
|
|
|
async _resetCache() {
|
|
if (this.cache) {
|
|
await this.cache.set("ads", {});
|
|
}
|
|
}
|
|
|
|
async resetAdsFeed() {
|
|
await this._resetCache();
|
|
this.tiles = [];
|
|
this.lastUpdated = null;
|
|
this.loaded = false;
|
|
this.enabled = false;
|
|
}
|
|
|
|
isEnabled() {
|
|
this.loaded = true;
|
|
|
|
// Check if AdsFeed is enabled
|
|
const adsFeedEnabled =
|
|
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_ADSFEED_ENABLED];
|
|
|
|
// Check all known prefs that top site tiles are enabled
|
|
const tilesEnabled = this.store.getState().Prefs.values[PREF_FEED_TOPSITES];
|
|
|
|
const sponsoredTilesEnabled =
|
|
this.store.getState().Prefs.values[PREF_SHOW_SPONSORED_TOPSITES] &&
|
|
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_TILES_ENABLED];
|
|
|
|
// Check all known prefs that spocs are enabled
|
|
const sponsoredStoriesEnabled =
|
|
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED] &&
|
|
this.store.getState().Prefs.values[PREF_SYSTEM_SHOW_SPONSORED] &&
|
|
this.store.getState().Prefs.values[PREF_SHOW_SPONSORED];
|
|
|
|
const storiesEnabled =
|
|
this.store.getState().Prefs.values[PREF_FEED_SECTIONS_TOPSTORIES];
|
|
|
|
// Confirm at least one ads section (tiles, spocs) are enabled to enable the adsfeed
|
|
if (
|
|
(tilesEnabled && sponsoredTilesEnabled) ||
|
|
(storiesEnabled && sponsoredStoriesEnabled)
|
|
) {
|
|
if (adsFeedEnabled) {
|
|
this.enabled = true;
|
|
}
|
|
|
|
return adsFeedEnabled;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* This thin wrapper around global.fetch makes it easier for us to write
|
|
* automated tests that simulate responses from this fetch.
|
|
*/
|
|
fetch(...args) {
|
|
return fetch(...args);
|
|
}
|
|
|
|
/**
|
|
* Normalize new Unified Ads API response into
|
|
* previous Contile ads response
|
|
*/
|
|
_normalizeTileData(data) {
|
|
const formattedTileData = [];
|
|
const responseTilesData = Object.values(data);
|
|
|
|
// Bug 1930653: Confirm response has data before iterating
|
|
if (responseTilesData?.length) {
|
|
for (const tileData of responseTilesData) {
|
|
const [tile] = tileData;
|
|
|
|
const formattedData = {
|
|
id: tile.block_key,
|
|
block_key: tile.block_key,
|
|
name: tile.name,
|
|
url: tile.url,
|
|
click_url: tile.callbacks.click,
|
|
image_url: tile.image_url,
|
|
impression_url: tile.callbacks.impression,
|
|
image_size: 200,
|
|
};
|
|
|
|
formattedTileData.push(formattedData);
|
|
}
|
|
}
|
|
|
|
return { tiles: formattedTileData };
|
|
}
|
|
|
|
/**
|
|
* Return object of supported ad types to query from MARS API from the adsfeed file
|
|
* @returns {object}
|
|
*/
|
|
getSupportedAdTypes() {
|
|
const supportsAdsFeedTiles =
|
|
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_TILES_ENABLED] &&
|
|
this.store.getState().Prefs.values[
|
|
PREF_UNIFIED_ADS_ADSFEED_TILES_ENABLED
|
|
];
|
|
|
|
const supportsAdsFeedSpocs =
|
|
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED] &&
|
|
this.store.getState().Prefs.values[
|
|
PREF_UNIFIED_ADS_ADSFEED_SPOCS_ENABLED
|
|
];
|
|
|
|
return {
|
|
tiles: supportsAdsFeedTiles,
|
|
spocs: supportsAdsFeedSpocs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get ads data either from cache or from API and
|
|
* broadcast the data via at.ADS_UPDATE_DATA event
|
|
* @param {boolean} isStartup=false - This is only used for reporting
|
|
* and is passed to the update functions meta attribute
|
|
* @returns {void}
|
|
*/
|
|
async getAdsData(isStartup = false) {
|
|
const supportedAdTypes = this.getSupportedAdTypes();
|
|
const cachedData = (await this.cache.get()) || {};
|
|
|
|
const { ads } = cachedData;
|
|
const adsCacheValid = ads
|
|
? this.Date().now() - ads.lastUpdated < ADS_UPDATE_TIME
|
|
: false;
|
|
|
|
let data = null;
|
|
|
|
// Get new data if necessary or default to cache
|
|
if (!ads?.lastUpdated || !adsCacheValid) {
|
|
// Fetch new data
|
|
data = await this.fetchData(supportedAdTypes);
|
|
data.lastUpdated = this.Date().now();
|
|
} else {
|
|
// Use cached data
|
|
data = ads;
|
|
}
|
|
|
|
if (!data) {
|
|
throw new Error(`No data available`);
|
|
}
|
|
|
|
// Update tile information if tile data is supported
|
|
if (supportedAdTypes.tiles) {
|
|
this.tiles = data.tiles;
|
|
}
|
|
|
|
this.lastUpdated = data.lastUpdated;
|
|
await this.update(isStartup);
|
|
}
|
|
|
|
/**
|
|
* Fetch data from the Mozilla Ad Routing Service (MARS) unified ads API
|
|
* This function is designed to get whichever ads types are needed (tiles, spocs)
|
|
* @param {array} supportedAdTypes
|
|
* @returns {object} Response object containing ad information from MARS
|
|
*/
|
|
async fetchData(supportedAdTypes) {
|
|
const state = this.store.getState();
|
|
const headers = new Headers();
|
|
headers.append("content-type", "application/json");
|
|
|
|
const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT];
|
|
const marsOhttpEnabled = Services.prefs.getBoolPref(
|
|
"browser.newtabpage.activity-stream.unifiedAds.ohttp.enabled",
|
|
false
|
|
);
|
|
const ohttpRelayURL = Services.prefs.getStringPref(
|
|
"browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL",
|
|
""
|
|
);
|
|
const ohttpConfigURL = Services.prefs.getStringPref(
|
|
"browser.newtabpage.activity-stream.discoverystream.ohttp.configURL",
|
|
""
|
|
);
|
|
|
|
let blockedSponsors =
|
|
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_BLOCKED_LIST];
|
|
|
|
// Overwrite URL to Unified Ads endpoint
|
|
const fetchUrl = `${endpointBaseUrl}v1/ads`;
|
|
|
|
let placements;
|
|
let counts;
|
|
|
|
// Determine which data needs to be fetched
|
|
if (supportedAdTypes.tiles) {
|
|
placements = state.Prefs.values[PREF_UNIFIED_ADS_PLACEMENTS]?.split(`,`)
|
|
.map(s => s.trim())
|
|
.filter(item => item);
|
|
counts = state.Prefs.values[PREF_UNIFIED_ADS_COUNTS]?.split(`,`)
|
|
.map(s => s.trim())
|
|
.filter(item => item)
|
|
.map(item => parseInt(item, 10));
|
|
}
|
|
|
|
let fetchPromise;
|
|
|
|
const controller = new AbortController();
|
|
const { signal } = controller;
|
|
|
|
const options = {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
context_id: await lazy.ContextId.request(),
|
|
placements: placements.map((placement, index) => ({
|
|
placement,
|
|
count: counts[index],
|
|
})),
|
|
blocks: blockedSponsors.split(","),
|
|
credentials: "omit",
|
|
}),
|
|
signal,
|
|
};
|
|
|
|
if (marsOhttpEnabled && ohttpConfigURL && ohttpRelayURL) {
|
|
let config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL);
|
|
if (!config) {
|
|
console.error(
|
|
new Error(
|
|
`OHTTP was configured for ${fetchUrl} but we couldn't fetch a valid config`
|
|
)
|
|
);
|
|
return null;
|
|
}
|
|
fetchPromise = lazy.ObliviousHTTP.ohttpRequest(
|
|
ohttpRelayURL,
|
|
config,
|
|
fetchUrl,
|
|
options
|
|
);
|
|
} else {
|
|
fetchPromise = this.fetch(fetchUrl, options);
|
|
}
|
|
|
|
const response = await fetchPromise;
|
|
|
|
// If supportedAdTypes tiles type, normalize it!
|
|
if (supportedAdTypes.tiles) {
|
|
const responseData = await response.json();
|
|
const normalizedTileData = this._normalizeTileData(responseData);
|
|
return normalizedTileData;
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Init function that runs only from onAction at.INIT call.
|
|
* @param {boolean} isStartup=false
|
|
* @returns {void}
|
|
*/
|
|
async init(isStartup = false) {
|
|
if (this.isEnabled()) {
|
|
await this.getAdsData(isStartup);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets cached data and dispatches ADS_UPDATE_DATA event to update store with new ads data
|
|
* @param {boolean} isStartup
|
|
* @returns {void}
|
|
*/
|
|
async update(isStartup) {
|
|
await this.cache.set("ads", {
|
|
tiles: this.tiles,
|
|
lastUpdated: this.lastUpdated,
|
|
});
|
|
|
|
// TODO: Submit data based on supportedTypes
|
|
this.store.dispatch(
|
|
ac.BroadcastToContent({
|
|
type: at.ADS_UPDATE_DATA,
|
|
data: {
|
|
tiles: this.tiles,
|
|
},
|
|
meta: {
|
|
isStartup,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
async onPrefChangedAction(action) {
|
|
switch (action.data.name) {
|
|
case PREF_FEED_SECTIONS_TOPSTORIES:
|
|
case PREF_FEED_TOPSITES:
|
|
case PREF_SHOW_SPONSORED_TOPSITES:
|
|
case PREF_SHOW_SPONSORED:
|
|
case PREF_SYSTEM_SHOW_SPONSORED:
|
|
case PREF_UNIFIED_ADS_ADSFEED_TILES_ENABLED:
|
|
case PREF_UNIFIED_ADS_ADSFEED_SPOCS_ENABLED:
|
|
case PREF_UNIFIED_ADS_ADSFEED_ENABLED:
|
|
if (this.isEnabled()) {
|
|
await this.getAdsData(false);
|
|
} else {
|
|
await this.resetAdsFeed();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
async onAction(action) {
|
|
switch (action.type) {
|
|
case at.INIT:
|
|
await this.init(true /* isStartup */);
|
|
break;
|
|
case at.UNINIT:
|
|
break;
|
|
case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK:
|
|
case at.SYSTEM_TICK:
|
|
if (this.isEnabled()) {
|
|
await this.getAdsData(false);
|
|
}
|
|
break;
|
|
case at.PREF_CHANGED:
|
|
await this.onPrefChangedAction(action);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creating a thin wrapper around PersistentCache, ObliviousHTTP and Date.
|
|
* This makes it easier for us to write automated tests that simulate responses.
|
|
*/
|
|
|
|
AdsFeed.prototype.PersistentCache = (...args) => {
|
|
return new lazy.PersistentCache(...args);
|
|
};
|
|
|
|
AdsFeed.prototype.Date = () => {
|
|
return Date;
|
|
};
|
|
|
|
AdsFeed.prototype.ObliviousHTTP = (...args) => {
|
|
return lazy.ObliviousHTTP(...args);
|
|
};
|