3246 lines
105 KiB
JavaScript
3246 lines
105 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 = {};
|
||
ChromeUtils.defineESModuleGetters(lazy, {
|
||
ContextId: "moz-src:///browser/modules/ContextId.sys.mjs",
|
||
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
|
||
NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
|
||
ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
|
||
pktApi: "chrome://pocket/content/pktApi.sys.mjs",
|
||
PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
|
||
Region: "resource://gre/modules/Region.sys.mjs",
|
||
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
|
||
ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs",
|
||
});
|
||
|
||
// We use importESModule here instead of static import so that
|
||
// the Karma test environment won't choke on this module. This
|
||
// is because the Karma test environment already stubs out
|
||
// setTimeout / clearTimeout, and overrides importESModule
|
||
// to be a no-op (which can't be done for a static import statement).
|
||
|
||
// eslint-disable-next-line mozilla/use-static-import
|
||
const { setTimeout, clearTimeout } = ChromeUtils.importESModule(
|
||
"resource://gre/modules/Timer.sys.mjs"
|
||
);
|
||
import {
|
||
actionTypes as at,
|
||
actionCreators as ac,
|
||
} from "resource://newtab/common/Actions.mjs";
|
||
|
||
const CACHE_KEY = "discovery_stream";
|
||
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_ROTATION_TIME = 60 * 60 * 1000; // 1 hour
|
||
const DEFAULT_RECS_IMPRESSION_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||
const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server
|
||
const SPOCS_CAP_DURATION = 24 * 60 * 60; // 1 day in seconds.
|
||
const FETCH_TIMEOUT = 45 * 1000;
|
||
const TOPIC_LOADING_TIMEOUT = 1 * 1000;
|
||
const TOPIC_SELECTION_DISPLAY_COUNT =
|
||
"discoverystream.topicSelection.onboarding.displayCount";
|
||
const TOPIC_SELECTION_LAST_DISPLAYED =
|
||
"discoverystream.topicSelection.onboarding.lastDisplayed";
|
||
const TOPIC_SELECTION_DISPLAY_TIMEOUT =
|
||
"discoverystream.topicSelection.onboarding.displayTimeout";
|
||
|
||
const SPOCS_URL = "https://spocs.getpocket.com/spocs";
|
||
const FEED_URL =
|
||
"https://getpocket.cdn.mozilla.net/v3/firefox/global-recs?version=3&consumer_key=$apiKey&locale_lang=$locale®ion=$region&count=30";
|
||
const PREF_CONFIG = "discoverystream.config";
|
||
const PREF_ENDPOINTS = "discoverystream.endpoints";
|
||
const PREF_IMPRESSION_ID = "browser.newtabpage.activity-stream.impressionId";
|
||
const PREF_LAYOUT_EXPERIMENT_A = "newtabLayouts.variant-a";
|
||
const PREF_LAYOUT_EXPERIMENT_B = "newtabLayouts.variant-b";
|
||
const PREF_CONTEXTUAL_SPOC_PLACEMENTS =
|
||
"discoverystream.placements.contextualSpocs";
|
||
const PREF_CONTEXTUAL_SPOC_COUNTS =
|
||
"discoverystream.placements.contextualSpocs.counts";
|
||
const PREF_SPOC_PLACEMENTS = "discoverystream.placements.spocs";
|
||
const PREF_SPOC_COUNTS = "discoverystream.placements.spocs.counts";
|
||
const PREF_SPOC_POSITIONS = "discoverystream.spoc-positions";
|
||
const PREF_MERINO_FEED_EXPERIMENT =
|
||
"browser.newtabpage.activity-stream.discoverystream.merino-feed-experiment";
|
||
const PREF_ENABLED = "discoverystream.enabled";
|
||
const PREF_HARDCODED_BASIC_LAYOUT = "discoverystream.hardcoded-basic-layout";
|
||
const PREF_SPOCS_ENDPOINT = "discoverystream.spocs-endpoint";
|
||
const PREF_SPOCS_ENDPOINT_QUERY = "discoverystream.spocs-endpoint-query";
|
||
const PREF_REGION_BASIC_LAYOUT = "discoverystream.region-basic-layout";
|
||
const PREF_USER_TOPSTORIES = "feeds.section.topstories";
|
||
const PREF_SYSTEM_TOPSTORIES = "feeds.system.topstories";
|
||
const PREF_SYSTEM_TOPSITES = "feeds.system.topsites";
|
||
const PREF_UNIFIED_ADS_BLOCKED_LIST = "unifiedAds.blockedAds";
|
||
const PREF_UNIFIED_ADS_SPOCS_ENABLED = "unifiedAds.spocs.enabled";
|
||
const PREF_UNIFIED_ADS_ENDPOINT = "unifiedAds.endpoint";
|
||
const PREF_USER_TOPSITES = "feeds.topsites";
|
||
const PREF_SPOCS_CLEAR_ENDPOINT = "discoverystream.endpointSpocsClear";
|
||
const PREF_SHOW_SPONSORED = "showSponsored";
|
||
const PREF_SYSTEM_SHOW_SPONSORED = "system.showSponsored";
|
||
const PREF_SHOW_SPONSORED_TOPSITES = "showSponsoredTopSites";
|
||
// Nimbus variable to enable the SOV feature for sponsored tiles.
|
||
const NIMBUS_VARIABLE_CONTILE_SOV_ENABLED = "topSitesContileSovEnabled";
|
||
const PREF_SPOC_IMPRESSIONS = "discoverystream.spoc.impressions";
|
||
const PREF_FLIGHT_BLOCKS = "discoverystream.flight.blocks";
|
||
const PREF_COLLECTIONS_ENABLED =
|
||
"discoverystream.sponsored-collections.enabled";
|
||
const PREF_POCKET_BUTTON = "extensions.pocket.enabled";
|
||
const PREF_COLLECTION_DISMISSIBLE = "discoverystream.isCollectionDismissible";
|
||
const PREF_SELECTED_TOPICS = "discoverystream.topicSelection.selectedTopics";
|
||
const PREF_TOPIC_SELECTION_ENABLED = "discoverystream.topicSelection.enabled";
|
||
const PREF_TOPIC_SELECTION_PREVIOUS_SELECTED =
|
||
"discoverystream.topicSelection.hasBeenUpdatedPreviously";
|
||
const PREF_SPOCS_CACHE_TIMEOUT = "discoverystream.spocs.cacheTimeout";
|
||
const PREF_SPOCS_STARTUP_CACHE_ENABLED =
|
||
"discoverystream.spocs.startupCache.enabled";
|
||
const PREF_CONTEXTUAL_CONTENT_ENABLED =
|
||
"discoverystream.contextualContent.enabled";
|
||
const PREF_FAKESPOT_ENABLED =
|
||
"discoverystream.contextualContent.fakespot.enabled";
|
||
const PREF_CONTEXTUAL_ADS = "discoverystream.sections.contextualAds.enabled";
|
||
const PREF_CONTEXTUAL_CONTENT_SELECTED_FEED =
|
||
"discoverystream.contextualContent.selectedFeed";
|
||
const PREF_CONTEXTUAL_CONTENT_LISTFEED_TITLE =
|
||
"discoverystream.contextualContent.listFeedTitle";
|
||
const PREF_CONTEXTUAL_CONTENT_FAKESPOT_FOOTER =
|
||
"discoverystream.contextualContent.fakespot.footerCopy";
|
||
const PREF_CONTEXTUAL_CONTENT_FAKESPOT_CATEGORY =
|
||
"discoverystream.contextualContent.fakespot.defaultCategoryTitle";
|
||
const PREF_CONTEXTUAL_CONTENT_FAKESPOT_CTA_COPY =
|
||
"discoverystream.contextualContent.fakespot.ctaCopy";
|
||
const PREF_CONTEXTUAL_CONTENT_FAKESPOT_CTA_URL =
|
||
"discoverystream.contextualContent.fakespot.ctaUrl";
|
||
const PREF_USER_INFERRED_PERSONALIZATION =
|
||
"discoverystream.sections.personalization.inferred.user.enabled";
|
||
const PREF_SYSTEM_INFERRED_PERSONALIZATION =
|
||
"discoverystream.sections.personalization.inferred.enabled";
|
||
|
||
const PREF_SECTIONS_ENABLED = "discoverystream.sections.enabled";
|
||
const PREF_SECTIONS_FOLLOWING = "discoverystream.sections.following";
|
||
const PREF_SECTIONS_BLOCKED = "discoverystream.sections.blocked";
|
||
const PREF_INTEREST_PICKER_ENABLED =
|
||
"discoverystream.sections.interestPicker.enabled";
|
||
const PREF_VISIBLE_SECTIONS =
|
||
"discoverystream.sections.interestPicker.visibleSections";
|
||
const PREF_PRIVATE_PING_ENABLED = "telemetry.privatePing.enabled";
|
||
const PREF_SURFACE_ID = "telemetry.surfaceId";
|
||
|
||
let getHardcodedLayout;
|
||
|
||
export 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 lazy.PersistentCache(CACHE_KEY, true);
|
||
this.locale = Services.locale.appLocaleAsBCP47;
|
||
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(Services.uuid.generateUUID());
|
||
Services.prefs.setCharPref(PREF_IMPRESSION_ID, impressionId);
|
||
}
|
||
return impressionId;
|
||
}
|
||
|
||
get config() {
|
||
if (this._prefCache.config) {
|
||
return this._prefCache.config;
|
||
}
|
||
try {
|
||
this._prefCache.config = JSON.parse(
|
||
this.store.getState().Prefs.values[PREF_CONFIG]
|
||
);
|
||
} catch (e) {
|
||
// istanbul ignore next
|
||
this._prefCache.config = {};
|
||
// istanbul ignore next
|
||
console.error(
|
||
`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 region() {
|
||
return lazy.Region.home;
|
||
}
|
||
|
||
get isBff() {
|
||
if (this._isBff === undefined) {
|
||
const pocketConfig =
|
||
this.store.getState().Prefs.values?.pocketConfig || {};
|
||
|
||
const preffedRegionBffConfigString = pocketConfig.regionBffConfig || "";
|
||
const preffedRegionBffConfig = preffedRegionBffConfigString
|
||
.split(",")
|
||
.map(s => s.trim());
|
||
const regionBff = preffedRegionBffConfig.includes(this.region);
|
||
this._isBff = regionBff;
|
||
}
|
||
|
||
return this._isBff;
|
||
}
|
||
|
||
get isContextualAds() {
|
||
if (this._isContextualAds === undefined) {
|
||
// We care about if the contextual ads pref is on, if contextual is supported,
|
||
// and if inferred is on, but OHTTP is off.
|
||
const state = this.store.getState();
|
||
const marsOhttpEnabled = Services.prefs.getBoolPref(
|
||
"browser.newtabpage.activity-stream.unifiedAds.ohttp.enabled",
|
||
false
|
||
);
|
||
const contextualAds = state.Prefs.values[PREF_CONTEXTUAL_ADS];
|
||
const inferredPersonalization =
|
||
state.Prefs.values[PREF_USER_INFERRED_PERSONALIZATION] &&
|
||
state.Prefs.values[PREF_SYSTEM_INFERRED_PERSONALIZATION];
|
||
const sectionsEnabled = state.Prefs.values[PREF_SECTIONS_ENABLED];
|
||
// We want this if contextual ads are on, and also if inferred personalization is on, we also use OHTTP.
|
||
const useContextualAds =
|
||
contextualAds &&
|
||
((inferredPersonalization && marsOhttpEnabled) ||
|
||
!inferredPersonalization);
|
||
this._isContextualAds = sectionsEnabled && useContextualAds;
|
||
}
|
||
|
||
return this._isContextualAds;
|
||
}
|
||
|
||
get isMerino() {
|
||
if (this._isMerino === undefined) {
|
||
const pocketConfig =
|
||
this.store.getState().Prefs.values?.pocketConfig || {};
|
||
|
||
this._isMerino = pocketConfig.merinoProviderEnabled;
|
||
}
|
||
|
||
return this._isMerino;
|
||
}
|
||
|
||
get showSpocs() {
|
||
// High level overall sponsored check, if one of these is true,
|
||
// we know we need some sort of spoc control setup.
|
||
return this.showSponsoredStories || this.showSponsoredTopsites;
|
||
}
|
||
|
||
get showSponsoredStories() {
|
||
// Combine user-set sponsored opt-out with Mozilla-set config
|
||
return (
|
||
this.store.getState().Prefs.values[PREF_SHOW_SPONSORED] &&
|
||
this.store.getState().Prefs.values[PREF_SYSTEM_SHOW_SPONSORED]
|
||
);
|
||
}
|
||
|
||
get showSponsoredTopsites() {
|
||
const placements = this.getPlacements();
|
||
// Combine user-set sponsored opt-out with placement data
|
||
return !!(
|
||
this.store.getState().Prefs.values[PREF_SHOW_SPONSORED_TOPSITES] &&
|
||
placements.find(placement => placement.name === "sponsored-topsites")
|
||
);
|
||
}
|
||
|
||
get showStories() {
|
||
// Combine user-set stories opt-out with Mozilla-set config
|
||
return (
|
||
this.store.getState().Prefs.values[PREF_SYSTEM_TOPSTORIES] &&
|
||
this.store.getState().Prefs.values[PREF_USER_TOPSTORIES]
|
||
);
|
||
}
|
||
|
||
get showTopsites() {
|
||
// Combine user-set topsites opt-out with Mozilla-set config
|
||
return (
|
||
this.store.getState().Prefs.values[PREF_SYSTEM_TOPSITES] &&
|
||
this.store.getState().Prefs.values[PREF_USER_TOPSITES]
|
||
);
|
||
}
|
||
|
||
get personalized() {
|
||
return this.recommendationProvider.personalized;
|
||
}
|
||
|
||
get recommendationProvider() {
|
||
if (this._recommendationProvider) {
|
||
return this._recommendationProvider;
|
||
}
|
||
this._recommendationProvider = this.store.feeds.get(
|
||
"feeds.recommendationprovider"
|
||
);
|
||
return this._recommendationProvider;
|
||
}
|
||
|
||
setupConfig(isStartup = false) {
|
||
// Send the initial state of the pref on our reducer
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.DISCOVERY_STREAM_CONFIG_SETUP,
|
||
data: this.config,
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
})
|
||
);
|
||
}
|
||
|
||
async setupDevtoolsState(isStartup = false) {
|
||
const cachedData = (await this.cache.get()) || {};
|
||
let impressions = cachedData.recsImpressions || {};
|
||
let blocks = cachedData.recsBlocks || {};
|
||
|
||
this.store.dispatch({
|
||
type: at.DISCOVERY_STREAM_DEV_IMPRESSIONS,
|
||
data: impressions,
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
});
|
||
|
||
this.store.dispatch({
|
||
type: at.DISCOVERY_STREAM_DEV_BLOCKS,
|
||
data: blocks,
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
});
|
||
}
|
||
|
||
setupPrefs(isStartup = false) {
|
||
const experimentMetadata =
|
||
lazy.NimbusFeatures.pocketNewtab.getEnrollmentMetadata();
|
||
|
||
let utmSource = "pocket-newtab";
|
||
let utmCampaign = experimentMetadata?.slug;
|
||
let utmContent = experimentMetadata?.branch;
|
||
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.DISCOVERY_STREAM_EXPERIMENT_DATA,
|
||
data: {
|
||
utmSource,
|
||
utmCampaign,
|
||
utmContent,
|
||
},
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
})
|
||
);
|
||
|
||
const pocketButtonEnabled = Services.prefs.getBoolPref(PREF_POCKET_BUTTON);
|
||
|
||
const nimbusConfig = this.store.getState().Prefs.values?.pocketConfig || {};
|
||
const { region } = this.store.getState().Prefs.values;
|
||
|
||
this.setupSpocsCacheUpdateTime();
|
||
const saveToPocketCardRegions = nimbusConfig.saveToPocketCardRegions
|
||
?.split(",")
|
||
.map(s => s.trim());
|
||
const saveToPocketCard =
|
||
pocketButtonEnabled &&
|
||
(nimbusConfig.saveToPocketCard ||
|
||
saveToPocketCardRegions?.includes(region));
|
||
|
||
const hideDescriptionsRegions = nimbusConfig.hideDescriptionsRegions
|
||
?.split(",")
|
||
.map(s => s.trim());
|
||
const hideDescriptions =
|
||
nimbusConfig.hideDescriptions ||
|
||
hideDescriptionsRegions?.includes(region);
|
||
|
||
// We don't BroadcastToContent for this, as the changes may
|
||
// shift around elements on an open newtab the user is currently reading.
|
||
// So instead we AlsoToPreloaded so the next tab is updated.
|
||
// This is because setupPrefs is called by the system and not a user interaction.
|
||
this.store.dispatch(
|
||
ac.AlsoToPreloaded({
|
||
type: at.DISCOVERY_STREAM_PREFS_SETUP,
|
||
data: {
|
||
recentSavesEnabled: nimbusConfig.recentSavesEnabled,
|
||
pocketButtonEnabled,
|
||
saveToPocketCard,
|
||
hideDescriptions,
|
||
compactImages: nimbusConfig.compactImages,
|
||
imageGradient: nimbusConfig.imageGradient,
|
||
newSponsoredLabel: nimbusConfig.newSponsoredLabel,
|
||
titleLines: nimbusConfig.titleLines,
|
||
descLines: nimbusConfig.descLines,
|
||
readTime: nimbusConfig.readTime,
|
||
},
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
})
|
||
);
|
||
|
||
// sync redux store with PersistantCache personalization data
|
||
this.configureFollowedSections(isStartup);
|
||
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.DISCOVERY_STREAM_COLLECTION_DISMISSIBLE_TOGGLE,
|
||
data: {
|
||
value:
|
||
this.store.getState().Prefs.values[PREF_COLLECTION_DISMISSIBLE],
|
||
},
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
})
|
||
);
|
||
}
|
||
|
||
async configureFollowedSections(isStartup = false) {
|
||
const prefs = this.store.getState().Prefs.values;
|
||
const cachedData = (await this.cache.get()) || {};
|
||
let { sectionPersonalization } = cachedData;
|
||
|
||
// if sectionPersonalization is empty, populate it with data from the followed and blocked prefs
|
||
// eventually we could remove this (maybe once more of sections is added to release)
|
||
if (
|
||
sectionPersonalization &&
|
||
Object.keys(sectionPersonalization).length === 0
|
||
) {
|
||
// Raw string of followed/blocked topics, ex: "entertainment, news"
|
||
const followedSectionsString = prefs[PREF_SECTIONS_FOLLOWING];
|
||
const blockedSectionsString = prefs[PREF_SECTIONS_BLOCKED];
|
||
// Format followed sections
|
||
const followedSections = followedSectionsString
|
||
? followedSectionsString.split(",").map(s => s.trim())
|
||
: [];
|
||
|
||
// Format blocked sections
|
||
const blockedSections = blockedSectionsString
|
||
? blockedSectionsString.split(",").map(s => s.trim())
|
||
: [];
|
||
|
||
const sectionTopics = new Set([...followedSections, ...blockedSections]);
|
||
sectionPersonalization = Array.from(sectionTopics).reduce(
|
||
(acc, section) => {
|
||
acc[section] = {
|
||
isFollowed: followedSections.includes(section),
|
||
isBlocked: blockedSections.includes(section),
|
||
};
|
||
return acc;
|
||
},
|
||
{}
|
||
);
|
||
await this.cache.set(
|
||
"sectionPersonalization",
|
||
sectionPersonalization || {}
|
||
);
|
||
}
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.SECTION_PERSONALIZATION_UPDATE,
|
||
data: sectionPersonalization || {},
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
})
|
||
);
|
||
}
|
||
|
||
async setupPocketState(target) {
|
||
let dispatch = action =>
|
||
this.store.dispatch(ac.OnlyToOneContent(action, target));
|
||
const isUserLoggedIn = lazy.pktApi.isUserLoggedIn();
|
||
dispatch({
|
||
type: at.DISCOVERY_STREAM_POCKET_STATE_SET,
|
||
data: {
|
||
isUserLoggedIn,
|
||
},
|
||
});
|
||
|
||
// If we're not logged in, don't bother fetching recent saves, we're done.
|
||
if (isUserLoggedIn) {
|
||
let recentSaves = await lazy.pktApi.getRecentSavesCache();
|
||
if (recentSaves) {
|
||
// We have cache, so we can use those.
|
||
dispatch({
|
||
type: at.DISCOVERY_STREAM_RECENT_SAVES,
|
||
data: {
|
||
recentSaves,
|
||
},
|
||
});
|
||
} else {
|
||
// We don't have cache, so fetch fresh stories.
|
||
lazy.pktApi.getRecentSaves({
|
||
success(data) {
|
||
dispatch({
|
||
type: at.DISCOVERY_STREAM_RECENT_SAVES,
|
||
data: {
|
||
recentSaves: data,
|
||
},
|
||
});
|
||
},
|
||
error() {},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
uninitPrefs() {
|
||
// Reset in-memory cache
|
||
this._prefCache = {};
|
||
}
|
||
|
||
async fetchFromEndpoint(rawEndpoint, options = {}, useOhttp = false) {
|
||
let fetchPromise;
|
||
if (!rawEndpoint) {
|
||
console.error("Tried to fetch endpoint but none was configured.");
|
||
return null;
|
||
}
|
||
|
||
const apiKeyPref = this.config.api_key_pref;
|
||
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
|
||
const ohttpRelayURL = Services.prefs.getStringPref(
|
||
"browser.newtabpage.activity-stream.discoverystream.ohttp.relayURL",
|
||
""
|
||
);
|
||
const ohttpConfigURL = Services.prefs.getStringPref(
|
||
"browser.newtabpage.activity-stream.discoverystream.ohttp.configURL",
|
||
""
|
||
);
|
||
|
||
const endpoint = rawEndpoint
|
||
.replace("$apiKey", apiKey)
|
||
.replace("$locale", this.locale)
|
||
.replace("$region", this.region);
|
||
|
||
try {
|
||
// Make sure the requested endpoint is allowed
|
||
const allowed =
|
||
this.store
|
||
.getState()
|
||
.Prefs.values[PREF_ENDPOINTS].split(",")
|
||
.map(item => item.trim())
|
||
.filter(item => item) || [];
|
||
if (!allowed.some(prefix => endpoint.startsWith(prefix))) {
|
||
throw new Error(`Not one of allowed prefixes (${allowed})`);
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const { signal } = controller;
|
||
|
||
if (useOhttp && ohttpConfigURL && ohttpRelayURL) {
|
||
let config = await lazy.ObliviousHTTP.getOHTTPConfig(ohttpConfigURL);
|
||
|
||
if (!config) {
|
||
console.error(
|
||
new Error(
|
||
`OHTTP was configured for ${endpoint} but we couldn't fetch a valid config`
|
||
)
|
||
);
|
||
return null;
|
||
}
|
||
fetchPromise = lazy.ObliviousHTTP.ohttpRequest(
|
||
ohttpRelayURL,
|
||
config,
|
||
endpoint,
|
||
{
|
||
...options,
|
||
credentials: "omit",
|
||
signal,
|
||
}
|
||
);
|
||
} else {
|
||
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) {
|
||
console.error(`Failed to fetch ${endpoint}:`, error.message);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
get spocsCacheUpdateTime() {
|
||
if (this._spocsCacheUpdateTime) {
|
||
return this._spocsCacheUpdateTime;
|
||
}
|
||
this.setupSpocsCacheUpdateTime();
|
||
return this._spocsCacheUpdateTime;
|
||
}
|
||
|
||
setupSpocsCacheUpdateTime() {
|
||
const spocsCacheTimeout =
|
||
this.store.getState().Prefs.values[PREF_SPOCS_CACHE_TIMEOUT];
|
||
const MAX_TIMEOUT = 30;
|
||
const MIN_TIMEOUT = 5;
|
||
// We do a bit of min max checking the the configured value is between
|
||
// 5 and 30 minutes, to protect against unreasonable values.
|
||
if (
|
||
spocsCacheTimeout &&
|
||
spocsCacheTimeout <= MAX_TIMEOUT &&
|
||
spocsCacheTimeout >= MIN_TIMEOUT
|
||
) {
|
||
// This value is in minutes, but we want ms.
|
||
this._spocsCacheUpdateTime = spocsCacheTimeout * 60 * 1000;
|
||
} else {
|
||
// The const is already in ms.
|
||
this._spocsCacheUpdateTime = SPOCS_FEEDS_UPDATE_TIME;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 { spocs, feeds } = cachedData;
|
||
const updateTimePerComponent = {
|
||
spocs: this.spocsCacheUpdateTime,
|
||
feed: COMPONENT_FEEDS_UPDATE_TIME,
|
||
};
|
||
const EXPIRATION_TIME = isStartup
|
||
? STARTUP_CACHE_EXPIRE_TIME
|
||
: updateTimePerComponent[key];
|
||
switch (key) {
|
||
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 {
|
||
spocs: this.showSpocs && this.isExpired({ cachedData, key: "spocs" }),
|
||
feeds:
|
||
this.showStories &&
|
||
(!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.spocs || expirationPerComponent.feeds;
|
||
}
|
||
|
||
updatePlacements(sendUpdate, layout, isStartup = false) {
|
||
const placements = [];
|
||
const placementsMap = {};
|
||
for (const row of layout.filter(r => r.components && r.components.length)) {
|
||
for (const component of row.components.filter(
|
||
c => c.placement && c.spocs
|
||
)) {
|
||
// If we find a valid placement, we set it to this value.
|
||
let placement;
|
||
|
||
// We need to check to see if this placement is on or not.
|
||
// If this placement has a prefs array, check against that.
|
||
if (component.spocs.prefs) {
|
||
// Check every pref in the array to see if this placement is turned on.
|
||
if (
|
||
component.spocs.prefs.length &&
|
||
component.spocs.prefs.every(
|
||
p => this.store.getState().Prefs.values[p]
|
||
)
|
||
) {
|
||
// This placement is on.
|
||
placement = component.placement;
|
||
}
|
||
} else if (this.showSponsoredStories) {
|
||
// If we do not have a prefs array, use old check.
|
||
// This is because Pocket spocs uses an old non pref method.
|
||
placement = component.placement;
|
||
}
|
||
|
||
// Validate this placement and check for dupes.
|
||
if (placement?.name && !placementsMap[placement.name]) {
|
||
placementsMap[placement.name] = placement;
|
||
placements.push(placement);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update placements data.
|
||
// Even if we have no placements, we still want to update it to clear it.
|
||
sendUpdate({
|
||
type: at.DISCOVERY_STREAM_SPOCS_PLACEMENTS,
|
||
data: { placements },
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Adds a query string to a URL.
|
||
* A query can be any string literal accepted by https://developer.mozilla.org/docs/Web/API/URLSearchParams
|
||
* Examples: "?foo=1&bar=2", "&foo=1&bar=2", "foo=1&bar=2", "?bar=2" or "bar=2"
|
||
*/
|
||
addEndpointQuery(url, query) {
|
||
if (!query) {
|
||
return url;
|
||
}
|
||
|
||
const urlObject = new URL(url);
|
||
const params = new URLSearchParams(query);
|
||
|
||
for (let [key, val] of params.entries()) {
|
||
urlObject.searchParams.append(key, val);
|
||
}
|
||
|
||
return urlObject.toString();
|
||
}
|
||
|
||
parseGridPositions(csvPositions) {
|
||
let gridPositions;
|
||
|
||
// Only accept parseable non-negative integers
|
||
try {
|
||
gridPositions = csvPositions.map(index => {
|
||
let parsedInt = parseInt(index, 10);
|
||
|
||
if (!isNaN(parsedInt) && parsedInt >= 0) {
|
||
return parsedInt;
|
||
}
|
||
|
||
throw new Error("Bad input");
|
||
});
|
||
} catch (e) {
|
||
// Catch spoc positions that are not numbers or negative, and do nothing.
|
||
// We have hard coded backup positions.
|
||
gridPositions = undefined;
|
||
}
|
||
|
||
return gridPositions;
|
||
}
|
||
|
||
generateFeedUrl() {
|
||
// check for experiment parameters
|
||
const hasParameters = lazy.NimbusFeatures.pocketNewtab.getVariable(
|
||
"pocketFeedParameters"
|
||
);
|
||
|
||
if (this.isMerino) {
|
||
return `https://${Services.prefs.getStringPref(
|
||
"browser.newtabpage.activity-stream.discoverystream.merino-provider.endpoint"
|
||
)}/api/v1/curated-recommendations`;
|
||
} else if (this.isBff) {
|
||
return `https://${Services.prefs.getStringPref(
|
||
"extensions.pocket.bffApi"
|
||
)}/desktop/v1/recommendations?locale=$locale®ion=$region&count=30${
|
||
hasParameters || ""
|
||
}`;
|
||
}
|
||
return FEED_URL;
|
||
}
|
||
|
||
loadLayout(sendUpdate, isStartup) {
|
||
let layoutData = {};
|
||
let url = "";
|
||
|
||
const isBasicLayout =
|
||
this.config.hardcoded_basic_layout ||
|
||
this.store.getState().Prefs.values[PREF_HARDCODED_BASIC_LAYOUT] ||
|
||
this.store.getState().Prefs.values[PREF_REGION_BASIC_LAYOUT];
|
||
|
||
const sponsoredCollectionsEnabled =
|
||
this.store.getState().Prefs.values[PREF_COLLECTIONS_ENABLED];
|
||
|
||
const pocketConfig = this.store.getState().Prefs.values?.pocketConfig || {};
|
||
const onboardingExperience =
|
||
this.isBff && pocketConfig.onboardingExperience;
|
||
|
||
// The Unified Ads API does not support the spoc topsite placement.
|
||
const unifiedAdsEnabled =
|
||
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED];
|
||
const spocTopsitesPlacementEnabled =
|
||
pocketConfig.spocTopsitesPlacementEnabled && !unifiedAdsEnabled;
|
||
|
||
const layoutExperiment =
|
||
this.store.getState().Prefs.values[PREF_LAYOUT_EXPERIMENT_A] ||
|
||
this.store.getState().Prefs.values[PREF_LAYOUT_EXPERIMENT_B];
|
||
|
||
let items = isBasicLayout ? 3 : 21;
|
||
if (
|
||
pocketConfig.fourCardLayout ||
|
||
pocketConfig.hybridLayout ||
|
||
layoutExperiment
|
||
) {
|
||
items = isBasicLayout ? 4 : 24;
|
||
}
|
||
|
||
const ctaButtonSponsors = pocketConfig.ctaButtonSponsors
|
||
?.split(",")
|
||
.map(s => s.trim().toLowerCase());
|
||
let ctaButtonVariant = "";
|
||
// We specifically against hard coded values, instead of applying whatever is in the pref.
|
||
// This is to ensure random class names from a user modified pref doesn't make it into the class list.
|
||
if (
|
||
pocketConfig.ctaButtonVariant === "variant-a" ||
|
||
pocketConfig.ctaButtonVariant === "variant-b"
|
||
) {
|
||
ctaButtonVariant = pocketConfig.ctaButtonVariant;
|
||
}
|
||
|
||
const topicSelectionHasBeenUpdatedPreviously =
|
||
this.store.getState().Prefs.values[
|
||
PREF_TOPIC_SELECTION_PREVIOUS_SELECTED
|
||
];
|
||
|
||
const selectedTopics =
|
||
this.store.getState().Prefs.values[PREF_SELECTED_TOPICS];
|
||
|
||
// Note: This requires a cache update to react to a pref update
|
||
const pocketStoriesHeadlineId =
|
||
topicSelectionHasBeenUpdatedPreviously || selectedTopics
|
||
? "newtab-section-header-todays-picks"
|
||
: "newtab-section-header-stories";
|
||
|
||
pocketConfig.pocketStoriesHeadlineId = pocketStoriesHeadlineId;
|
||
|
||
let spocMessageVariant = "";
|
||
if (
|
||
pocketConfig.spocMessageVariant === "variant-a" ||
|
||
pocketConfig.spocMessageVariant === "variant-b"
|
||
) {
|
||
spocMessageVariant = pocketConfig.spocMessageVariant;
|
||
}
|
||
|
||
const prepConfArr = arr => {
|
||
return arr
|
||
?.split(",")
|
||
.filter(item => item)
|
||
.map(item => parseInt(item, 10));
|
||
};
|
||
|
||
const spocAdTypes = prepConfArr(pocketConfig.spocAdTypes);
|
||
const spocZoneIds = prepConfArr(pocketConfig.spocZoneIds);
|
||
const spocTopsitesAdTypes = prepConfArr(pocketConfig.spocTopsitesAdTypes);
|
||
const spocTopsitesZoneIds = prepConfArr(pocketConfig.spocTopsitesZoneIds);
|
||
const { spocSiteId } = pocketConfig;
|
||
let spocPlacementData;
|
||
let spocTopsitesPlacementData;
|
||
let spocsUrl;
|
||
|
||
if (spocAdTypes?.length && spocZoneIds?.length) {
|
||
spocPlacementData = {
|
||
ad_types: spocAdTypes,
|
||
zone_ids: spocZoneIds,
|
||
};
|
||
}
|
||
|
||
if (spocTopsitesAdTypes?.length && spocTopsitesZoneIds?.length) {
|
||
spocTopsitesPlacementData = {
|
||
ad_types: spocTopsitesAdTypes,
|
||
zone_ids: spocTopsitesZoneIds,
|
||
};
|
||
}
|
||
|
||
if (spocSiteId) {
|
||
const newUrl = new URL(SPOCS_URL);
|
||
newUrl.searchParams.set("site", spocSiteId);
|
||
spocsUrl = newUrl.href;
|
||
}
|
||
|
||
let feedUrl = this.generateFeedUrl();
|
||
|
||
// Set layout config.
|
||
// Changing values in this layout in memory object is unnecessary.
|
||
layoutData = getHardcodedLayout({
|
||
spocsUrl,
|
||
feedUrl,
|
||
items,
|
||
sponsoredCollectionsEnabled,
|
||
spocPlacementData,
|
||
spocTopsitesPlacementEnabled,
|
||
spocTopsitesPlacementData,
|
||
spocPositions: this.parseGridPositions(
|
||
this.store.getState().Prefs.values[PREF_SPOC_POSITIONS]?.split(`,`)
|
||
),
|
||
spocTopsitesPositions: this.parseGridPositions(
|
||
pocketConfig.spocTopsitesPositions?.split(`,`)
|
||
),
|
||
widgetPositions: this.parseGridPositions(
|
||
pocketConfig.widgetPositions?.split(`,`)
|
||
),
|
||
widgetData: [
|
||
...(this.locale.startsWith("en-") ? [{ type: "TopicsWidget" }] : []),
|
||
],
|
||
hybridLayout: pocketConfig.hybridLayout,
|
||
hideCardBackground: pocketConfig.hideCardBackground,
|
||
fourCardLayout: pocketConfig.fourCardLayout,
|
||
newFooterSection: pocketConfig.newFooterSection,
|
||
compactGrid: pocketConfig.compactGrid,
|
||
// For now essentialReadsHeader and editorsPicksHeader are English only.
|
||
essentialReadsHeader:
|
||
this.locale.startsWith("en-") && pocketConfig.essentialReadsHeader,
|
||
editorsPicksHeader:
|
||
this.locale.startsWith("en-") && pocketConfig.editorsPicksHeader,
|
||
onboardingExperience,
|
||
// For now button variants are for experimentation and English only.
|
||
ctaButtonSponsors: this.locale.startsWith("en-") ? ctaButtonSponsors : [],
|
||
ctaButtonVariant: this.locale.startsWith("en-") ? ctaButtonVariant : "",
|
||
spocMessageVariant: this.locale.startsWith("en-")
|
||
? spocMessageVariant
|
||
: "",
|
||
pocketStoriesHeadlineId: pocketConfig.pocketStoriesHeadlineId,
|
||
});
|
||
|
||
sendUpdate({
|
||
type: at.DISCOVERY_STREAM_LAYOUT_UPDATE,
|
||
data: layoutData,
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
});
|
||
|
||
if (layoutData.spocs) {
|
||
url =
|
||
this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT] ||
|
||
this.config.spocs_endpoint ||
|
||
layoutData.spocs.url;
|
||
|
||
const spocsEndpointQuery =
|
||
this.store.getState().Prefs.values[PREF_SPOCS_ENDPOINT_QUERY];
|
||
|
||
// For QA, testing, or debugging purposes, there may be a query string to add.
|
||
url = this.addEndpointQuery(url, spocsEndpointQuery);
|
||
|
||
if (
|
||
url &&
|
||
url !== this.store.getState().DiscoveryStream.spocs.spocs_endpoint
|
||
) {
|
||
sendUpdate({
|
||
type: at.DISCOVERY_STREAM_SPOCS_ENDPOINT,
|
||
data: {
|
||
url,
|
||
},
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
});
|
||
this.updatePlacements(sendUpdate, layoutData.layout, isStartup);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 = false,
|
||
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 => {
|
||
// I think we could reduce doing this for cache fetches.
|
||
// Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1606277
|
||
// We can remove filterRecommendations once ESR catches up to bug 1932196
|
||
newFeeds[url] = this.filterRecommendations(feed);
|
||
sendUpdate({
|
||
type: at.DISCOVERY_STREAM_FEED_UPDATE,
|
||
data: {
|
||
feed: newFeeds[url],
|
||
url,
|
||
},
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
});
|
||
})
|
||
.catch(
|
||
/* istanbul ignore next */ error => {
|
||
console.error(
|
||
`Error trying to load component feed ${url}:`,
|
||
error
|
||
);
|
||
}
|
||
);
|
||
newFeedsPromises.push(feedPromise);
|
||
}
|
||
};
|
||
}
|
||
|
||
// This filters just recommendations using NewTabUtils.blockedLinks only.
|
||
// This is essentially a sync blocked links filter. filterBlocked is async.
|
||
// See bug 1606277.
|
||
filterRecommendations(feed) {
|
||
if (feed?.data?.recommendations?.length) {
|
||
const recommendations = feed.data.recommendations.filter(item => {
|
||
const blocked = lazy.NewTabUtils.blockedLinks.isBlocked({
|
||
url: item.url,
|
||
});
|
||
return !blocked;
|
||
});
|
||
|
||
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 = false) {
|
||
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 { 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);
|
||
await this.cache.set("feeds", newFeeds);
|
||
sendUpdate({
|
||
type: at.DISCOVERY_STREAM_FEEDS_UPDATE,
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
});
|
||
}
|
||
|
||
getPlacements() {
|
||
const { placements } = this.store.getState().DiscoveryStream.spocs;
|
||
return placements;
|
||
}
|
||
|
||
// I wonder, can this be better as a reducer?
|
||
// See Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1606717
|
||
placementsForEach(callback) {
|
||
this.getPlacements().forEach(callback);
|
||
}
|
||
|
||
// Bug 1567271 introduced meta data on a list of spocs.
|
||
// This involved moving the spocs array into an items prop.
|
||
// However, old data could still be returned, and cached data might also be old.
|
||
// For ths reason, we want to ensure if we don't find an items array,
|
||
// we use the previous array placement, and then stub out title and context to empty strings.
|
||
// We need to do this *after* both fresh fetches and cached data to reduce repetition.
|
||
|
||
// Bug 1916488 introduced a new data stricture from the unified ads API.
|
||
// We want to maintain both implementations until we're done rollout out,
|
||
// so for now we are going to normlaize the new data to match the old data props,
|
||
// so we can change as little as possible. Once we commit to one, we can remove all this.
|
||
normalizeSpocsItems(spocs) {
|
||
const unifiedAdsEnabled =
|
||
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED];
|
||
if (unifiedAdsEnabled) {
|
||
return {
|
||
items: spocs.map(spoc => ({
|
||
format: spoc.format,
|
||
alt_text: spoc.alt_text,
|
||
id: spoc.caps?.cap_key,
|
||
flight_id: spoc.block_key,
|
||
block_key: spoc.block_key,
|
||
shim: spoc.callbacks,
|
||
caps: {
|
||
flight: {
|
||
count: spoc.caps?.day,
|
||
period: SPOCS_CAP_DURATION,
|
||
},
|
||
},
|
||
domain: spoc.domain,
|
||
excerpt: spoc.excerpt,
|
||
raw_image_src: spoc.image_url,
|
||
priority: spoc.ranking?.priority || 1,
|
||
personalization_models: spoc.ranking?.personalization_models,
|
||
item_score: spoc.ranking?.item_score,
|
||
sponsor: spoc.sponsor,
|
||
title: spoc.title,
|
||
url: spoc.url,
|
||
})),
|
||
};
|
||
}
|
||
|
||
const items = spocs.items || spocs;
|
||
const title = spocs.title || "";
|
||
const context = spocs.context || "";
|
||
const sponsor = spocs.sponsor || "";
|
||
// We do not stub sponsored_by_override with an empty string. It is an override, and an empty string
|
||
// explicitly means to override the client to display an empty string.
|
||
// An empty string is not an no op in this case. Undefined is the proper no op here.
|
||
const { sponsored_by_override } = spocs;
|
||
// Undefined is fine here. It's optional and only used by collections.
|
||
// If we leave it out, you get a collection that cannot be dismissed.
|
||
const { flight_id } = spocs;
|
||
return {
|
||
items,
|
||
title,
|
||
context,
|
||
sponsor,
|
||
sponsored_by_override,
|
||
...(flight_id ? { flight_id } : {}),
|
||
};
|
||
}
|
||
|
||
updateSponsoredCollectionsPref(collectionEnabled = false) {
|
||
const currentState =
|
||
this.store.getState().Prefs.values[PREF_COLLECTIONS_ENABLED];
|
||
|
||
// If the current state does not match the new state, update the pref.
|
||
if (currentState !== collectionEnabled) {
|
||
this.store.dispatch(
|
||
ac.SetPref(PREF_COLLECTIONS_ENABLED, collectionEnabled)
|
||
);
|
||
}
|
||
}
|
||
|
||
// This returns ad placements that contain IAB content.
|
||
// The results are ads that are contextual, and match an IAB category.
|
||
getContextualAdsPlacements() {
|
||
const state = this.store.getState();
|
||
const placementsArray = state.Prefs.values[
|
||
PREF_CONTEXTUAL_SPOC_PLACEMENTS
|
||
]?.split(`,`)
|
||
.map(s => s.trim())
|
||
.filter(item => item);
|
||
const countsArray = state.Prefs.values[PREF_CONTEXTUAL_SPOC_COUNTS]?.split(
|
||
`,`
|
||
)
|
||
.map(s => s.trim())
|
||
.filter(item => item)
|
||
.map(item => parseInt(item, 10));
|
||
|
||
const feeds = state.DiscoveryStream.feeds.data;
|
||
const recsFeed = Object.values(feeds).find(
|
||
feed => feed?.data?.sections?.length
|
||
);
|
||
|
||
let iabPlacements = [];
|
||
|
||
// If we don't have recsFeed, it means we are loading for the first time,
|
||
// and don't have any cached data.
|
||
// In this situation, we don't fill iabPlacements,
|
||
// and go with the non IAB default contextual placement prefs.
|
||
if (recsFeed) {
|
||
// An array of all iab placements, flattened, sorted, and filtered.
|
||
iabPlacements = recsFeed.data.sections
|
||
.filter(section => section.iab)
|
||
.sort((a, b) => a.receivedRank - b.receivedRank)
|
||
.reduce((acc, section) => {
|
||
const iabArray = section.layout.responsiveLayouts[0].tiles
|
||
.filter(tile => tile.hasAd)
|
||
.map(() => {
|
||
return section.iab;
|
||
});
|
||
return [...acc, ...iabArray];
|
||
}, []);
|
||
}
|
||
|
||
return placementsArray.map((placement, index) => ({
|
||
placement,
|
||
count: countsArray[index],
|
||
...(iabPlacements[index] ? { content: iabPlacements[index] } : {}),
|
||
}));
|
||
}
|
||
|
||
// This returns ad placements that don't contain IAB content.
|
||
// The results are ads that are not contextual, and can be of any IAB category.
|
||
getSimpleAdsPlacements() {
|
||
const state = this.store.getState();
|
||
const placementsArray = state.Prefs.values[PREF_SPOC_PLACEMENTS]?.split(`,`)
|
||
.map(s => s.trim())
|
||
.filter(item => item);
|
||
const countsArray = state.Prefs.values[PREF_SPOC_COUNTS]?.split(`,`)
|
||
.map(s => s.trim())
|
||
.filter(item => item)
|
||
.map(item => parseInt(item, 10));
|
||
|
||
return placementsArray.map((placement, index) => ({
|
||
placement,
|
||
count: countsArray[index],
|
||
}));
|
||
}
|
||
|
||
getAdsPlacements() {
|
||
// We can replace unifiedAdsPlacements if we have and can use contextual ads.
|
||
// No longer relying on pref based placements and counts.
|
||
if (this.isContextualAds) {
|
||
return this.getContextualAdsPlacements();
|
||
}
|
||
return this.getSimpleAdsPlacements();
|
||
}
|
||
|
||
async loadSpocs(sendUpdate, isStartup) {
|
||
const cachedData = (await this.cache.get()) || {};
|
||
const unifiedAdsEnabled =
|
||
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED];
|
||
let spocsState = cachedData.spocs;
|
||
let placements = this.getPlacements();
|
||
let unifiedAdsPlacements = [];
|
||
|
||
if (
|
||
this.showSpocs &&
|
||
placements?.length &&
|
||
this.isExpired({ cachedData, key: "spocs", isStartup })
|
||
) {
|
||
// We optimistically set this to true, because if SOV is not ready, we fetch them.
|
||
let useTopsitesPlacement = true;
|
||
|
||
// If SOV is turned off or not available, we optimistically fetch sponsored topsites.
|
||
if (
|
||
lazy.NimbusFeatures.pocketNewtab.getVariable(
|
||
NIMBUS_VARIABLE_CONTILE_SOV_ENABLED
|
||
) &&
|
||
!unifiedAdsEnabled
|
||
) {
|
||
let { positions, ready } = this.store.getState().TopSites.sov;
|
||
if (ready) {
|
||
// We don't need to await here, because we don't need it now.
|
||
this.cache.set("sov", positions);
|
||
} else {
|
||
// If SOV is not available, and there is a SOV cache, use it.
|
||
positions = cachedData.sov;
|
||
}
|
||
|
||
if (positions?.length) {
|
||
// If SOV is ready and turned on, we can check if we need moz-sales position.
|
||
useTopsitesPlacement = positions.some(
|
||
allocation => allocation.assignedPartner === "moz-sales"
|
||
);
|
||
}
|
||
}
|
||
|
||
// We can filter out the topsite placement from the fetch.
|
||
if (!useTopsitesPlacement || unifiedAdsEnabled) {
|
||
placements = placements.filter(
|
||
placement => placement.name !== "sponsored-topsites"
|
||
);
|
||
}
|
||
|
||
if (placements?.length) {
|
||
const apiKeyPref = this.config.api_key_pref;
|
||
const apiKey = Services.prefs.getCharPref(apiKeyPref, "");
|
||
const state = this.store.getState();
|
||
let endpoint = state.DiscoveryStream.spocs.spocs_endpoint;
|
||
let body = {
|
||
pocket_id: this._impressionId,
|
||
version: 2,
|
||
consumer_key: apiKey,
|
||
...(placements.length ? { placements } : {}),
|
||
};
|
||
|
||
if (unifiedAdsEnabled) {
|
||
const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT];
|
||
endpoint = `${endpointBaseUrl}v1/ads`;
|
||
unifiedAdsPlacements = this.getAdsPlacements();
|
||
const blockedSponsors =
|
||
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_BLOCKED_LIST];
|
||
|
||
body = {
|
||
context_id: await lazy.ContextId.request(),
|
||
placements: unifiedAdsPlacements,
|
||
blocks: blockedSponsors.split(","),
|
||
};
|
||
}
|
||
|
||
const headers = new Headers();
|
||
const marsOhttpEnabled = Services.prefs.getBoolPref(
|
||
"browser.newtabpage.activity-stream.unifiedAds.ohttp.enabled",
|
||
false
|
||
);
|
||
headers.append("content-type", "application/json");
|
||
|
||
let spocsResponse;
|
||
try {
|
||
spocsResponse = await this.fetchFromEndpoint(
|
||
endpoint,
|
||
{
|
||
method: "POST",
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
},
|
||
marsOhttpEnabled
|
||
);
|
||
} catch (error) {
|
||
console.error("Error trying to load spocs feeds:", error);
|
||
}
|
||
|
||
if (spocsResponse) {
|
||
const fetchTimestamp = Date.now();
|
||
spocsState = {
|
||
lastUpdated: fetchTimestamp,
|
||
spocs: {
|
||
...spocsResponse,
|
||
},
|
||
};
|
||
|
||
if (spocsResponse.settings && spocsResponse.settings.feature_flags) {
|
||
this.store.dispatch(
|
||
ac.OnlyToMain({
|
||
type: at.DISCOVERY_STREAM_PERSONALIZATION_OVERRIDE,
|
||
data: {
|
||
override: !spocsResponse.settings.feature_flags.spoc_v2,
|
||
},
|
||
})
|
||
);
|
||
this.updateSponsoredCollectionsPref(
|
||
spocsResponse.settings.feature_flags.collections
|
||
);
|
||
}
|
||
|
||
const spocsResultPromises = this.getPlacements().map(
|
||
async placement => {
|
||
let freshSpocs = spocsState.spocs[placement.name];
|
||
|
||
if (unifiedAdsEnabled) {
|
||
freshSpocs = unifiedAdsPlacements.reduce(
|
||
(accumulator, currentValue) => {
|
||
return accumulator.concat(
|
||
spocsState.spocs[currentValue.placement]
|
||
);
|
||
},
|
||
[]
|
||
);
|
||
}
|
||
|
||
if (!freshSpocs) {
|
||
return;
|
||
}
|
||
|
||
// spocs can be returns as an array, or an object with an items array.
|
||
// We want to normalize this so all our spocs have an items array.
|
||
// There can also be some meta data for title and context.
|
||
// This is mostly because of backwards compat.
|
||
const {
|
||
items: normalizedSpocsItems,
|
||
title,
|
||
context,
|
||
sponsor,
|
||
sponsored_by_override,
|
||
} = this.normalizeSpocsItems(freshSpocs);
|
||
|
||
if (!normalizedSpocsItems || !normalizedSpocsItems.length) {
|
||
// In the case of old data, we still want to ensure we normalize the data structure,
|
||
// even if it's empty. We expect the empty data to be an object with items array,
|
||
// and not just an empty array.
|
||
spocsState.spocs = {
|
||
...spocsState.spocs,
|
||
[placement.name]: {
|
||
title,
|
||
context,
|
||
items: [],
|
||
},
|
||
};
|
||
return;
|
||
}
|
||
|
||
// Migrate flight_id
|
||
const { data: migratedSpocs } =
|
||
this.migrateFlightId(normalizedSpocsItems);
|
||
|
||
const { data: capResult } = this.frequencyCapSpocs(migratedSpocs);
|
||
|
||
const { data: blockedResults } =
|
||
await this.filterBlocked(capResult);
|
||
|
||
const { data: spocsWithFetchTimestamp } = this.addFetchTimestamp(
|
||
blockedResults,
|
||
fetchTimestamp
|
||
);
|
||
|
||
let items = spocsWithFetchTimestamp;
|
||
let personalized = false;
|
||
|
||
// We only need to rank if we don't have contextual ads.
|
||
if (!this.isContextualAds) {
|
||
const scoreResults = await this.scoreItems(
|
||
spocsWithFetchTimestamp,
|
||
"spocs"
|
||
);
|
||
items = scoreResults.data;
|
||
personalized = scoreResults.personalized;
|
||
}
|
||
|
||
spocsState.spocs = {
|
||
...spocsState.spocs,
|
||
[placement.name]: {
|
||
title,
|
||
context,
|
||
sponsor,
|
||
sponsored_by_override,
|
||
personalized,
|
||
items,
|
||
},
|
||
};
|
||
}
|
||
);
|
||
await Promise.all(spocsResultPromises);
|
||
|
||
this.cleanUpFlightImpressionPref(spocsState.spocs);
|
||
} else {
|
||
console.error("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: {},
|
||
};
|
||
await this.cache.set("spocs", {
|
||
lastUpdated: spocsState.lastUpdated,
|
||
spocs: spocsState.spocs,
|
||
});
|
||
|
||
sendUpdate({
|
||
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
|
||
data: {
|
||
lastUpdated: spocsState.lastUpdated,
|
||
spocs: spocsState.spocs,
|
||
},
|
||
meta: {
|
||
isStartup,
|
||
},
|
||
});
|
||
}
|
||
|
||
async clearSpocs() {
|
||
const state = this.store.getState();
|
||
let endpoint = state.Prefs.values[PREF_SPOCS_CLEAR_ENDPOINT];
|
||
|
||
const unifiedAdsEnabled =
|
||
state.Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED];
|
||
|
||
let body = {
|
||
pocket_id: this._impressionId,
|
||
};
|
||
|
||
if (unifiedAdsEnabled) {
|
||
const endpointBaseUrl = state.Prefs.values[PREF_UNIFIED_ADS_ENDPOINT];
|
||
|
||
if (!endpointBaseUrl) {
|
||
return;
|
||
}
|
||
|
||
// If rotation is enabled, then the module is going to take care of
|
||
// sending the request to MARS to delete the context_id. Otherwise,
|
||
// we do it manually here.
|
||
if (lazy.ContextId.rotationEnabled) {
|
||
await lazy.ContextId.forceRotation();
|
||
} else {
|
||
endpoint = `${endpointBaseUrl}v1/delete_user`;
|
||
body = {
|
||
context_id: await lazy.ContextId.request(),
|
||
};
|
||
}
|
||
}
|
||
|
||
if (!endpoint) {
|
||
return;
|
||
}
|
||
const headers = new Headers();
|
||
headers.append("content-type", "application/json");
|
||
|
||
await this.fetchFromEndpoint(endpoint, {
|
||
method: "DELETE",
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
observe(subject, topic, data) {
|
||
switch (topic) {
|
||
case "nsPref:changed":
|
||
// If the Pocket button was turned on or off, we need to update the cards
|
||
// because cards show menu options for the Pocket button that need to be removed.
|
||
if (data === PREF_POCKET_BUTTON) {
|
||
this.configReset();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
/*
|
||
* This function is used to sort any type of story, both spocs and recs.
|
||
* This uses hierarchical sorting, first sorting by priority, then by score within a priority.
|
||
* This function could be sorting an array of spocs or an array of recs.
|
||
* A rec would have priority undefined, and a spoc would probably have a priority set.
|
||
* Priority is sorted ascending, so low numbers are the highest priority.
|
||
* Score is sorted descending, so high numbers are the highest score.
|
||
* Undefined priority values are considered the lowest priority.
|
||
* A negative priority is considered the same as undefined, lowest priority.
|
||
* A negative priority is unlikely and not currently supported or expected.
|
||
* A negative score is a possible use case.
|
||
*/
|
||
sortItem(a, b) {
|
||
// If the priorities are the same, sort based on score.
|
||
// If both item priorities are undefined,
|
||
// we can safely sort via score.
|
||
if (a.priority === b.priority) {
|
||
return b.score - a.score;
|
||
} else if (!a.priority || a.priority <= 0) {
|
||
// If priority is undefined or an unexpected value,
|
||
// consider it lowest priority.
|
||
return 1;
|
||
} else if (!b.priority || b.priority <= 0) {
|
||
// Also consider this case lowest priority.
|
||
return -1;
|
||
}
|
||
// Our primary sort for items with priority.
|
||
return a.priority - b.priority;
|
||
}
|
||
|
||
async scoreItems(items, type) {
|
||
const spocsPersonalized =
|
||
this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized;
|
||
const recsPersonalized =
|
||
this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized;
|
||
const personalizedByType =
|
||
type === "feed" ? recsPersonalized : spocsPersonalized;
|
||
// If this is initialized, we are ready to go.
|
||
const personalized = this.store.getState().Personalization.initialized;
|
||
|
||
const data = (
|
||
await Promise.all(
|
||
items.map(item => this.scoreItem(item, personalizedByType))
|
||
)
|
||
)
|
||
// Sort by highest scores.
|
||
.sort(this.sortItem);
|
||
|
||
return { data, personalized };
|
||
}
|
||
|
||
async scoreItem(item, personalizedByType) {
|
||
item.score = item.item_score;
|
||
if (item.score !== 0 && !item.score) {
|
||
item.score = 1;
|
||
}
|
||
if (this.personalized && personalizedByType) {
|
||
await this.recommendationProvider.calculateItemRelevanceScore(item);
|
||
}
|
||
return item;
|
||
}
|
||
|
||
async filterBlocked(data) {
|
||
if (data?.length) {
|
||
let flights = this.readDataPref(PREF_FLIGHT_BLOCKS);
|
||
|
||
const cachedData = (await this.cache.get()) || {};
|
||
let blocks = cachedData.recsBlocks || {};
|
||
|
||
const filteredItems = data.filter(item => {
|
||
const blocked =
|
||
lazy.NewTabUtils.blockedLinks.isBlocked({ url: item.url }) ||
|
||
flights[item.flight_id] ||
|
||
blocks[item.id];
|
||
return !blocked;
|
||
});
|
||
return { data: filteredItems };
|
||
}
|
||
return { data };
|
||
}
|
||
|
||
// Add the fetch timestamp property to each spoc returned to communicate how
|
||
// old the spoc is in telemetry when it is used by the client
|
||
addFetchTimestamp(spocs, fetchTimestamp) {
|
||
if (spocs && spocs.length) {
|
||
return {
|
||
data: spocs.map(s => {
|
||
return {
|
||
...s,
|
||
fetchTimestamp,
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
return { data: spocs };
|
||
}
|
||
|
||
// For backwards compatibility, older spoc endpoint don't have flight_id,
|
||
// but instead had campaign_id we can use
|
||
//
|
||
// @param {Object} data An object that might have a SPOCS array.
|
||
// @returns {Object} An object with a property `data` as the result.
|
||
migrateFlightId(spocs) {
|
||
if (spocs && spocs.length) {
|
||
return {
|
||
data: spocs.map(s => {
|
||
return {
|
||
...s,
|
||
...(s.flight_id || s.campaign_id
|
||
? {
|
||
flight_id: s.flight_id || s.campaign_id,
|
||
}
|
||
: {}),
|
||
...(s.caps
|
||
? {
|
||
caps: {
|
||
...s.caps,
|
||
flight: s.caps.flight || s.caps.campaign,
|
||
},
|
||
}
|
||
: {}),
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
return { data: spocs };
|
||
}
|
||
|
||
// 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?.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 flight, which may include multiple spocs.
|
||
// We currently support two types of frequency caps:
|
||
// - lifetime: Indicates how many times spocs from a flight can be shown in total
|
||
// - period: Indicates how many times spocs from a flight can be shown within a period
|
||
//
|
||
// So, for example, the feed configuration below defines that for flight 1 no more
|
||
// than 5 spocs can be shown in total, and no more than 2 per hour.
|
||
// "flight_id": 1,
|
||
// "caps": {
|
||
// "lifetime": 5,
|
||
// "flight": {
|
||
// "count": 2,
|
||
// "period": 3600
|
||
// }
|
||
// }
|
||
isBelowFrequencyCap(impressions, spoc) {
|
||
const flightImpressions = impressions[spoc.flight_id];
|
||
if (!flightImpressions) {
|
||
return true;
|
||
}
|
||
|
||
const lifetime = spoc.caps && spoc.caps.lifetime;
|
||
|
||
const lifeTimeCap = Math.min(
|
||
lifetime || MAX_LIFETIME_CAP,
|
||
MAX_LIFETIME_CAP
|
||
);
|
||
const lifeTimeCapExceeded = flightImpressions.length >= lifeTimeCap;
|
||
if (lifeTimeCapExceeded) {
|
||
return false;
|
||
}
|
||
|
||
const flightCap = spoc.caps && spoc.caps.flight;
|
||
if (flightCap) {
|
||
const flightCapExceeded =
|
||
flightImpressions.filter(i => Date.now() - i < flightCap.period * 1000)
|
||
.length >= flightCap.count;
|
||
return !flightCapExceeded;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async retryFeed(feed) {
|
||
const { url } = feed;
|
||
const newFeed = await this.getComponentFeed(url);
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.DISCOVERY_STREAM_FEED_UPDATE,
|
||
data: {
|
||
feed: newFeed,
|
||
url,
|
||
},
|
||
})
|
||
);
|
||
}
|
||
|
||
getExperimentInfo() {
|
||
// We want to know if the user is in an experiment or rollout,
|
||
// but we prioritize experiments over rollouts.
|
||
const experimentMetadata =
|
||
lazy.NimbusFeatures.pocketNewtab.getEnrollmentMetadata();
|
||
|
||
let experimentName = experimentMetadata?.slug ?? "";
|
||
let experimentBranch = experimentMetadata?.branch ?? "";
|
||
|
||
return {
|
||
experimentName,
|
||
experimentBranch,
|
||
};
|
||
}
|
||
|
||
// eslint-disable-next-line max-statements
|
||
async getComponentFeed(feedUrl, isStartup) {
|
||
const cachedData = (await this.cache.get()) || {};
|
||
const prefs = this.store.getState().Prefs.values;
|
||
const sectionsEnabled = prefs[PREF_SECTIONS_ENABLED];
|
||
let isFakespot;
|
||
const selectedFeedPref = prefs[PREF_CONTEXTUAL_CONTENT_SELECTED_FEED];
|
||
// Should we fetch /curated-recommendations over OHTTP
|
||
const merinoOhttpEnabled = Services.prefs.getBoolPref(
|
||
"browser.newtabpage.activity-stream.discoverystream.merino-provider.ohttp.enabled",
|
||
false
|
||
);
|
||
let sections = [];
|
||
const { feeds } = cachedData;
|
||
|
||
let feed = feeds ? feeds[feedUrl] : null;
|
||
if (this.isExpired({ cachedData, key: "feed", url: feedUrl, isStartup })) {
|
||
const options = this.formatComponentFeedRequest(
|
||
cachedData.sectionPersonalization
|
||
);
|
||
|
||
const feedResponse = await this.fetchFromEndpoint(
|
||
feedUrl,
|
||
options,
|
||
merinoOhttpEnabled
|
||
);
|
||
|
||
if (feedResponse) {
|
||
const { settings = {} } = feedResponse;
|
||
let { recommendations } = feedResponse;
|
||
if (this.isMerino) {
|
||
recommendations = feedResponse.data.map(item => ({
|
||
id: item.corpusItemId || item.scheduledCorpusItemId || item.tileId,
|
||
scheduled_corpus_item_id: item.scheduledCorpusItemId,
|
||
corpus_item_id: item.corpusItemId,
|
||
url: item.url,
|
||
title: item.title,
|
||
topic: item.topic,
|
||
features: item.features,
|
||
excerpt: item.excerpt,
|
||
publisher: item.publisher,
|
||
raw_image_src: item.imageUrl,
|
||
received_rank: item.receivedRank,
|
||
recommended_at: feedResponse.recommendedAt,
|
||
icon_src: item.iconUrl,
|
||
isTimeSensitive: item.isTimeSensitive,
|
||
}));
|
||
if (feedResponse.feeds && selectedFeedPref && !sectionsEnabled) {
|
||
isFakespot = selectedFeedPref === "fakespot";
|
||
const keyName = isFakespot ? "products" : "recommendations";
|
||
const selectedFeedResponse = feedResponse.feeds[selectedFeedPref];
|
||
selectedFeedResponse?.[keyName]?.forEach(item =>
|
||
recommendations.push({
|
||
id: isFakespot
|
||
? item.id
|
||
: item.corpusItemId ||
|
||
item.scheduledCorpusItemId ||
|
||
item.tileId,
|
||
scheduled_corpus_item_id: item.scheduledCorpusItemId,
|
||
corpus_item_id: item.corpusItemId,
|
||
url: item.url,
|
||
title: item.title,
|
||
topic: item.topic,
|
||
excerpt: item.excerpt,
|
||
publisher: item.publisher,
|
||
raw_image_src: item.imageUrl,
|
||
received_rank: item.receivedRank,
|
||
recommended_at: feedResponse.recommendedAt,
|
||
// property to determine if rec is used in ListFeed or not
|
||
feedName: selectedFeedPref,
|
||
category: item.category,
|
||
icon_src: item.iconUrl,
|
||
isTimeSensitive: item.isTimeSensitive,
|
||
})
|
||
);
|
||
|
||
const prevTitle = prefs[PREF_CONTEXTUAL_CONTENT_LISTFEED_TITLE];
|
||
|
||
const feedTitle = isFakespot
|
||
? selectedFeedResponse.headerCopy
|
||
: selectedFeedResponse.title;
|
||
|
||
if (feedTitle && feedTitle !== prevTitle) {
|
||
this.handleListfeedStrings(selectedFeedResponse, isFakespot);
|
||
}
|
||
}
|
||
|
||
if (sectionsEnabled) {
|
||
for (const [sectionKey, sectionData] of Object.entries(
|
||
feedResponse.feeds
|
||
)) {
|
||
if (sectionData) {
|
||
for (const item of sectionData.recommendations) {
|
||
recommendations.push({
|
||
id:
|
||
item.corpusItemId ||
|
||
item.scheduledCorpusItemId ||
|
||
item.tileId,
|
||
scheduled_corpus_item_id: item.scheduledCorpusItemId,
|
||
corpus_item_id: item.corpusItemId,
|
||
url: item.url,
|
||
title: item.title,
|
||
topic: item.topic,
|
||
features: item.features,
|
||
excerpt: item.excerpt,
|
||
publisher: item.publisher,
|
||
raw_image_src: item.imageUrl,
|
||
received_rank: item.receivedRank,
|
||
recommended_at: feedResponse.recommendedAt,
|
||
section: sectionKey,
|
||
icon_src: item.iconUrl,
|
||
isTimeSensitive: item.isTimeSensitive,
|
||
});
|
||
}
|
||
sections.push({
|
||
sectionKey,
|
||
title: sectionData.title,
|
||
subtitle: sectionData.subtitle || "",
|
||
receivedRank: sectionData.receivedFeedRank,
|
||
layout: sectionData.layout,
|
||
iab: sectionData.iab,
|
||
// property if initially shown (with interest picker)
|
||
visible: sectionData.isInitiallyVisible,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
} else if (this.isBff) {
|
||
recommendations = feedResponse.data.map(item => ({
|
||
id: item.tileId,
|
||
url: item.url,
|
||
title: item.title,
|
||
excerpt: item.excerpt,
|
||
publisher: item.publisher,
|
||
time_to_read: item.timeToRead,
|
||
raw_image_src: item.imageUrl,
|
||
recommendation_id: item.recommendationId,
|
||
}));
|
||
}
|
||
const { data: scoredItems, personalized } = await this.scoreItems(
|
||
recommendations,
|
||
"feed"
|
||
);
|
||
|
||
if (sections.length) {
|
||
const visibleSections = sections
|
||
.filter(({ visible }) => visible)
|
||
.sort((a, b) => a.receivedRank - b.receivedRank)
|
||
.map(section => section.sectionKey)
|
||
.join(",");
|
||
|
||
// after the request only show the sections that are
|
||
// initially visible and only keep the initial order (determined by the server)
|
||
this.store.dispatch(
|
||
ac.SetPref(PREF_VISIBLE_SECTIONS, visibleSections)
|
||
);
|
||
}
|
||
|
||
// This assigns the section title to the interestPicker.sections
|
||
// object to more easily access the title in JSX files
|
||
if (
|
||
feedResponse.interestPicker &&
|
||
feedResponse.interestPicker.sections
|
||
) {
|
||
feedResponse.interestPicker.sections =
|
||
feedResponse.interestPicker.sections.map(section => {
|
||
const { sectionId } = section;
|
||
const title = sections.find(
|
||
({ sectionKey }) => sectionKey === sectionId
|
||
)?.title;
|
||
return { sectionId, title };
|
||
});
|
||
}
|
||
if (feedResponse.inferredLocalModel) {
|
||
this.store.dispatch(
|
||
ac.AlsoToMain({
|
||
type: at.INFERRED_PERSONALIZATION_MODEL_UPDATE,
|
||
data: feedResponse.inferredLocalModel || {},
|
||
})
|
||
);
|
||
}
|
||
// We can cleanup any impressions we have that are old before we rotate.
|
||
// In theory we can do this anywhere, but doing it just before rotate is optimal.
|
||
// Rotate is also the only place that uses these impressions.
|
||
await this.cleanUpTopRecImpressions();
|
||
const rotatedItems = await this.rotate(scoredItems);
|
||
|
||
const { data: filteredResults } =
|
||
await this.filterBlocked(rotatedItems);
|
||
this.componentFeedFetched = true;
|
||
feed = {
|
||
lastUpdated: Date.now(),
|
||
personalized,
|
||
data: {
|
||
settings,
|
||
sections,
|
||
interestPicker: feedResponse.interestPicker || {},
|
||
recommendations: filteredResults,
|
||
surfaceId: feedResponse.surfaceId || "",
|
||
status: "success",
|
||
},
|
||
};
|
||
} else {
|
||
console.error("No response for feed");
|
||
}
|
||
}
|
||
|
||
// if surfaceID is availible either through the cache or the response set value in Glean
|
||
if (prefs[PREF_PRIVATE_PING_ENABLED] && feed.data.surfaceId) {
|
||
Glean.newtabContent.surfaceId.set(feed.data.surfaceId);
|
||
this.store.dispatch(ac.SetPref(PREF_SURFACE_ID, feed.data.surfaceId));
|
||
}
|
||
|
||
// If we have no feed at this point, both fetch and cache failed for some reason.
|
||
return (
|
||
feed || {
|
||
data: {
|
||
status: "failed",
|
||
},
|
||
}
|
||
);
|
||
}
|
||
|
||
handleListfeedStrings(feedResponse, isFakespot) {
|
||
if (isFakespot) {
|
||
this.store.dispatch(
|
||
ac.SetPref(
|
||
PREF_CONTEXTUAL_CONTENT_LISTFEED_TITLE,
|
||
feedResponse.headerCopy
|
||
)
|
||
);
|
||
this.store.dispatch(
|
||
ac.SetPref(
|
||
PREF_CONTEXTUAL_CONTENT_FAKESPOT_CATEGORY,
|
||
feedResponse.defaultCategoryName
|
||
)
|
||
);
|
||
this.store.dispatch(
|
||
ac.SetPref(
|
||
PREF_CONTEXTUAL_CONTENT_FAKESPOT_FOOTER,
|
||
feedResponse.footerCopy
|
||
)
|
||
);
|
||
this.store.dispatch(
|
||
ac.SetPref(
|
||
PREF_CONTEXTUAL_CONTENT_FAKESPOT_CTA_COPY,
|
||
feedResponse.cta.ctaCopy
|
||
)
|
||
);
|
||
this.store.dispatch(
|
||
ac.SetPref(
|
||
PREF_CONTEXTUAL_CONTENT_FAKESPOT_CTA_URL,
|
||
feedResponse.cta.url
|
||
)
|
||
);
|
||
} else {
|
||
this.store.dispatch(
|
||
ac.SetPref(PREF_CONTEXTUAL_CONTENT_LISTFEED_TITLE, feedResponse.title)
|
||
);
|
||
}
|
||
}
|
||
|
||
formatComponentFeedRequest(sectionPersonalization = {}) {
|
||
const prefs = this.store.getState().Prefs.values;
|
||
const inferredPersonalization =
|
||
prefs[PREF_USER_INFERRED_PERSONALIZATION] &&
|
||
prefs[PREF_SYSTEM_INFERRED_PERSONALIZATION];
|
||
const merinoOhttpEnabled = Services.prefs.getBoolPref(
|
||
"browser.newtabpage.activity-stream.discoverystream.merino-provider.ohttp.enabled",
|
||
false
|
||
);
|
||
const headers = new Headers();
|
||
if (this.isMerino) {
|
||
const topicSelectionEnabled = prefs[PREF_TOPIC_SELECTION_ENABLED];
|
||
const topicsString = prefs[PREF_SELECTED_TOPICS];
|
||
const topics = topicSelectionEnabled
|
||
? topicsString
|
||
.split(",")
|
||
.map(s => s.trim())
|
||
.filter(item => item)
|
||
: [];
|
||
|
||
// Should we pass the experiment branch and slug to the Merino feed request.
|
||
const prefMerinoFeedExperiment = Services.prefs.getBoolPref(
|
||
PREF_MERINO_FEED_EXPERIMENT
|
||
);
|
||
|
||
// convert section to array to match what merino is expecting
|
||
const sections = Object.entries(sectionPersonalization).map(
|
||
([sectionId, data]) => ({
|
||
sectionId,
|
||
isFollowed: data.isFollowed,
|
||
isBlocked: data.isBlocked,
|
||
...(data.followedAt && { followedAt: data.followedAt }),
|
||
})
|
||
);
|
||
|
||
// To display the inline interest picker pass `enableInterestPicker` into the request
|
||
const interestPickerEnabled = prefs[PREF_INTEREST_PICKER_ENABLED];
|
||
|
||
let inferredInterests = null;
|
||
if (inferredPersonalization && merinoOhttpEnabled) {
|
||
inferredInterests =
|
||
this.store.getState().InferredPersonalization.inferredInterests || {};
|
||
}
|
||
const requestMetadata = {
|
||
utc_offset: lazy.NewTabUtils.getUtcOffset(prefs[PREF_SURFACE_ID]),
|
||
coarse_os: lazy.NewTabUtils.normalizeOs(),
|
||
surface_id: prefs[PREF_SURFACE_ID] || "",
|
||
inferredInterests,
|
||
};
|
||
|
||
headers.append("content-type", "application/json");
|
||
let body = {
|
||
...(prefMerinoFeedExperiment ? this.getExperimentInfo() : {}),
|
||
...requestMetadata,
|
||
locale: this.locale,
|
||
region: this.region,
|
||
topics,
|
||
sections,
|
||
enableInterestPicker: !!interestPickerEnabled,
|
||
};
|
||
|
||
const sectionsEnabled = prefs[PREF_SECTIONS_ENABLED];
|
||
|
||
// Should we pass the feed param to the merino request
|
||
const contextualContentEnabled = prefs[PREF_CONTEXTUAL_CONTENT_ENABLED];
|
||
const selectedFeed = prefs[PREF_CONTEXTUAL_CONTENT_SELECTED_FEED];
|
||
const isFakespot = selectedFeed === "fakespot";
|
||
const fakespotEnabled = prefs[PREF_FAKESPOT_ENABLED];
|
||
|
||
const shouldFetchTBRFeed =
|
||
(contextualContentEnabled && !isFakespot) ||
|
||
(contextualContentEnabled && isFakespot && fakespotEnabled);
|
||
|
||
if (shouldFetchTBRFeed) {
|
||
body.feeds = [selectedFeed];
|
||
}
|
||
if (sectionsEnabled) {
|
||
// if sections is enabled, it should override the TBR feed
|
||
body.feeds = ["sections"];
|
||
}
|
||
|
||
return {
|
||
method: "POST",
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
};
|
||
} else if (this.isBff) {
|
||
const oAuthConsumerKey = Services.prefs.getStringPref(
|
||
"extensions.pocket.oAuthConsumerKeyBff"
|
||
);
|
||
headers.append("consumer_key", oAuthConsumerKey);
|
||
return {
|
||
method: "GET",
|
||
headers,
|
||
};
|
||
}
|
||
return {};
|
||
}
|
||
|
||
/**
|
||
* 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.spocs) {
|
||
await this.loadSpocs(this.store.dispatch);
|
||
}
|
||
if (expirationPerComponent.feeds) {
|
||
await this.loadComponentFeeds(this.store.dispatch);
|
||
}
|
||
}
|
||
|
||
async scoreFeeds(feedsState) {
|
||
if (feedsState.data) {
|
||
const feeds = {};
|
||
const feedsPromises = Object.keys(feedsState.data).map(url => {
|
||
let feed = feedsState.data[url];
|
||
if (feed.personalized) {
|
||
// Feed was previously personalized then cached, we don't need to do this again.
|
||
return Promise.resolve();
|
||
}
|
||
const feedPromise = this.scoreItems(feed.data.recommendations, "feed");
|
||
feedPromise.then(({ data: scoredItems, personalized }) => {
|
||
feed = {
|
||
...feed,
|
||
personalized,
|
||
data: {
|
||
...feed.data,
|
||
recommendations: scoredItems,
|
||
},
|
||
};
|
||
|
||
feeds[url] = feed;
|
||
|
||
this.store.dispatch(
|
||
ac.AlsoToPreloaded({
|
||
type: at.DISCOVERY_STREAM_FEED_UPDATE,
|
||
data: {
|
||
feed,
|
||
url,
|
||
},
|
||
})
|
||
);
|
||
});
|
||
return feedPromise;
|
||
});
|
||
await Promise.all(feedsPromises);
|
||
await this.cache.set("feeds", feeds);
|
||
}
|
||
}
|
||
|
||
async scoreSpocs(spocsState) {
|
||
const spocsResultPromises = this.getPlacements().map(async placement => {
|
||
const nextSpocs = spocsState.data[placement.name] || {};
|
||
const { items } = nextSpocs;
|
||
|
||
if (nextSpocs.personalized || !items || !items.length) {
|
||
return;
|
||
}
|
||
|
||
const { data: scoreResult, personalized } = await this.scoreItems(
|
||
items,
|
||
"spocs"
|
||
);
|
||
|
||
spocsState.data = {
|
||
...spocsState.data,
|
||
[placement.name]: {
|
||
...nextSpocs,
|
||
personalized,
|
||
items: scoreResult,
|
||
},
|
||
};
|
||
});
|
||
await Promise.all(spocsResultPromises);
|
||
|
||
// Update cache here so we don't need to re calculate scores on loads from cache.
|
||
// Related Bug 1606276
|
||
await this.cache.set("spocs", {
|
||
lastUpdated: spocsState.lastUpdated,
|
||
spocs: spocsState.data,
|
||
});
|
||
this.store.dispatch(
|
||
ac.AlsoToPreloaded({
|
||
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
|
||
data: {
|
||
lastUpdated: spocsState.lastUpdated,
|
||
spocs: spocsState.data,
|
||
},
|
||
})
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @typedef {Object} RefreshAll
|
||
* @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 component feeds, and spocs in order if caches have expired.
|
||
* @param {RefreshAll} options
|
||
*/
|
||
async refreshAll(options = {}) {
|
||
const { updateOpenTabs, isStartup } = options;
|
||
|
||
const dispatch = updateOpenTabs
|
||
? action => this.store.dispatch(ac.BroadcastToContent(action))
|
||
: this.store.dispatch;
|
||
|
||
this.loadLayout(dispatch, isStartup);
|
||
if (this.showStories || this.showTopsites) {
|
||
const spocsStartupCacheEnabled =
|
||
this.store.getState().Prefs.values[PREF_SPOCS_STARTUP_CACHE_ENABLED];
|
||
const promises = [];
|
||
|
||
// We could potentially have either or both sponsored topsites or stories.
|
||
// We only make one fetch, and control which to request when we fetch.
|
||
// So for now we only care if we need to make this request at all.
|
||
const spocsPromise = this.loadSpocs(
|
||
dispatch,
|
||
isStartup && spocsStartupCacheEnabled
|
||
).catch(error =>
|
||
console.error("Error trying to load spocs feeds:", error)
|
||
);
|
||
promises.push(spocsPromise);
|
||
if (this.showStories) {
|
||
const storiesPromise = this.loadComponentFeeds(
|
||
dispatch,
|
||
isStartup
|
||
).catch(error =>
|
||
console.error("Error trying to load component feeds:", error)
|
||
);
|
||
promises.push(storiesPromise);
|
||
}
|
||
await Promise.all(promises);
|
||
if (isStartup) {
|
||
// We don't pass isStartup in _maybeUpdateCachedData on purpose,
|
||
// because startup loads have a longer cache timer,
|
||
// and we want this to update in the background sooner.
|
||
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 DEFAULT_RECS_ROTATION_TIME.
|
||
async rotate(recommendations) {
|
||
const cachedData = (await this.cache.get()) || {};
|
||
const impressions = cachedData.recsImpressions;
|
||
|
||
// If we have no impressions, don't bother checking.
|
||
if (!impressions) {
|
||
return recommendations;
|
||
}
|
||
|
||
const expired = [];
|
||
const active = [];
|
||
for (const item of recommendations) {
|
||
if (
|
||
impressions[item.id] &&
|
||
Date.now() - impressions[item.id] >= DEFAULT_RECS_ROTATION_TIME
|
||
) {
|
||
expired.push(item);
|
||
} else {
|
||
active.push(item);
|
||
}
|
||
}
|
||
return active.concat(expired);
|
||
}
|
||
|
||
enableStories() {
|
||
if (this.config.enabled) {
|
||
// If stories are being re enabled, ensure we have stories.
|
||
this.refreshAll({ updateOpenTabs: true });
|
||
}
|
||
}
|
||
|
||
async enable(options = {}) {
|
||
await this.refreshAll(options);
|
||
this.loaded = true;
|
||
}
|
||
|
||
async reset() {
|
||
this.resetDataPrefs();
|
||
await this.resetCache();
|
||
this.resetState();
|
||
}
|
||
|
||
async resetCache() {
|
||
await this.resetAllCache();
|
||
}
|
||
|
||
async resetContentCache() {
|
||
await this.cache.set("feeds", {});
|
||
await this.cache.set("spocs", {});
|
||
await this.cache.set("sov", {});
|
||
await this.cache.set("recsImpressions", {});
|
||
}
|
||
|
||
async resetBlocks() {
|
||
await this.cache.set("recsBlocks", {});
|
||
const cachedData = (await this.cache.get()) || {};
|
||
let blocks = cachedData.recsBlocks || {};
|
||
|
||
this.store.dispatch({
|
||
type: at.DISCOVERY_STREAM_DEV_BLOCKS,
|
||
data: blocks,
|
||
});
|
||
// Update newtab after clearing blocks.
|
||
await this.refreshAll({ updateOpenTabs: true });
|
||
}
|
||
|
||
async resetContentFeed() {
|
||
await this.cache.set("feeds", {});
|
||
}
|
||
|
||
async resetAllCache() {
|
||
await this.resetContentCache();
|
||
// Reset in-memory caches.
|
||
this._isBff = undefined;
|
||
this._isMerino = undefined;
|
||
this._isContextualAds = undefined;
|
||
this._spocsCacheUpdateTime = undefined;
|
||
}
|
||
|
||
resetDataPrefs() {
|
||
this.writeDataPref(PREF_SPOC_IMPRESSIONS, {});
|
||
this.writeDataPref(PREF_FLIGHT_BLOCKS, {});
|
||
}
|
||
|
||
resetState() {
|
||
// Reset reducer
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({ type: at.DISCOVERY_STREAM_LAYOUT_RESET })
|
||
);
|
||
this.setupPrefs(false /* isStartup */);
|
||
this.loaded = false;
|
||
}
|
||
|
||
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({ updateOpenTabs: true });
|
||
}
|
||
}
|
||
|
||
// This is a request to change the config from somewhere.
|
||
// Can be from a specific pref related to Discovery Stream,
|
||
// or can be a generic request from an external feed that
|
||
// something changed.
|
||
configReset() {
|
||
this._prefCache.config = null;
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
|
||
data: this.config,
|
||
})
|
||
);
|
||
}
|
||
|
||
recordFlightImpression(flightId) {
|
||
let impressions = this.readDataPref(PREF_SPOC_IMPRESSIONS);
|
||
|
||
const timeStamps = impressions[flightId] || [];
|
||
timeStamps.push(Date.now());
|
||
impressions = { ...impressions, [flightId]: timeStamps };
|
||
|
||
this.writeDataPref(PREF_SPOC_IMPRESSIONS, impressions);
|
||
}
|
||
|
||
async recordTopRecImpression(recId) {
|
||
const cachedData = (await this.cache.get()) || {};
|
||
let impressions = cachedData.recsImpressions || {};
|
||
|
||
if (!impressions[recId]) {
|
||
impressions = { ...impressions, [recId]: Date.now() };
|
||
await this.cache.set("recsImpressions", impressions);
|
||
|
||
this.store.dispatch({
|
||
type: at.DISCOVERY_STREAM_DEV_IMPRESSIONS,
|
||
data: impressions,
|
||
});
|
||
}
|
||
}
|
||
|
||
async recordBlockRecId(recId) {
|
||
const cachedData = (await this.cache.get()) || {};
|
||
let blocks = cachedData.recsBlocks || {};
|
||
|
||
if (!blocks[recId]) {
|
||
blocks[recId] = 1;
|
||
await this.cache.set("recsBlocks", blocks);
|
||
|
||
this.store.dispatch({
|
||
type: at.DISCOVERY_STREAM_DEV_BLOCKS,
|
||
data: blocks,
|
||
});
|
||
}
|
||
}
|
||
|
||
recordBlockFlightId(flightId) {
|
||
const unifiedAdsEnabled =
|
||
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_SPOCS_ENABLED];
|
||
|
||
const flights = this.readDataPref(PREF_FLIGHT_BLOCKS);
|
||
if (!flights[flightId]) {
|
||
flights[flightId] = 1;
|
||
this.writeDataPref(PREF_FLIGHT_BLOCKS, flights);
|
||
|
||
if (unifiedAdsEnabled) {
|
||
let blockList =
|
||
this.store.getState().Prefs.values[PREF_UNIFIED_ADS_BLOCKED_LIST];
|
||
|
||
let blockedAdsArray = [];
|
||
|
||
// If prev ads have been blocked, convert CSV string to array
|
||
if (blockList !== "") {
|
||
blockedAdsArray = blockList
|
||
.split(",")
|
||
.map(s => s.trim())
|
||
.filter(item => item);
|
||
}
|
||
|
||
blockedAdsArray.push(flightId);
|
||
|
||
this.store.dispatch(
|
||
ac.SetPref(PREF_UNIFIED_ADS_BLOCKED_LIST, blockedAdsArray.join(","))
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
cleanUpFlightImpressionPref(data) {
|
||
let flightIds = [];
|
||
this.placementsForEach(placement => {
|
||
const newSpocs = data[placement.name];
|
||
if (!newSpocs) {
|
||
return;
|
||
}
|
||
|
||
const items = newSpocs.items || [];
|
||
flightIds = [...flightIds, ...items.map(s => `${s.flight_id}`)];
|
||
});
|
||
if (flightIds && flightIds.length) {
|
||
this.cleanUpImpressionPref(
|
||
id => !flightIds.includes(id),
|
||
PREF_SPOC_IMPRESSIONS
|
||
);
|
||
}
|
||
}
|
||
|
||
// Clean up rec impressions that are old.
|
||
async cleanUpTopRecImpressions() {
|
||
await this.cleanUpImpressionCache(
|
||
impression =>
|
||
Date.now() - impression >= DEFAULT_RECS_IMPRESSION_EXPIRE_TIME,
|
||
"recsImpressions"
|
||
);
|
||
}
|
||
|
||
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) : {};
|
||
}
|
||
|
||
async cleanUpImpressionCache(isExpired, cacheKey) {
|
||
const cachedData = (await this.cache.get()) || {};
|
||
let impressions = cachedData[cacheKey];
|
||
let changed = false;
|
||
|
||
if (impressions) {
|
||
Object.keys(impressions).forEach(id => {
|
||
if (isExpired(impressions[id])) {
|
||
changed = true;
|
||
delete impressions[id];
|
||
}
|
||
});
|
||
|
||
if (changed) {
|
||
await this.cache.set(cacheKey, impressions);
|
||
|
||
this.store.dispatch({
|
||
type: at.DISCOVERY_STREAM_DEV_IMPRESSIONS,
|
||
data: impressions,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
onCollectionsChanged() {
|
||
// Update layout, and reload any off screen tabs.
|
||
// This does not change any existing open tabs.
|
||
// It also doesn't update any spoc or rec data, just the layout.
|
||
const dispatch = action => this.store.dispatch(ac.AlsoToPreloaded(action));
|
||
this.loadLayout(dispatch, false);
|
||
}
|
||
|
||
async retreiveProfileAge() {
|
||
let profileAccessor = await lazy.ProfileAge();
|
||
let profileCreateTime = await profileAccessor.created;
|
||
let timeNow = new Date().getTime();
|
||
let profileAge = timeNow - profileCreateTime;
|
||
// Convert milliseconds to days
|
||
return profileAge / 1000 / 60 / 60 / 24;
|
||
}
|
||
|
||
topicSelectionImpressionEvent() {
|
||
let counter =
|
||
this.store.getState().Prefs.values[TOPIC_SELECTION_DISPLAY_COUNT];
|
||
|
||
const newCount = counter + 1;
|
||
this.store.dispatch(ac.SetPref(TOPIC_SELECTION_DISPLAY_COUNT, newCount));
|
||
this.store.dispatch(
|
||
ac.SetPref(TOPIC_SELECTION_LAST_DISPLAYED, `${new Date().getTime()}`)
|
||
);
|
||
}
|
||
|
||
topicSelectionMaybeLaterEvent() {
|
||
const age = this.retreiveProfileAge();
|
||
const newProfile = age <= 1;
|
||
const day = 24 * 60 * 60 * 1000;
|
||
this.store.dispatch(
|
||
ac.SetPref(
|
||
TOPIC_SELECTION_DISPLAY_TIMEOUT,
|
||
newProfile ? 3 * day : 7 * day
|
||
)
|
||
);
|
||
}
|
||
|
||
async onPrefChangedAction(action) {
|
||
switch (action.data.name) {
|
||
case PREF_CONFIG:
|
||
case PREF_ENABLED:
|
||
case PREF_HARDCODED_BASIC_LAYOUT:
|
||
case PREF_SPOCS_ENDPOINT:
|
||
case PREF_SPOCS_ENDPOINT_QUERY:
|
||
case PREF_SPOCS_CLEAR_ENDPOINT:
|
||
case PREF_ENDPOINTS:
|
||
case PREF_LAYOUT_EXPERIMENT_A:
|
||
case PREF_LAYOUT_EXPERIMENT_B:
|
||
case PREF_SPOC_POSITIONS:
|
||
case PREF_UNIFIED_ADS_SPOCS_ENABLED:
|
||
case PREF_CONTEXTUAL_CONTENT_ENABLED:
|
||
case PREF_CONTEXTUAL_CONTENT_SELECTED_FEED:
|
||
case PREF_SECTIONS_ENABLED:
|
||
case PREF_INTEREST_PICKER_ENABLED:
|
||
// This is a config reset directly related to Discovery Stream pref.
|
||
this.configReset();
|
||
break;
|
||
case PREF_CONTEXTUAL_ADS:
|
||
case PREF_USER_INFERRED_PERSONALIZATION:
|
||
case PREF_SYSTEM_INFERRED_PERSONALIZATION:
|
||
this._isContextualAds = undefined;
|
||
break;
|
||
case PREF_COLLECTIONS_ENABLED:
|
||
this.onCollectionsChanged();
|
||
break;
|
||
case PREF_SELECTED_TOPICS:
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({ type: at.DISCOVERY_STREAM_LAYOUT_RESET })
|
||
);
|
||
// Ensure at least a little bit of loading is seen, if this is too fast,
|
||
// it's not clear to the user what just happened.
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.DISCOVERY_STREAM_TOPICS_LOADING,
|
||
data: true,
|
||
})
|
||
);
|
||
setTimeout(() => {
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.DISCOVERY_STREAM_TOPICS_LOADING,
|
||
data: false,
|
||
})
|
||
);
|
||
}, TOPIC_LOADING_TIMEOUT);
|
||
this.loadLayout(
|
||
a => this.store.dispatch(ac.BroadcastToContent(a)),
|
||
false
|
||
);
|
||
|
||
// when topics have been updated, make a new request from merino and clear impression cap
|
||
await this.cache.set("recsImpressions", {});
|
||
await this.resetContentFeed();
|
||
this.refreshAll({ updateOpenTabs: true });
|
||
break;
|
||
case PREF_USER_TOPSITES:
|
||
case PREF_SYSTEM_TOPSITES:
|
||
if (
|
||
!(
|
||
this.showTopsites ||
|
||
(this.showStories && this.showSponsoredStories)
|
||
)
|
||
) {
|
||
// Ensure we delete any remote data potentially related to spocs.
|
||
this.clearSpocs();
|
||
}
|
||
break;
|
||
case PREF_USER_TOPSTORIES:
|
||
case PREF_SYSTEM_TOPSTORIES:
|
||
if (
|
||
!(
|
||
this.showStories ||
|
||
(this.showTopsites && this.showSponsoredTopsites)
|
||
)
|
||
) {
|
||
// Ensure we delete any remote data potentially related to spocs.
|
||
this.clearSpocs();
|
||
}
|
||
if (action.data.value) {
|
||
this.enableStories();
|
||
}
|
||
break;
|
||
// Check if spocs was disabled. Remove them if they were.
|
||
case PREF_SHOW_SPONSORED:
|
||
case PREF_SHOW_SPONSORED_TOPSITES: {
|
||
const dispatch = update =>
|
||
this.store.dispatch(ac.BroadcastToContent(update));
|
||
// We refresh placements data because one of the spocs were turned off.
|
||
this.updatePlacements(
|
||
dispatch,
|
||
this.store.getState().DiscoveryStream.layout
|
||
);
|
||
// Currently the order of this is important.
|
||
// We need to check this after updatePlacements is called,
|
||
// because some of the spoc logic depends on the result of placement updates.
|
||
if (
|
||
!(
|
||
(this.showSponsoredStories ||
|
||
(this.showTopsites && this.showSponsoredTopsites)) &&
|
||
(this.showSponsoredTopsites ||
|
||
(this.showStories && this.showSponsoredStories))
|
||
)
|
||
) {
|
||
// Ensure we delete any remote data potentially related to spocs.
|
||
this.clearSpocs();
|
||
}
|
||
// Placements have changed so consider spocs expired, and reload them.
|
||
await this.cache.set("spocs", {});
|
||
await this.loadSpocs(dispatch);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
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.setupConfig(true /* isStartup */);
|
||
this.setupPrefs(true /* isStartup */);
|
||
// 2. If config.enabled is true, start loading data.
|
||
if (this.config.enabled) {
|
||
await this.enable({ updateOpenTabs: true, isStartup: true });
|
||
}
|
||
Services.prefs.addObserver(PREF_POCKET_BUTTON, this);
|
||
// This function is async but just for devtools,
|
||
// so we don't need to wait for it.
|
||
this.setupDevtoolsState(true /* isStartup */);
|
||
break;
|
||
case at.TOPIC_SELECTION_MAYBE_LATER:
|
||
this.topicSelectionMaybeLaterEvent();
|
||
break;
|
||
case at.DISCOVERY_STREAM_DEV_BLOCKS_RESET:
|
||
await this.resetBlocks();
|
||
break;
|
||
case at.DISCOVERY_STREAM_DEV_SYSTEM_TICK:
|
||
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_DEV_SYNC_RS:
|
||
lazy.RemoteSettings.pollChanges();
|
||
break;
|
||
case at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE:
|
||
// Personalization scores update at a slower interval than content, so in order to debug,
|
||
// we want to be able to expire just content to trigger the earlier expire times.
|
||
await this.resetContentCache();
|
||
break;
|
||
case at.DISCOVERY_STREAM_DEV_SHOW_PLACEHOLDER: {
|
||
// We want to display the loading state permanently, for dev purposes.
|
||
// We do this by resetting everything, loading the layout, and nothing else.
|
||
// This essentially hangs because we never triggered the content load.
|
||
await this.reset();
|
||
this.loadLayout(
|
||
a => this.store.dispatch(ac.BroadcastToContent(a)),
|
||
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_POCKET_STATE_INIT:
|
||
this.setupPocketState(action.meta.fromTarget);
|
||
break;
|
||
case at.DISCOVERY_STREAM_PERSONALIZATION_UPDATED:
|
||
if (this.personalized) {
|
||
const { feeds, spocs } = this.store.getState().DiscoveryStream;
|
||
const spocsPersonalized =
|
||
this.store.getState().Prefs.values?.pocketConfig?.spocsPersonalized;
|
||
const recsPersonalized =
|
||
this.store.getState().Prefs.values?.pocketConfig?.recsPersonalized;
|
||
if (recsPersonalized && feeds.loaded) {
|
||
this.scoreFeeds(feeds);
|
||
}
|
||
if (spocsPersonalized && spocs.loaded) {
|
||
this.scoreSpocs(spocs);
|
||
}
|
||
}
|
||
break;
|
||
case at.DISCOVERY_STREAM_CONFIG_RESET:
|
||
// This is a generic config reset likely related to an external feed pref.
|
||
this.configReset();
|
||
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.recordTopRecImpression(action.data.tiles[0].id);
|
||
}
|
||
break;
|
||
case at.DISCOVERY_STREAM_SPOC_IMPRESSION:
|
||
if (this.showSpocs) {
|
||
this.recordFlightImpression(action.data.flightId);
|
||
|
||
// 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 spocs = spocsState.data[placement.name];
|
||
if (!spocs || !spocs.items) {
|
||
return;
|
||
}
|
||
|
||
const { data: capResult, filtered } = this.frequencyCapSpocs(
|
||
spocs.items
|
||
);
|
||
frequencyCapped = [...frequencyCapped, ...filtered];
|
||
|
||
spocsState.data = {
|
||
...spocsState.data,
|
||
[placement.name]: {
|
||
...spocs,
|
||
items: capResult,
|
||
},
|
||
};
|
||
});
|
||
|
||
if (frequencyCapped.length) {
|
||
// Update cache here so we don't need to re calculate frequency caps on loads from cache.
|
||
await this.cache.set("spocs", {
|
||
lastUpdated: spocsState.lastUpdated,
|
||
spocs: spocsState.data,
|
||
});
|
||
|
||
this.store.dispatch(
|
||
ac.AlsoToPreloaded({
|
||
type: at.DISCOVERY_STREAM_SPOCS_UPDATE,
|
||
data: {
|
||
lastUpdated: spocsState.lastUpdated,
|
||
spocs: spocsState.data,
|
||
},
|
||
})
|
||
);
|
||
}
|
||
}
|
||
break;
|
||
|
||
// This is fired from the browser, it has no concept of spocs, flights or pocket.
|
||
// We match the blocked url with our available story 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: {
|
||
const feedsState = this.store.getState().DiscoveryStream.feeds;
|
||
const feeds = {};
|
||
|
||
for (const url of Object.keys(feedsState.data)) {
|
||
let feed = feedsState.data[url];
|
||
|
||
const { data: filteredResults } = await this.filterBlocked(
|
||
feed.data.recommendations
|
||
);
|
||
|
||
feed = {
|
||
...feed,
|
||
data: {
|
||
...feed.data,
|
||
recommendations: filteredResults,
|
||
},
|
||
};
|
||
|
||
feeds[url] = feed;
|
||
}
|
||
|
||
await this.cache.set("feeds", feeds);
|
||
|
||
if (this.showSpocs) {
|
||
let blockedItems = [];
|
||
const spocsState = this.store.getState().DiscoveryStream.spocs;
|
||
|
||
this.placementsForEach(placement => {
|
||
const spocs = spocsState.data[placement.name];
|
||
if (spocs && spocs.items && spocs.items.length) {
|
||
const blockedResults = [];
|
||
const blocks = spocs.items.filter(s => {
|
||
const blocked = s.url === action.data.url;
|
||
if (!blocked) {
|
||
blockedResults.push(s);
|
||
}
|
||
return blocked;
|
||
});
|
||
|
||
blockedItems = [...blockedItems, ...blocks];
|
||
|
||
spocsState.data = {
|
||
...spocsState.data,
|
||
[placement.name]: {
|
||
...spocs,
|
||
items: blockedResults,
|
||
},
|
||
};
|
||
}
|
||
});
|
||
|
||
if (blockedItems.length) {
|
||
// Update cache here so we don't need to re calculate blocks on loads from cache.
|
||
await this.cache.set("spocs", {
|
||
lastUpdated: spocsState.lastUpdated,
|
||
spocs: spocsState.data,
|
||
});
|
||
|
||
// If we're blocking a spoc, we want open tabs to have
|
||
// a slightly different treatment from future 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();
|
||
this._recommendationProvider = null;
|
||
Services.prefs.removeObserver(PREF_POCKET_BUTTON, this);
|
||
break;
|
||
case at.BLOCK_URL: {
|
||
// If we block a story that also has a flight_id
|
||
// we want to record that as blocked too.
|
||
// This is because a single flight might have slightly different urls.
|
||
for (const site of action.data) {
|
||
const { flight_id, tile_id } = site;
|
||
if (flight_id) {
|
||
this.recordBlockFlightId(flight_id);
|
||
}
|
||
if (tile_id) {
|
||
await this.recordBlockRecId(tile_id);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case at.PREF_CHANGED:
|
||
await this.onPrefChangedAction(action);
|
||
if (action.data.name === "pocketConfig") {
|
||
await this.onPrefChange();
|
||
this.setupPrefs(false /* isStartup */);
|
||
}
|
||
break;
|
||
case at.TOPIC_SELECTION_IMPRESSION:
|
||
this.topicSelectionImpressionEvent();
|
||
break;
|
||
case at.SECTION_PERSONALIZATION_SET:
|
||
await this.cache.set("sectionPersonalization", action.data);
|
||
this.store.dispatch(
|
||
ac.BroadcastToContent({
|
||
type: at.SECTION_PERSONALIZATION_UPDATE,
|
||
data: action.data,
|
||
})
|
||
);
|
||
break;
|
||
case at.INFERRED_PERSONALIZATION_MODEL_UPDATE:
|
||
await this.cache.set("inferredModel", action.data);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* This function generates a hardcoded layout each call.
|
||
This is because modifying the original object would
|
||
persist across pref changes and system_tick updates.
|
||
|
||
NOTE: There is some branching logic in the template.
|
||
`spocsUrl` Changing the url for spocs is used for adding a siteId query param.
|
||
`feedUrl` Where to fetch stories from.
|
||
`items` How many items to include in the primary card grid.
|
||
`spocPositions` Changes the position of spoc cards.
|
||
`spocTopsitesPositions` Changes the position of spoc topsites.
|
||
`spocPlacementData` Used to set the spoc content.
|
||
`spocTopsitesPlacementEnabled` Tuns on and off the sponsored topsites placement.
|
||
`spocTopsitesPlacementData` Used to set spoc content for topsites.
|
||
`sponsoredCollectionsEnabled` Tuns on and off the sponsored collection section.
|
||
`hybridLayout` Changes cards to smaller more compact cards only for specific breakpoints.
|
||
`hideCardBackground` Removes Pocket card background and borders.
|
||
`fourCardLayout` Enable four Pocket cards per row.
|
||
`newFooterSection` Changes the layout of the topics section.
|
||
`compactGrid` Reduce the number of pixels between the Pocket cards.
|
||
`essentialReadsHeader` Updates the Pocket section header and title to say "Today’s Essential Reads", moves the "Recommended by Pocket" header to the right side.
|
||
`editorsPicksHeader` Updates the Pocket section header and title to say "Editor’s Picks", if used with essentialReadsHeader, creates a second section 2 rows down for editorsPicks.
|
||
`onboardingExperience` Show new users some UI explaining Pocket above the Pocket section.
|
||
`ctaButtonSponsors` An array of sponsors we want to show a cta button on the card for.
|
||
`ctaButtonVariant` Sets the variant for the cta sponsor button.
|
||
`spocMessageVariant` Sets the variant for the sponsor message dialog.
|
||
*/
|
||
getHardcodedLayout = ({
|
||
spocsUrl = SPOCS_URL,
|
||
feedUrl = FEED_URL,
|
||
items = 21,
|
||
spocPositions = [1, 5, 7, 11, 18, 20],
|
||
spocTopsitesPositions = [1],
|
||
spocPlacementData = { ad_types: [3617], zone_ids: [217758, 217995] },
|
||
spocTopsitesPlacementEnabled = false,
|
||
spocTopsitesPlacementData = { ad_types: [3120], zone_ids: [280143] },
|
||
widgetPositions = [],
|
||
widgetData = [],
|
||
sponsoredCollectionsEnabled = false,
|
||
hybridLayout = false,
|
||
hideCardBackground = false,
|
||
fourCardLayout = false,
|
||
newFooterSection = false,
|
||
compactGrid = false,
|
||
essentialReadsHeader = false,
|
||
editorsPicksHeader = false,
|
||
onboardingExperience = false,
|
||
ctaButtonSponsors = [],
|
||
ctaButtonVariant = "",
|
||
spocMessageVariant = "",
|
||
pocketStoriesHeadlineId = "newtab-section-header-stories",
|
||
}) => ({
|
||
lastUpdate: Date.now(),
|
||
spocs: {
|
||
url: spocsUrl,
|
||
},
|
||
layout: [
|
||
{
|
||
width: 12,
|
||
components: [
|
||
{
|
||
type: "TopSites",
|
||
header: {
|
||
title: {
|
||
id: "newtab-section-header-topsites",
|
||
},
|
||
},
|
||
...(spocTopsitesPlacementEnabled && spocTopsitesPlacementData
|
||
? {
|
||
placement: {
|
||
name: "sponsored-topsites",
|
||
ad_types: spocTopsitesPlacementData.ad_types,
|
||
zone_ids: spocTopsitesPlacementData.zone_ids,
|
||
},
|
||
spocs: {
|
||
probability: 1,
|
||
prefs: [PREF_SHOW_SPONSORED_TOPSITES],
|
||
positions: spocTopsitesPositions.map(position => {
|
||
return { index: position };
|
||
}),
|
||
},
|
||
}
|
||
: {}),
|
||
properties: {},
|
||
},
|
||
...(sponsoredCollectionsEnabled
|
||
? [
|
||
{
|
||
type: "CollectionCardGrid",
|
||
properties: {
|
||
items: 3,
|
||
},
|
||
header: {
|
||
title: "",
|
||
},
|
||
placement: {
|
||
name: "sponsored-collection",
|
||
ad_types: [3617],
|
||
zone_ids: [217759, 218031],
|
||
},
|
||
spocs: {
|
||
probability: 1,
|
||
positions: [
|
||
{
|
||
index: 0,
|
||
},
|
||
{
|
||
index: 1,
|
||
},
|
||
{
|
||
index: 2,
|
||
},
|
||
],
|
||
},
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
type: "Message",
|
||
essentialReadsHeader,
|
||
editorsPicksHeader,
|
||
header: {
|
||
title: {
|
||
id: pocketStoriesHeadlineId,
|
||
},
|
||
subtitle: "",
|
||
link_text: {
|
||
id: "newtab-pocket-learn-more",
|
||
},
|
||
link_url: "",
|
||
icon: "chrome://global/skin/icons/pocket.svg",
|
||
},
|
||
properties: {
|
||
spocMessageVariant,
|
||
},
|
||
styles: {
|
||
".ds-message": "margin-bottom: -20px",
|
||
},
|
||
},
|
||
{
|
||
type: "CardGrid",
|
||
properties: {
|
||
items,
|
||
hybridLayout,
|
||
hideCardBackground,
|
||
fourCardLayout,
|
||
compactGrid,
|
||
essentialReadsHeader,
|
||
editorsPicksHeader,
|
||
onboardingExperience,
|
||
ctaButtonSponsors,
|
||
ctaButtonVariant,
|
||
spocMessageVariant,
|
||
},
|
||
widgets: {
|
||
positions: widgetPositions.map(position => {
|
||
return { index: position };
|
||
}),
|
||
data: widgetData,
|
||
},
|
||
cta_variant: "link",
|
||
header: {
|
||
title: "",
|
||
},
|
||
placement: {
|
||
name: "newtab_spocs",
|
||
ad_types: spocPlacementData.ad_types,
|
||
zone_ids: spocPlacementData.zone_ids,
|
||
},
|
||
feed: {
|
||
embed_reference: null,
|
||
url: feedUrl,
|
||
},
|
||
spocs: {
|
||
probability: 1,
|
||
positions: spocPositions.map(position => {
|
||
return { index: position };
|
||
}),
|
||
},
|
||
},
|
||
{
|
||
type: "Navigation",
|
||
newFooterSection,
|
||
properties: {
|
||
alignment: "left-align",
|
||
extraLinks: [
|
||
{
|
||
name: "Career",
|
||
url: "https://getpocket.com/explore/career?utm_source=pocket-newtab",
|
||
},
|
||
{
|
||
name: "Technology",
|
||
url: "https://getpocket.com/explore/technology?utm_source=pocket-newtab",
|
||
},
|
||
],
|
||
privacyNoticeURL: {
|
||
url: "https://www.mozilla.org/privacy/firefox/#recommend-relevant-content",
|
||
title: {
|
||
id: "newtab-section-menu-privacy-notice",
|
||
},
|
||
},
|
||
},
|
||
styles: {
|
||
".ds-navigation": "margin-top: -10px;",
|
||
},
|
||
},
|
||
...(newFooterSection
|
||
? [
|
||
{
|
||
type: "PrivacyLink",
|
||
properties: {
|
||
url: "https://www.mozilla.org/privacy/firefox/",
|
||
title: {
|
||
id: "newtab-section-menu-privacy-notice",
|
||
},
|
||
},
|
||
},
|
||
]
|
||
: []),
|
||
],
|
||
},
|
||
],
|
||
});
|