fune/browser/components/newtab/lib/DiscoveryStreamFeed.jsm
2019-10-17 16:37:12 +00:00

1656 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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/. */
"use strict";
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"NewTabUtils",
"resource://gre/modules/NewTabUtils.jsm"
);
const { setTimeout, clearTimeout } = ChromeUtils.import(
"resource://gre/modules/Timer.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"Services",
"resource://gre/modules/Services.jsm"
);
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
ChromeUtils.defineModuleGetter(
this,
"perfService",
"resource://activity-stream/common/PerfService.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"UserDomainAffinityProvider",
"resource://activity-stream/lib/UserDomainAffinityProvider.jsm"
);
const { actionTypes: at, actionCreators: ac } = ChromeUtils.import(
"resource://activity-stream/common/Actions.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PersistentCache",
"resource://activity-stream/lib/PersistentCache.jsm"
);
XPCOMUtils.defineLazyServiceGetters(this, {
gUUIDGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
});
const CACHE_KEY = "discovery_stream";
const LAYOUT_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const STARTUP_CACHE_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 1 week
const COMPONENT_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const SPOCS_FEEDS_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const DEFAULT_RECS_EXPIRE_TIME = 60 * 60 * 1000; // 1 hour
const MIN_DOMAIN_AFFINITIES_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours
const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server
const DEFAULT_MAX_HISTORY_QUERY_RESULTS = 1000;
const FETCH_TIMEOUT = 45 * 1000;
const PREF_CONFIG = "discoverystream.config";
const PREF_ENDPOINTS = "discoverystream.endpoints";
const PREF_IMPRESSION_ID = "browser.newtabpage.activity-stream.impressionId";
const PREF_ENABLED = "discoverystream.enabled";
const PREF_HARDCODED_BASIC_LAYOUT = "discoverystream.hardcoded-basic-layout";
const PREF_SPOCS_ENDPOINT = "discoverystream.spocs-endpoint";
const PREF_TOPSTORIES = "feeds.section.topstories";
const PREF_SPOCS_CLEAR_ENDPOINT = "discoverystream.endpointSpocsClear";
const PREF_SHOW_SPONSORED = "showSponsored";
const PREF_SPOC_IMPRESSIONS = "discoverystream.spoc.impressions";
const PREF_CAMPAIGN_BLOCKS = "discoverystream.campaign.blocks";
const PREF_REC_IMPRESSIONS = "discoverystream.rec.impressions";
let getHardcodedLayout;
this.DiscoveryStreamFeed = class DiscoveryStreamFeed {
constructor() {
// Internal state for checking if we've intialized all our data
this.loaded = false;
// Persistent cache for remote endpoint data.
this.cache = new PersistentCache(CACHE_KEY, true);
this._impressionId = this.getOrCreateImpressionId();
// Internal in-memory cache for parsing json prefs.
this._prefCache = {};
}
getOrCreateImpressionId() {
let impressionId = Services.prefs.getCharPref(PREF_IMPRESSION_ID, "");
if (!impressionId) {
impressionId = String(gUUIDGenerator.generateUUID());
Services.prefs.setCharPref(PREF_IMPRESSION_ID, impressionId);
}
return impressionId;
}
/**
* Send SPOCS Fill telemetry.
* @param {object} filteredItems An object keyed on filter reasons, and the value
* is a list of SPOCS.
* reasons: blocked_by_user, frequency_cap, below_min_score, campaign_duplicate
* @param {boolean} fullRecalc A boolean indicating if it's a full recalculation.
* Calling `loadSpocs` will be treated as a full recalculation.
* Whereas responding the action "DISCOVERY_STREAM_SPOC_IMPRESSION"
* is not a full recalculation.
*/
_sendSpocsFill(filteredItems, fullRecalc) {
const full_recalc = fullRecalc ? 1 : 0;
const spocsFill = [];
for (const [reason, items] of Object.entries(filteredItems)) {
items.forEach(item => {
// Only send SPOCS (i.e. it has a campaign_id)
if (item.campaign_id) {
spocsFill.push({ reason, full_recalc, id: item.id, displayed: 0 });
}
});
}
if (spocsFill.length) {
this.store.dispatch(
ac.DiscoveryStreamSpocsFill({ spoc_fills: spocsFill })
);
}
}
finalLayoutEndpoint(url, apiKey) {
if (url.includes("$apiKey") && !apiKey) {
throw new Error(
`Layout Endpoint - An API key was specified but none configured: ${url}`
);
}
return url.replace("$apiKey", apiKey);
}
get config() {
if (this._prefCache.config) {
return this._prefCache.config;
}
try {
this._prefCache.config = JSON.parse(
this.store.getState().Prefs.values[PREF_CONFIG]
);
const layoutUrl = this._prefCache.config.layout_endpoint;
const apiKeyPref = this._prefCache.config.api_key_pref;
if (layoutUrl && apiKeyPref) {
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
this._prefCache.config.layout_endpoint = this.finalLayoutEndpoint(
layoutUrl,
apiKey
);
}
} catch (e) {
// istanbul ignore next
this._prefCache.config = {};
// istanbul ignore next
Cu.reportError(
`Could not parse preference. Try resetting ${PREF_CONFIG} in about:config. ${e}`
);
}
this._prefCache.config.enabled =
this._prefCache.config.enabled &&
this.store.getState().Prefs.values[PREF_ENABLED];
return this._prefCache.config;
}
resetConfigDefauts() {
this.store.dispatch({
type: at.CLEAR_PREF,
data: {
name: PREF_CONFIG,
},
});
}
get showSpocs() {
// Combine user-set sponsored opt-out with Mozilla-set config
return (
this.store.getState().Prefs.values[PREF_SHOW_SPONSORED] &&
this.config.show_spocs
);
}
get personalized() {
return this.config.personalized;
}
setupPrefs() {
// Send the initial state of the pref on our reducer
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_CONFIG_SETUP,
data: this.config,
})
);
}
uninitPrefs() {
// Reset in-memory cache
this._prefCache = {};
}
async fetchFromEndpoint(rawEndpoint, options = {}) {
if (!rawEndpoint) {
Cu.reportError("Tried to fetch endpoint but none was configured.");
return null;
}
const apiKeyPref = this._prefCache.config.api_key_pref;
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
// The server somtimes returns this value already replaced, but we try this for two reasons:
// 1. Layout endpoints are not from the server.
// 2. Hardcoded layouts don't have this already done for us.
const endpoint = rawEndpoint.replace("$apiKey", apiKey);
try {
// Make sure the requested endpoint is allowed
const allowed = this.store
.getState()
.Prefs.values[PREF_ENDPOINTS].split(",");
if (!allowed.some(prefix => endpoint.startsWith(prefix))) {
throw new Error(`Not one of allowed prefixes (${allowed})`);
}
const controller = new AbortController();
const { signal } = controller;
const fetchPromise = fetch(endpoint, {
...options,
credentials: "omit",
signal,
});
// istanbul ignore next
const timeoutId = setTimeout(() => {
controller.abort();
}, FETCH_TIMEOUT);
const response = await fetchPromise;
if (!response.ok) {
throw new Error(`Unexpected status (${response.status})`);
}
clearTimeout(timeoutId);
return response.json();
} catch (error) {
Cu.reportError(`Failed to fetch ${endpoint}: ${error.message}`);
}
return null;
}
/**
* Returns true if data in the cache for a particular key has expired or is missing.
* @param {object} cachedData data returned from cache.get()
* @param {string} key a cache key
* @param {string?} url for "feed" only, the URL of the feed.
* @param {boolean} is this check done at initial browser load
*/
isExpired({ cachedData, key, url, isStartup }) {
const { layout, spocs, feeds } = cachedData;
const updateTimePerComponent = {
layout: LAYOUT_UPDATE_TIME,
spocs: SPOCS_FEEDS_UPDATE_TIME,
feed: COMPONENT_FEEDS_UPDATE_TIME,
};
const EXPIRATION_TIME = isStartup
? STARTUP_CACHE_EXPIRE_TIME
: updateTimePerComponent[key];
switch (key) {
case "layout":
// This never needs to expire, as it's not expected to change.
if (this.config.hardcoded_layout) {
return false;
}
return !layout || !(Date.now() - layout.lastUpdated < EXPIRATION_TIME);
case "spocs":
return !spocs || !(Date.now() - spocs.lastUpdated < EXPIRATION_TIME);
case "feed":
return (
!feeds ||
!feeds[url] ||
!(Date.now() - feeds[url].lastUpdated < EXPIRATION_TIME)
);
default:
// istanbul ignore next
throw new Error(`${key} is not a valid key`);
}
}
async _checkExpirationPerComponent() {
const cachedData = (await this.cache.get()) || {};
const { feeds } = cachedData;
return {
layout: this.isExpired({ cachedData, key: "layout" }),
spocs: this.isExpired({ cachedData, key: "spocs" }),
feeds:
!feeds ||
Object.keys(feeds).some(url =>
this.isExpired({ cachedData, key: "feed", url })
),
};
}
/**
* Returns true if any data for the cached endpoints has expired or is missing.
*/
async checkIfAnyCacheExpired() {
const expirationPerComponent = await this._checkExpirationPerComponent();
return (
expirationPerComponent.layout ||
expirationPerComponent.spocs ||
expirationPerComponent.feeds
);
}
async fetchLayout(isStartup) {
const cachedData = (await this.cache.get()) || {};
let { layout } = cachedData;
if (this.isExpired({ cachedData, key: "layout", isStartup })) {
const start = perfService.absNow();
const layoutResponse = await this.fetchFromEndpoint(
this.config.layout_endpoint
);
if (layoutResponse && layoutResponse.layout) {
this.layoutRequestTime = Math.round(perfService.absNow() - start);
layout = {
lastUpdated: Date.now(),
spocs: layoutResponse.spocs,
layout: layoutResponse.layout,
status: "success",
};
await this.cache.set("layout", layout);
} else {
Cu.reportError("No response for response.layout prop");
}
}
return layout;
}
updatePlacements(sendUpdate, layout) {
const placements = [];
const placementsMap = {};
for (const row of layout.filter(r => r.components && r.components.length)) {
for (const component of row.components) {
if (component.placement) {
// Throw away any dupes for the request.
if (!placementsMap[component.placement.name]) {
placementsMap[component.placement.name] = component.placement;
placements.push(component.placement);
}
}
}
}
if (placements.length) {
sendUpdate({
type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
data: { placements },
});
}
}
async loadLayout(sendUpdate, isStartup) {
let layoutResp = {};
let url = "";
if (!this.config.hardcoded_layout) {
layoutResp = await this.fetchLayout(isStartup);
}
if (!layoutResp || !layoutResp.layout) {
// Set a hardcoded layout if one is needed.
// Changing values in this layout in memory object is unnecessary.
layoutResp = getHardcodedLayout(
this.config.hardcoded_basic_layout ||
this.store.getState().Prefs.values[PREF_HARDCODED_BASIC_LAYOUT]
);
}
sendUpdate({
type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
data: layoutResp,
});
if (layoutResp.spocs) {
url =
this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT] ||
this.config.spocs_endpoint ||
layoutResp.spocs.url;
if (
url &&
url !== this.store.getState().DiscoveryStream.spocs.spocs_endpoint
) {
sendUpdate({
type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
data: {
url,
spocs_per_domain: layoutResp.spocs.spocs_per_domain,
},
});
}
}
}
/**
* buildFeedPromise - Adds the promise result to newFeeds and
* pushes a promise to newsFeedsPromises.
* @param {Object} Has both newFeedsPromises (Array) and newFeeds (Object)
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Function} We return a function so we can contain
* the scope for isStartup and the promises object.
* Combines feed results and promises for each component with a feed.
*/
buildFeedPromise({ newFeedsPromises, newFeeds }, isStartup, sendUpdate) {
return component => {
const { url } = component.feed;
if (!newFeeds[url]) {
// We initially stub this out so we don't fetch dupes,
// we then fill in with the proper object inside the promise.
newFeeds[url] = {};
const feedPromise = this.getComponentFeed(url, isStartup);
feedPromise
.then(feed => {
newFeeds[url] = this.filterRecommendations(feed);
sendUpdate({
type: at.DISCOVERY_STREAM_FEED_UPDATE,
data: {
feed: newFeeds[url],
url,
},
});
// We grab affinities off the first feed for the moment.
// Ideally this would be returned from the server on the layout,
// or from another endpoint.
if (!this.affinities) {
const { settings } = feed.data;
this.affinities = {
timeSegments: settings.timeSegments,
parameterSets: settings.domainAffinityParameterSets,
maxHistoryQueryResults:
settings.maxHistoryQueryResults ||
DEFAULT_MAX_HISTORY_QUERY_RESULTS,
version: settings.version,
};
}
})
.catch(
/* istanbul ignore next */ error => {
Cu.reportError(
`Error trying to load component feed ${url}: ${error}`
);
}
);
newFeedsPromises.push(feedPromise);
}
};
}
filterRecommendations(feed) {
if (
feed &&
feed.data &&
feed.data.recommendations &&
feed.data.recommendations.length
) {
const { data: recommendations } = this.filterBlocked(
feed.data.recommendations
);
return {
...feed,
data: {
...feed.data,
recommendations,
},
};
}
return feed;
}
/**
* reduceFeedComponents - Filters out components with no feeds, and combines
* all feeds on this component with the feeds from other components.
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Function} We return a function so we can contain the scope for isStartup.
* Reduces feeds into promises and feed data.
*/
reduceFeedComponents(isStartup, sendUpdate) {
return (accumulator, row) => {
row.components
.filter(component => component && component.feed)
.forEach(this.buildFeedPromise(accumulator, isStartup, sendUpdate));
return accumulator;
};
}
/**
* buildFeedPromises - Filters out rows with no components,
* and gets us a promise for each unique feed.
* @param {Object} layout This is the Discovery Stream layout object.
* @param {Boolean} isStartup We have different cache handling for startup.
* @returns {Object} An object with newFeedsPromises (Array) and newFeeds (Object),
* we can Promise.all newFeedsPromises to get completed data in newFeeds.
*/
buildFeedPromises(layout, isStartup, sendUpdate) {
const initialData = {
newFeedsPromises: [],
newFeeds: {},
};
return layout
.filter(row => row && row.components)
.reduce(this.reduceFeedComponents(isStartup, sendUpdate), initialData);
}
async loadComponentFeeds(sendUpdate, isStartup) {
const { DiscoveryStream } = this.store.getState();
if (!DiscoveryStream || !DiscoveryStream.layout) {
return;
}
// Reset the flag that indicates whether or not at least one API request
// was issued to fetch the component feed in `getComponentFeed()`.
this.componentFeedFetched = false;
const start = perfService.absNow();
const { newFeedsPromises, newFeeds } = this.buildFeedPromises(
DiscoveryStream.layout,
isStartup,
sendUpdate
);
// Each promise has a catch already built in, so no need to catch here.
await Promise.all(newFeedsPromises);
if (this.componentFeedFetched) {
this.cleanUpTopRecImpressionPref(newFeeds);
this.componentFeedRequestTime = Math.round(perfService.absNow() - start);
}
await this.cache.set("feeds", newFeeds);
sendUpdate({
type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
});
}
placementsForEach(callback) {
const { placements } = this.store.getState().DiscoveryStream.spocs;
// Backwards comp for before we had placements, assume just a single spocs placement.
if (!placements || !placements.length) {
[{ name: "spocs" }].forEach(callback);
} else {
placements.forEach(callback);
}
}
async loadSpocs(sendUpdate, isStartup) {
const cachedData = (await this.cache.get()) || {};
let spocsState;
const { placements } = this.store.getState().DiscoveryStream.spocs;
if (this.showSpocs) {
spocsState = cachedData.spocs;
if (this.isExpired({ cachedData, key: "spocs", isStartup })) {
const endpoint = this.store.getState().DiscoveryStream.spocs
.spocs_endpoint;
const start = perfService.absNow();
const headers = new Headers();
headers.append("content-type", "application/json");
const apiKeyPref = this._prefCache.config.api_key_pref;
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
const spocsResponse = await this.fetchFromEndpoint(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
pocket_id: this._impressionId,
version: 1,
consumer_key: apiKey,
...(placements.length ? { placements } : {}),
}),
});
if (spocsResponse) {
this.spocsRequestTime = Math.round(perfService.absNow() - start);
spocsState = {
lastUpdated: Date.now(),
spocs: {
...spocsResponse,
},
};
this.cleanUpCampaignImpressionPref(spocsState.spocs);
await this.cache.set("spocs", spocsState);
} else {
Cu.reportError("No response for spocs_endpoint prop");
}
}
}
// Use good data if we have it, otherwise nothing.
// We can have no data if spocs set to off.
// We can have no data if request fails and there is no good cache.
// We want to send an update spocs or not, so client can render something.
spocsState =
spocsState && spocsState.spocs
? spocsState
: {
lastUpdated: Date.now(),
spocs: {},
};
let frequencyCapped = [];
let blockedItems = [];
let belowMinScore = [];
let campaignDupes = [];
this.placementsForEach(placement => {
const freshSpocs = spocsState.spocs[placement.name];
if (!freshSpocs || !freshSpocs.length) {
return;
}
const { data: capResult, filtered: caps } = this.frequencyCapSpocs(
freshSpocs
);
frequencyCapped = [...frequencyCapped, ...caps];
const { data: blockedResults, filtered: blocks } = this.filterBlocked(
capResult
);
blockedItems = [...blockedItems, ...blocks];
let { data: transformResult, filtered: transformFilter } = this.transform(
blockedResults
);
let {
below_min_score: minScoreFilter,
campaign_duplicate: dupes,
} = transformFilter;
belowMinScore = [...belowMinScore, ...minScoreFilter];
campaignDupes = [...campaignDupes, ...dupes];
spocsState.spocs = {
...spocsState.spocs,
[placement.name]: transformResult,
};
});
sendUpdate({
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data: {
lastUpdated: spocsState.lastUpdated,
spocs: spocsState.spocs,
},
});
// TODO make sure this works in other places we use it.
// TODO make sure to also validate all of these that they still contain the right ites in the array.
this._sendSpocsFill(
{
frequency_cap: frequencyCapped,
blocked_by_user: blockedItems,
below_min_score: belowMinScore,
campaign_duplicate: campaignDupes,
},
true
);
}
async clearSpocs() {
const endpoint = this.store.getState().Prefs.values[
PREF_SPOCS_CLEAR_ENDPOINT
];
if (!endpoint) {
return;
}
const headers = new Headers();
headers.append("content-type", "application/json");
await this.fetchFromEndpoint(endpoint, {
method: "DELETE",
headers,
body: JSON.stringify({
pocket_id: this._impressionId,
}),
});
}
async loadAffinityScoresCache() {
const cachedData = (await this.cache.get()) || {};
const { affinities } = cachedData;
if (this.personalized && affinities && affinities.scores) {
this.affinityProvider = new UserDomainAffinityProvider(
affinities.timeSegments,
affinities.parameterSets,
affinities.maxHistoryQueryResults,
affinities.version,
affinities.scores
);
this.domainAffinitiesLastUpdated = affinities._timestamp;
}
}
updateDomainAffinityScores() {
if (
!this.personalized ||
!this.affinities ||
!this.affinities.parameterSets ||
Date.now() - this.domainAffinitiesLastUpdated <
MIN_DOMAIN_AFFINITIES_UPDATE_TIME
) {
return;
}
this.affinityProvider = new UserDomainAffinityProvider(
this.affinities.timeSegments,
this.affinities.parameterSets,
this.affinities.maxHistoryQueryResults,
this.affinities.version,
undefined
);
const affinities = this.affinityProvider.getAffinities();
this.domainAffinitiesLastUpdated = Date.now();
affinities._timestamp = this.domainAffinitiesLastUpdated;
this.cache.set("affinities", affinities);
}
observe(subject, topic, data) {
switch (topic) {
case "idle-daily":
this.updateDomainAffinityScores();
break;
}
}
scoreItems(items) {
const filtered = [];
const data = items
.map(item => this.scoreItem(item))
// Remove spocs that are scored too low.
.filter(s => {
if (s.score >= s.min_score) {
return true;
}
filtered.push(s);
return false;
})
// Sort by highest scores.
.sort((a, b) => b.score - a.score);
return { data, filtered };
}
scoreItem(item) {
item.score = item.item_score;
item.min_score = item.min_score || 0;
if (item.score !== 0 && !item.score) {
item.score = 1;
}
if (this.personalized && this.affinityProvider) {
const scoreResult = this.affinityProvider.calculateItemRelevanceScore(
item
);
if (scoreResult === 0 || scoreResult) {
item.score = scoreResult;
}
}
return item;
}
filterBlocked(data) {
const filtered = [];
if (data && data.length) {
let campaigns = this.readDataPref(PREF_CAMPAIGN_BLOCKS);
const filteredItems = data.filter(item => {
const blocked =
NewTabUtils.blockedLinks.isBlocked({ url: item.url }) ||
campaigns[item.campaign_id];
if (blocked) {
filtered.push(item);
}
return !blocked;
});
return {
data: filteredItems,
filtered,
};
}
return { data, filtered };
}
transform(spocs) {
if (spocs && spocs.length) {
const spocsPerDomain =
this.store.getState().DiscoveryStream.spocs.spocs_per_domain || 1;
const campaignMap = {};
const campaignDuplicates = [];
// This order of operations is intended.
// scoreItems must be first because it creates this.score.
const { data: items, filtered: belowMinScoreItems } = this.scoreItems(
spocs
);
// This removes campaign dupes.
// We do this only after scoring and sorting because that way
// we can keep the first item we see, and end up keeping the highest scored.
const newSpocs = items.filter(s => {
if (!campaignMap[s.campaign_id]) {
campaignMap[s.campaign_id] = 1;
return true;
} else if (campaignMap[s.campaign_id] < spocsPerDomain) {
campaignMap[s.campaign_id]++;
return true;
}
campaignDuplicates.push(s);
return false;
});
return {
data: newSpocs,
filtered: {
below_min_score: belowMinScoreItems,
campaign_duplicate: campaignDuplicates,
},
};
}
return {
data: spocs,
filtered: {
below_min_score: [],
campaign_duplicate: [],
},
};
}
// Filter spocs based on frequency caps
//
// @param {Object} data An object that might have a SPOCS array.
// @returns {Object} An object with a property `data` as the result, and a property
// `filterItems` as the frequency capped items.
frequencyCapSpocs(spocs) {
if (spocs && spocs.length) {
const impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS);
const caps = [];
const result = spocs.filter(s => {
const isBelow = this.isBelowFrequencyCap(impressions, s);
if (!isBelow) {
caps.push(s);
}
return isBelow;
});
// send caps to redux if any.
if (caps.length) {
this.store.dispatch({
type: at.DISCOVERY_STREAM_SPOCS_CAPS,
data: caps,
});
}
return { data: result, filtered: caps };
}
return { data: spocs, filtered: [] };
}
// Frequency caps are based on campaigns, which may include multiple spocs.
// We currently support two types of frequency caps:
// - lifetime: Indicates how many times spocs from a campaign can be shown in total
// - period: Indicates how many times spocs from a campaign can be shown within a period
//
// So, for example, the feed configuration below defines that for campaign 1 no more
// than 5 spocs can be shown in total, and no more than 2 per hour.
// "campaign_id": 1,
// "caps": {
// "lifetime": 5,
// "campaign": {
// "count": 2,
// "period": 3600
// }
// }
isBelowFrequencyCap(impressions, spoc) {
const campaignImpressions = impressions[spoc.campaign_id];
if (!campaignImpressions) {
return true;
}
const lifetime = spoc.caps && spoc.caps.lifetime;
const lifeTimeCap = Math.min(
lifetime || MAX_LIFETIME_CAP,
MAX_LIFETIME_CAP
);
const lifeTimeCapExceeded = campaignImpressions.length >= lifeTimeCap;
if (lifeTimeCapExceeded) {
return false;
}
const campaignCap = spoc.caps && spoc.caps.campaign;
if (campaignCap) {
const campaignCapExceeded =
campaignImpressions.filter(
i => Date.now() - i < campaignCap.period * 1000
).length >= campaignCap.count;
return !campaignCapExceeded;
}
return true;
}
async retryFeed(feed) {
const { url } = feed;
const result = await this.getComponentFeed(url);
const newFeed = this.filterRecommendations(result);
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_FEED_UPDATE,
data: {
feed: newFeed,
url,
},
})
);
}
async getComponentFeed(feedUrl, isStartup) {
const cachedData = (await this.cache.get()) || {};
const { feeds } = cachedData;
let feed = feeds ? feeds[feedUrl] : null;
if (this.isExpired({ cachedData, key: "feed", url: feedUrl, isStartup })) {
const feedResponse = await this.fetchFromEndpoint(feedUrl);
if (feedResponse) {
const { data: scoredItems } = this.scoreItems(
feedResponse.recommendations
);
const { recsExpireTime } = feedResponse.settings;
const recommendations = this.rotate(scoredItems, recsExpireTime);
this.componentFeedFetched = true;
feed = {
lastUpdated: Date.now(),
data: {
settings: feedResponse.settings,
recommendations,
status: "success",
},
};
} else {
Cu.reportError("No response for feed");
}
}
// If we have no feed at this point, both fetch and cache failed for some reason.
return (
feed || {
data: {
status: "failed",
},
}
);
}
/**
* Called at startup to update cached data in the background.
*/
async _maybeUpdateCachedData() {
const expirationPerComponent = await this._checkExpirationPerComponent();
// Pass in `store.dispatch` to send the updates only to main
if (expirationPerComponent.layout) {
await this.loadLayout(this.store.dispatch);
}
if (expirationPerComponent.spocs) {
await this.loadSpocs(this.store.dispatch);
}
if (expirationPerComponent.feeds) {
await this.loadComponentFeeds(this.store.dispatch);
}
}
/**
* @typedef {Object} RefreshAllOptions
* @property {boolean} updateOpenTabs - Sends updates to open tabs immediately if true,
* updates in background if false
* @property {boolean} isStartup - When the function is called at browser startup
*
* Refreshes layout, component feeds, and spocs in order if caches have expired.
* @param {RefreshAllOptions} options
*/
async refreshAll(options = {}) {
const { updateOpenTabs, isStartup } = options;
const dispatch = updateOpenTabs
? action => this.store.dispatch(ac.BroadcastToContent(action))
: this.store.dispatch;
this.loadAffinityScoresCache();
await this.loadLayout(dispatch, isStartup);
await Promise.all([
this.loadSpocs(dispatch, isStartup).catch(error =>
Cu.reportError(`Error trying to load spocs feeds: ${error}`)
),
this.loadComponentFeeds(dispatch, isStartup).catch(error =>
Cu.reportError(`Error trying to load component feeds: ${error}`)
),
]);
if (isStartup) {
await this._maybeUpdateCachedData();
}
}
// We have to rotate stories on the client so that
// active stories are at the front of the list, followed by stories that have expired
// impressions i.e. have been displayed for longer than recsExpireTime.
rotate(recommendations, recsExpireTime) {
const maxImpressionAge = Math.max(
recsExpireTime * 1000 || DEFAULT_RECS_EXPIRE_TIME,
DEFAULT_RECS_EXPIRE_TIME
);
const impressions = this.readDataPref(PREF_REC_IMPRESSIONS);
const expired = [];
const active = [];
for (const item of recommendations) {
if (
impressions[item.id] &&
Date.now() - impressions[item.id] >= maxImpressionAge
) {
expired.push(item);
} else {
active.push(item);
}
}
return active.concat(expired);
}
/**
* Reports the cache age in second for Discovery Stream.
*/
async reportCacheAge() {
const cachedData = (await this.cache.get()) || {};
const { layout, spocs, feeds } = cachedData;
let cacheAge = Date.now();
let updated = false;
if (layout && layout.lastUpdated && layout.lastUpdated < cacheAge) {
updated = true;
cacheAge = layout.lastUpdated;
}
if (spocs && spocs.lastUpdated && spocs.lastUpdated < cacheAge) {
updated = true;
cacheAge = spocs.lastUpdated;
}
if (feeds) {
Object.keys(feeds).forEach(url => {
const feed = feeds[url];
if (feed.lastUpdated && feed.lastUpdated < cacheAge) {
updated = true;
cacheAge = feed.lastUpdated;
}
});
}
if (updated) {
this.store.dispatch(
ac.PerfEvent({
event: "DS_CACHE_AGE_IN_SEC",
value: Math.round((Date.now() - cacheAge) / 1000),
})
);
}
}
/**
* Reports various time durations when the feed is requested from endpoint for
* the first time. This could happen on the browser start-up, or the pref changes
* of discovery stream.
*
* Metrics to be reported:
* - Request time for layout endpoint
* - Request time for feed endpoint
* - Request time for spoc endpoint
* - Total request time for data completeness
*/
reportRequestTime() {
if (this.layoutRequestTime) {
this.store.dispatch(
ac.PerfEvent({
event: "LAYOUT_REQUEST_TIME",
value: this.layoutRequestTime,
})
);
}
if (this.spocsRequestTime) {
this.store.dispatch(
ac.PerfEvent({
event: "SPOCS_REQUEST_TIME",
value: this.spocsRequestTime,
})
);
}
if (this.componentFeedRequestTime) {
this.store.dispatch(
ac.PerfEvent({
event: "COMPONENT_FEED_REQUEST_TIME",
value: this.componentFeedRequestTime,
})
);
}
if (this.totalRequestTime) {
this.store.dispatch(
ac.PerfEvent({
event: "DS_FEED_TOTAL_REQUEST_TIME",
value: this.totalRequestTime,
})
);
}
}
async enable() {
// Note that cache age needs to be reported prior to refreshAll.
await this.reportCacheAge();
const start = perfService.absNow();
await this.refreshAll({ updateOpenTabs: true, isStartup: true });
Services.obs.addObserver(this, "idle-daily");
this.loaded = true;
this.totalRequestTime = Math.round(perfService.absNow() - start);
this.reportRequestTime();
}
async reset() {
this.resetDataPrefs();
await this.resetCache();
if (this.loaded) {
Services.obs.removeObserver(this, "idle-daily");
}
this.resetState();
}
async resetCache() {
await this.cache.set("layout", {});
await this.cache.set("feeds", {});
await this.cache.set("spocs", {});
await this.cache.set("affinities", {});
}
resetDataPrefs() {
this.writeDataPref(PREF_SPOC_IMPRESSIONS, {});
this.writeDataPref(PREF_REC_IMPRESSIONS, {});
this.writeDataPref(PREF_CAMPAIGN_BLOCKS, {});
}
resetState() {
// Reset reducer
this.store.dispatch(
ac.BroadcastToContent({ type: at.DISCOVERY_STREAM_LAYOUT_RESET })
);
this.loaded = false;
this.layoutRequestTime = undefined;
this.spocsRequestTime = undefined;
this.componentFeedRequestTime = undefined;
this.totalRequestTime = undefined;
}
async onPrefChange() {
// We always want to clear the cache/state if the pref has changed
await this.reset();
if (this.config.enabled) {
// Load data from all endpoints
await this.enable();
}
}
recordCampaignImpression(campaignId) {
let impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS);
const timeStamps = impressions[campaignId] || [];
timeStamps.push(Date.now());
impressions = { ...impressions, [campaignId]: timeStamps };
this.writeDataPref(PREF_SPOC_IMPRESSIONS, impressions);
}
recordTopRecImpressions(recId) {
let impressions = this.readDataPref(PREF_REC_IMPRESSIONS);
if (!impressions[recId]) {
impressions = { ...impressions, [recId]: Date.now() };
this.writeDataPref(PREF_REC_IMPRESSIONS, impressions);
}
}
recordBlockCampaignId(campaignId) {
const campaigns = this.readDataPref(PREF_CAMPAIGN_BLOCKS);
if (!campaigns[campaignId]) {
campaigns[campaignId] = 1;
this.writeDataPref(PREF_CAMPAIGN_BLOCKS, campaigns);
}
}
cleanUpCampaignImpressionPref(data) {
let campaignIds = [];
this.placementsForEach(placement => {
const newSpocs = data[placement.name];
if (!newSpocs) {
return;
}
campaignIds = [...campaignIds, ...newSpocs.map(s => `${s.campaign_id}`)];
});
if (campaignIds && campaignIds.length) {
this.cleanUpImpressionPref(
id => !campaignIds.includes(id),
PREF_SPOC_IMPRESSIONS
);
}
}
// Clean up rec impression pref by removing all stories that are no
// longer part of the response.
cleanUpTopRecImpressionPref(newFeeds) {
// Need to build a single list of stories.
const activeStories = Object.keys(newFeeds)
.filter(currentValue => newFeeds[currentValue].data)
.reduce((accumulator, currentValue) => {
const { recommendations } = newFeeds[currentValue].data;
return accumulator.concat(recommendations.map(i => `${i.id}`));
}, []);
this.cleanUpImpressionPref(
id => !activeStories.includes(id),
PREF_REC_IMPRESSIONS
);
}
writeDataPref(pref, impressions) {
this.store.dispatch(ac.SetPref(pref, JSON.stringify(impressions)));
}
readDataPref(pref) {
const prefVal = this.store.getState().Prefs.values[pref];
return prefVal ? JSON.parse(prefVal) : {};
}
cleanUpImpressionPref(isExpired, pref) {
const impressions = this.readDataPref(pref);
let changed = false;
Object.keys(impressions).forEach(id => {
if (isExpired(id)) {
changed = true;
delete impressions[id];
}
});
if (changed) {
this.writeDataPref(pref, impressions);
}
}
async onAction(action) {
switch (action.type) {
case at.INIT:
// During the initialization of Firefox:
// 1. Set-up listeners and initialize the redux state for config;
this.setupPrefs();
// 2. If config.enabled is true, start loading data.
if (this.config.enabled) {
await this.enable();
}
break;
case at.SYSTEM_TICK:
// Only refresh if we loaded once in .enable()
if (
this.config.enabled &&
this.loaded &&
(await this.checkIfAnyCacheExpired())
) {
await this.refreshAll({ updateOpenTabs: false });
}
break;
case at.DISCOVERY_STREAM_CONFIG_SET_VALUE:
// Use the original string pref to then set a value instead of
// this.config which has some modifications
this.store.dispatch(
ac.SetPref(
PREF_CONFIG,
JSON.stringify({
...JSON.parse(this.store.getState().Prefs.values[PREF_CONFIG]),
[action.data.name]: action.data.value,
})
)
);
break;
case at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS:
this.resetConfigDefauts();
break;
case at.DISCOVERY_STREAM_RETRY_FEED:
this.retryFeed(action.data.feed);
break;
case at.DISCOVERY_STREAM_CONFIG_CHANGE:
// When the config pref changes, load or unload data as needed.
await this.onPrefChange();
break;
case at.DISCOVERY_STREAM_IMPRESSION_STATS:
if (
action.data.tiles &&
action.data.tiles[0] &&
action.data.tiles[0].id
) {
this.recordTopRecImpressions(action.data.tiles[0].id);
}
break;
case at.DISCOVERY_STREAM_SPOC_IMPRESSION:
if (this.showSpocs) {
this.recordCampaignImpression(action.data.campaignId);
// Apply frequency capping to SPOCs in the redux store, only update the
// store if the SPOCs are changed.
const spocsState = this.store.getState().DiscoveryStream.spocs;
let frequencyCapped = [];
this.placementsForEach(placement => {
const freshSpocs = spocsState.data[placement.name];
if (!freshSpocs) {
return;
}
const { data: newSpocs, filtered } = this.frequencyCapSpocs(
freshSpocs
);
frequencyCapped = [...frequencyCapped, ...filtered];
spocsState.data = {
...spocsState.data,
[placement.name]: newSpocs,
};
});
if (frequencyCapped.length) {
this.store.dispatch(
ac.AlsoToPreloaded({
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
data: {
lastUpdated: spocsState.lastUpdated,
spocs: spocsState.data,
},
})
);
this._sendSpocsFill({ frequency_cap: frequencyCapped }, false);
}
}
break;
// This is fired from the browser, it has no concept of spocs, campaign or pocket.
// We match the blocked url with our available spoc urls to see if there is a match.
// I suspect we *could* instead do this in BLOCK_URL but I'm not sure.
case at.PLACES_LINK_BLOCKED:
if (this.showSpocs) {
const spocsState = this.store.getState().DiscoveryStream.spocs;
let spocsList = [];
this.placementsForEach(placement => {
const spocs = spocsState.data[placement.name];
if (spocs && spocs.length) {
spocsList = [...spocsList, ...spocs];
}
});
const filtered = spocsList.filter(s => s.url === action.data.url);
if (filtered.length) {
this._sendSpocsFill({ blocked_by_user: filtered }, false);
// If we're blocking a spoc, we want a slightly different treatment for open tabs.
// AlsoToPreloaded updates the source data and preloaded tabs with a new spoc.
// BroadcastToContent updates open tabs with a non spoc instead of a new spoc.
this.store.dispatch(
ac.AlsoToPreloaded({
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: action.data,
})
);
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_SPOC_BLOCKED,
data: action.data,
})
);
break;
}
}
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_LINK_BLOCKED,
data: action.data,
})
);
break;
case at.UNINIT:
// When this feed is shutting down:
this.uninitPrefs();
break;
case at.BLOCK_URL: {
// If we block a story that also has a campaign_id
// we want to record that as blocked too.
// This is because a single campaign might have slightly different urls.
const { campaign_id } = action.data;
if (campaign_id) {
this.recordBlockCampaignId(campaign_id);
}
break;
}
case at.PREF_CHANGED:
switch (action.data.name) {
case PREF_CONFIG:
case PREF_ENABLED:
case PREF_HARDCODED_BASIC_LAYOUT:
case PREF_SPOCS_ENDPOINT:
// Clear the cached config and broadcast the newly computed value
this._prefCache.config = null;
this.store.dispatch(
ac.BroadcastToContent({
type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
data: this.config,
})
);
break;
case PREF_TOPSTORIES:
if (!action.data.value) {
// Ensure we delete any remote data potentially related to spocs.
this.clearSpocs();
}
break;
// Check if spocs was disabled. Remove them if they were.
case PREF_SHOW_SPONSORED:
if (!action.data.value) {
// Ensure we delete any remote data potentially related to spocs.
this.clearSpocs();
}
await this.loadSpocs(update =>
this.store.dispatch(ac.BroadcastToContent(update))
);
break;
}
break;
}
}
};
// This function generates a hardcoded layout each call.
// This is because modifying the original object would
// persist across pref changes and system_tick updates.
getHardcodedLayout = basic => {
if (basic) {
// Hardcoded version of layout_variant `basic`
return {
lastUpdate: Date.now(),
spocs: {
url: "https://spocs.getpocket.com/spocs",
spocs_per_domain: 1,
},
layout: [
{
width: 12,
components: [
{
type: "TopSites",
header: {
title: "Top Sites",
},
properties: {},
},
{
type: "Message",
header: {
title: "Recommended by Pocket",
subtitle: "",
link_text: "How it works",
link_url: "https://getpocket.com/firefox/new_tab_learn_more",
icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
},
properties: {},
styles: {
".ds-message": "margin-bottom: -20px",
},
},
{
type: "CardGrid",
properties: {
items: 3,
},
header: {
title: "",
},
feed: {
embed_reference: null,
url:
"https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=en-US&feed_variant=default_spocs_on",
},
spocs: {
probability: 1,
positions: [
{
index: 2,
},
],
},
},
{
type: "Navigation",
properties: {
alignment: "left-align",
links: [
{
name: "Must Reads",
url:
"https://getpocket.com/explore/must-reads?src=fx_new_tab",
},
{
name: "Productivity",
url:
"https://getpocket.com/explore/productivity?src=fx_new_tab",
},
{
name: "Health",
url: "https://getpocket.com/explore/health?src=fx_new_tab",
},
{
name: "Finance",
url: "https://getpocket.com/explore/finance?src=fx_new_tab",
},
{
name: "Technology",
url:
"https://getpocket.com/explore/technology?src=fx_new_tab",
},
{
name: "More Recommendations ",
url:
"https://getpocket.com/explore/trending?src=fx_new_tab",
},
],
},
},
],
},
],
};
}
// Hardcoded version of layout_variant `3-col-7-row-octr`
return {
lastUpdate: Date.now(),
spocs: {
url: "https://spocs.getpocket.com/spocs",
spocs_per_domain: 1,
},
layout: [
{
width: 12,
components: [
{
type: "TopSites",
header: {
title: "Top Sites",
},
},
],
},
{
width: 12,
components: [
{
type: "Message",
header: {
title: "Recommended by Pocket",
subtitle: "",
link_text: "How it works",
link_url: "https://getpocket.com/firefox/new_tab_learn_more",
icon:
"resource://activity-stream/data/content/assets/glyph-pocket-16.svg",
},
properties: {},
styles: {
".ds-message": "margin-bottom: -20px",
},
},
],
},
{
width: 12,
components: [
{
type: "CardGrid",
properties: {
items: 21,
},
header: {
title: "",
},
feed: {
embed_reference: null,
url:
"https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=en-US&count=30",
},
spocs: {
probability: 1,
positions: [
{
index: 2,
},
{
index: 4,
},
{
index: 11,
},
{
index: 20,
},
],
},
},
{
type: "Navigation",
properties: {
alignment: "left-align",
links: [
{
name: "Must Reads",
url:
"https://getpocket.com/explore/must-reads?src=fx_new_tab",
},
{
name: "Productivity",
url:
"https://getpocket.com/explore/productivity?src=fx_new_tab",
},
{
name: "Health",
url: "https://getpocket.com/explore/health?src=fx_new_tab",
},
{
name: "Finance",
url: "https://getpocket.com/explore/finance?src=fx_new_tab",
},
{
name: "Technology",
url:
"https://getpocket.com/explore/technology?src=fx_new_tab",
},
{
name: "More Recommendations ",
url: "https://getpocket.com/explore/trending?src=fx_new_tab",
},
],
},
header: {
title: "Popular Topics",
},
styles: {
".ds-navigation": "margin-top: -10px;",
},
},
],
},
],
};
};
const EXPORTED_SYMBOLS = ["DiscoveryStreamFeed"];