123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811 |
- /* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
- "use strict";
- const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
- const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
- const {NewTabUtils} = ChromeUtils.import("resource://gre/modules/NewTabUtils.jsm");
- XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
- const {actionTypes: at, actionCreators: ac} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
- const {Prefs} = ChromeUtils.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm");
- const {shortURL} = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm");
- const {SectionsManager} = ChromeUtils.import("resource://activity-stream/lib/SectionsManager.jsm");
- const {UserDomainAffinityProvider} = ChromeUtils.import("resource://activity-stream/lib/UserDomainAffinityProvider.jsm");
- const {PersonalityProvider} = ChromeUtils.import("resource://activity-stream/lib/PersonalityProvider.jsm");
- const {PersistentCache} = ChromeUtils.import("resource://activity-stream/lib/PersistentCache.jsm");
- ChromeUtils.defineModuleGetter(this, "perfService", "resource://activity-stream/common/PerfService.jsm");
- ChromeUtils.defineModuleGetter(this, "pktApi", "chrome://pocket/content/pktApi.jsm");
- const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
- const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours
- const STORIES_NOW_THRESHOLD = 24 * 60 * 60 * 1000; // 24 hours
- const MIN_DOMAIN_AFFINITIES_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours
- const DEFAULT_RECS_EXPIRE_TIME = 60 * 60 * 1000; // 1 hour
- const SECTION_ID = "topstories";
- const IMPRESSION_SOURCE = "TOP_STORIES";
- const SPOC_IMPRESSION_TRACKING_PREF = "feeds.section.topstories.spoc.impressions";
- const REC_IMPRESSION_TRACKING_PREF = "feeds.section.topstories.rec.impressions";
- const OPTIONS_PREF = "feeds.section.topstories.options";
- const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server
- const DISCOVERY_STREAM_PREF = "discoverystream.config";
- this.TopStoriesFeed = class TopStoriesFeed {
- constructor(ds) {
- // Use discoverystream config pref default values for fast path and
- // if needed lazy load activity stream top stories feed based on
- // actual user preference when PREFS_INITIAL_VALUES and PREF_CHANGED is invoked
- this.discoveryStreamEnabled = ds && ds.value && JSON.parse(ds.value).enabled;
- if (!this.discoveryStreamEnabled) {
- this.initializeProperties();
- }
- }
- initializeProperties() {
- this.contentUpdateQueue = [];
- this.spocCampaignMap = new Map();
- this.cache = new PersistentCache(SECTION_ID, true);
- this._prefs = new Prefs();
- this.propertiesInitialized = true;
- }
- async onInit() {
- SectionsManager.enableSection(SECTION_ID);
- if (this.discoveryStreamEnabled) {
- return;
- }
- try {
- const {options} = SectionsManager.sections.get(SECTION_ID);
- const apiKey = this.getApiKeyFromPref(options.api_key_pref);
- this.stories_endpoint = this.produceFinalEndpointUrl(options.stories_endpoint, apiKey);
- this.topics_endpoint = this.produceFinalEndpointUrl(options.topics_endpoint, apiKey);
- this.read_more_endpoint = options.read_more_endpoint;
- this.stories_referrer = options.stories_referrer;
- this.personalized = options.personalized;
- this.show_spocs = options.show_spocs;
- this.maxHistoryQueryResults = options.maxHistoryQueryResults;
- this.storiesLastUpdated = 0;
- this.topicsLastUpdated = 0;
- this.storiesLoaded = false;
- this.domainAffinitiesLastUpdated = 0;
- this.processAffinityProividerVersion(options);
- this.dispatchPocketCta(this._prefs.get("pocketCta"), false);
- Services.obs.addObserver(this, "idle-daily");
- // Cache is used for new page loads, which shouldn't have changed data.
- // If we have changed data, cache should be cleared,
- // and last updated should be 0, and we can fetch.
- let {stories, topics} = await this.loadCachedData();
- if (this.storiesLastUpdated === 0) {
- stories = await this.fetchStories();
- }
- if (this.topicsLastUpdated === 0) {
- topics = await this.fetchTopics();
- }
- this.doContentUpdate({stories, topics}, true);
- this.storiesLoaded = true;
- // This is filtered so an update function can return true to retry on the next run
- this.contentUpdateQueue = this.contentUpdateQueue.filter(update => update());
- } catch (e) {
- Cu.reportError(`Problem initializing top stories feed: ${e.message}`);
- }
- }
- init() {
- SectionsManager.onceInitialized(this.onInit.bind(this));
- }
- observe(subject, topic, data) {
- switch (topic) {
- case "idle-daily":
- this.updateDomainAffinityScores();
- break;
- }
- }
- async clearCache() {
- await this.cache.set("stories", {});
- await this.cache.set("topics", {});
- await this.cache.set("spocs", {});
- }
- uninit() {
- this.storiesLoaded = false;
- try {
- Services.obs.removeObserver(this, "idle-daily");
- } catch (e) {
- // Attempt to remove unassociated observer which is possible when discovery stream
- // is enabled and user never used activity stream experience
- }
- SectionsManager.disableSection(SECTION_ID);
- }
- getPocketState(target) {
- const action = {type: at.POCKET_LOGGED_IN, data: pktApi.isUserLoggedIn()};
- this.store.dispatch(ac.OnlyToOneContent(action, target));
- }
- dispatchPocketCta(data, shouldBroadcast) {
- const action = {type: at.POCKET_CTA, data: JSON.parse(data)};
- this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : ac.AlsoToPreloaded(action));
- }
- /**
- * doContentUpdate - Updates topics and stories in the topstories section.
- *
- * Sections have one update action for the whole section.
- * Redux creates a state race condition if you call the same action,
- * twice, concurrently. Because of this, doContentUpdate is
- * one place to update both topics and stories in a single action.
- *
- * Section updates used old topics if none are available,
- * but clear stories if none are available. Because of this, if no
- * stories are passed, we instead use the existing stories in state.
- *
- * @param {Object} This is an object with potential new stories or topics.
- * @param {Boolean} shouldBroadcast If we should update existing tabs or not. For first page
- * loads or pref changes, we want to update existing tabs,
- * for system tick or other updates we do not.
- */
- doContentUpdate({stories, topics}, shouldBroadcast) {
- let updateProps = {};
- if (stories) {
- updateProps.rows = stories;
- } else {
- const {Sections} = this.store.getState();
- if (Sections && Sections.find) {
- updateProps.rows = Sections.find(s => s.id === SECTION_ID).rows;
- }
- }
- if (topics) {
- Object.assign(updateProps, {topics, read_more_endpoint: this.read_more_endpoint});
- }
- // We should only be calling this once per init.
- this.dispatchUpdateEvent(shouldBroadcast, updateProps);
- }
- async onPersonalityProviderInit() {
- const data = await this.cache.get();
- let stories = data.stories && data.stories.recommendations;
- this.stories = this.rotate(this.transform(stories));
- this.doContentUpdate({stories: this.stories}, false);
- const affinities = this.affinityProvider.getAffinities();
- this.domainAffinitiesLastUpdated = Date.now();
- affinities._timestamp = this.domainAffinitiesLastUpdated;
- this.cache.set("domainAffinities", affinities);
- }
- affinityProividerSwitcher(...args) {
- const {affinityProviderV2} = this;
- if (affinityProviderV2 && affinityProviderV2.use_v2) {
- const provider = this.PersonalityProvider(...args, {modelKeys: affinityProviderV2.model_keys, dispatch: this.store.dispatch});
- provider.init(this.onPersonalityProviderInit.bind(this));
- return provider;
- }
- const start = perfService.absNow();
- const v1Provider = this.UserDomainAffinityProvider(...args);
- this.store.dispatch(ac.PerfEvent({
- event: "topstories.domain.affinity.calculation.ms",
- value: Math.round(perfService.absNow() - start),
- }));
- return v1Provider;
- }
- PersonalityProvider(...args) {
- return new PersonalityProvider(...args);
- }
- UserDomainAffinityProvider(...args) {
- return new UserDomainAffinityProvider(...args);
- }
- async fetchStories() {
- if (!this.stories_endpoint) {
- return null;
- }
- try {
- const response = await fetch(this.stories_endpoint, {credentials: "omit"});
- if (!response.ok) {
- throw new Error(`Stories endpoint returned unexpected status: ${response.status}`);
- }
- const body = await response.json();
- this.updateSettings(body.settings);
- this.stories = this.rotate(this.transform(body.recommendations));
- this.cleanUpTopRecImpressionPref();
- if (this.show_spocs && body.spocs) {
- this.spocCampaignMap = new Map(body.spocs.map(s => [s.id, `${s.campaign_id}`]));
- this.spocs = this.transform(body.spocs).filter(s => s.score >= s.min_score);
- this.cleanUpCampaignImpressionPref();
- }
- this.storiesLastUpdated = Date.now();
- body._timestamp = this.storiesLastUpdated;
- this.cache.set("stories", body);
- } catch (error) {
- Cu.reportError(`Failed to fetch content: ${error.message}`);
- }
- return this.stories;
- }
- async loadCachedData() {
- const data = await this.cache.get();
- let stories = data.stories && data.stories.recommendations;
- let topics = data.topics && data.topics.topics;
- let affinities = data.domainAffinities;
- if (this.personalized && affinities && affinities.scores) {
- this.affinityProvider = this.affinityProividerSwitcher(affinities.timeSegments,
- affinities.parameterSets, affinities.maxHistoryQueryResults, affinities.version, affinities.scores);
- this.domainAffinitiesLastUpdated = affinities._timestamp;
- }
- if (stories && stories.length > 0 && this.storiesLastUpdated === 0) {
- this.updateSettings(data.stories.settings);
- this.stories = this.rotate(this.transform(stories));
- this.storiesLastUpdated = data.stories._timestamp;
- if (data.stories.spocs && data.stories.spocs.length) {
- this.spocCampaignMap = new Map(data.stories.spocs.map(s => [s.id, `${s.campaign_id}`]));
- this.spocs = this.transform(data.stories.spocs).filter(s => s.score >= s.min_score);
- this.cleanUpCampaignImpressionPref();
- }
- }
- if (topics && topics.length > 0 && this.topicsLastUpdated === 0) {
- this.topics = topics;
- this.topicsLastUpdated = data.topics._timestamp;
- }
- return {topics: this.topics, stories: this.stories};
- }
- dispatchRelevanceScore(start) {
- let event = "PERSONALIZATION_V1_ITEM_RELEVANCE_SCORE_DURATION";
- let initialized = true;
- if (!this.personalized) {
- return;
- }
- const {affinityProviderV2} = this;
- if (affinityProviderV2 && affinityProviderV2.use_v2) {
- if (this.affinityProvider) {
- initialized = this.affinityProvider.initialized;
- event = "PERSONALIZATION_V2_ITEM_RELEVANCE_SCORE_DURATION";
- }
- }
- // If v2 is not yet initialized we don't bother tracking yet.
- // Before it is initialized it doesn't do any ranking.
- // Once it's initialized it ensures ranking is done.
- // v1 doesn't have any initialized issues around ranking,
- // and should be ready right away.
- if (initialized) {
- this.store.dispatch(ac.PerfEvent({
- event,
- value: Math.round(perfService.absNow() - start),
- }));
- }
- }
- transform(items) {
- if (!items) {
- return [];
- }
- const scoreStart = perfService.absNow();
- const calcResult = items
- .filter(s => !NewTabUtils.blockedLinks.isBlocked({"url": s.url}))
- .map(s => {
- let mapped = {
- "guid": s.id,
- "hostname": s.domain || shortURL(Object.assign({}, s, {url: s.url})),
- "type": (Date.now() - (s.published_timestamp * 1000)) <= STORIES_NOW_THRESHOLD ? "now" : "trending",
- "context": s.context,
- "icon": s.icon,
- "title": s.title,
- "description": s.excerpt,
- "image": this.normalizeUrl(s.image_src),
- "referrer": this.stories_referrer,
- "url": s.url,
- "min_score": s.min_score || 0,
- "score": this.personalized && this.affinityProvider ? this.affinityProvider.calculateItemRelevanceScore(s) : s.item_score || 1,
- "spoc_meta": this.show_spocs ? {campaign_id: s.campaign_id, caps: s.caps} : {},
- };
- // Very old cached spocs may not contain an `expiration_timestamp` property
- if (s.expiration_timestamp) {
- mapped.expiration_timestamp = s.expiration_timestamp;
- }
- return mapped;
- })
- .sort(this.personalized ? this.compareScore : (a, b) => 0);
- this.dispatchRelevanceScore(scoreStart);
- return calcResult;
- }
- async fetchTopics() {
- if (!this.topics_endpoint) {
- return null;
- }
- try {
- const response = await fetch(this.topics_endpoint, {credentials: "omit"});
- if (!response.ok) {
- throw new Error(`Topics endpoint returned unexpected status: ${response.status}`);
- }
- const body = await response.json();
- const {topics} = body;
- if (topics) {
- this.topics = topics;
- this.topicsLastUpdated = Date.now();
- body._timestamp = this.topicsLastUpdated;
- this.cache.set("topics", body);
- }
- } catch (error) {
- Cu.reportError(`Failed to fetch topics: ${error.message}`);
- }
- return this.topics;
- }
- dispatchUpdateEvent(shouldBroadcast, data) {
- SectionsManager.updateSection(SECTION_ID, data, shouldBroadcast);
- }
- compareScore(a, b) {
- return b.score - a.score;
- }
- updateSettings(settings) {
- if (!this.personalized) {
- return;
- }
- this.spocsPerNewTabs = settings.spocsPerNewTabs; // Probability of a new tab getting a spoc [0,1]
- this.timeSegments = settings.timeSegments;
- this.domainAffinityParameterSets = settings.domainAffinityParameterSets;
- this.recsExpireTime = settings.recsExpireTime;
- this.version = settings.version;
- if (this.affinityProvider && (this.affinityProvider.version !== this.version)) {
- this.resetDomainAffinityScores();
- }
- }
- updateDomainAffinityScores() {
- if (!this.personalized || !this.domainAffinityParameterSets ||
- Date.now() - this.domainAffinitiesLastUpdated < MIN_DOMAIN_AFFINITIES_UPDATE_TIME) {
- return;
- }
- this.affinityProvider = this.affinityProividerSwitcher(
- this.timeSegments,
- this.domainAffinityParameterSets,
- this.maxHistoryQueryResults,
- this.version, undefined);
- const affinities = this.affinityProvider.getAffinities();
- this.domainAffinitiesLastUpdated = Date.now();
- affinities._timestamp = this.domainAffinitiesLastUpdated;
- this.cache.set("domainAffinities", affinities);
- }
- resetDomainAffinityScores() {
- delete this.affinityProvider;
- this.cache.set("domainAffinities", {});
- }
- // If personalization is turned on, we have to rotate stories on the client so that
- // active stories are at the front of the list, followed by stories that have expired
- // impressions i.e. have been displayed for longer than recsExpireTime.
- rotate(items) {
- if (!this.personalized || items.length <= 3) {
- return items;
- }
- const maxImpressionAge = Math.max(this.recsExpireTime * 1000 || DEFAULT_RECS_EXPIRE_TIME, DEFAULT_RECS_EXPIRE_TIME);
- const impressions = this.readImpressionsPref(REC_IMPRESSION_TRACKING_PREF);
- const expired = [];
- const active = [];
- for (const item of items) {
- if (impressions[item.guid] && Date.now() - impressions[item.guid] >= maxImpressionAge) {
- expired.push(item);
- } else {
- active.push(item);
- }
- }
- return active.concat(expired);
- }
- getApiKeyFromPref(apiKeyPref) {
- if (!apiKeyPref) {
- return apiKeyPref;
- }
- return this._prefs.get(apiKeyPref) || Services.prefs.getCharPref(apiKeyPref);
- }
- produceFinalEndpointUrl(url, apiKey) {
- if (!url) {
- return url;
- }
- if (url.includes("$apiKey") && !apiKey) {
- throw new Error(`An API key was specified but none configured: ${url}`);
- }
- return url.replace("$apiKey", apiKey);
- }
- // Need to remove parenthesis from image URLs as React will otherwise
- // fail to render them properly as part of the card template.
- normalizeUrl(url) {
- if (url) {
- return url.replace(/\(/g, "%28").replace(/\)/g, "%29");
- }
- return url;
- }
- shouldShowSpocs() {
- return this.show_spocs && this.store.getState().Prefs.values.showSponsored;
- }
- dispatchSpocDone(target) {
- const action = {type: at.POCKET_WAITING_FOR_SPOC, data: false};
- this.store.dispatch(ac.OnlyToOneContent(action, target));
- }
- filterSpocs() {
- if (!this.shouldShowSpocs()) {
- return [];
- }
- if (Math.random() > this.spocsPerNewTabs) {
- return [];
- }
- if (!this.spocs || !this.spocs.length) {
- // We have stories but no spocs so there's nothing to do and this update can be
- // removed from the queue.
- return [];
- }
- // Filter spocs based on frequency caps
- const impressions = this.readImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF);
- let spocs = this.spocs.filter(s => this.isBelowFrequencyCap(impressions, s));
- // Filter out expired spocs based on `expiration_timestamp`
- spocs = spocs.filter(spoc => {
- // If cached data is so old it doesn't contain this property, assume the spoc is ok to show
- if (!(`expiration_timestamp` in spoc)) {
- return true;
- }
- // `expiration_timestamp` is the number of seconds elapsed since January 1, 1970 00:00:00 UTC
- return spoc.expiration_timestamp * 1000 > Date.now();
- });
- return spocs;
- }
- maybeAddSpoc(target) {
- const updateContent = () => {
- let spocs = this.filterSpocs();
- if (!spocs.length) {
- this.dispatchSpocDone(target);
- return false;
- }
- // Create a new array with a spoc inserted at index 2
- const section = this.store.getState().Sections.find(s => s.id === SECTION_ID);
- let rows = section.rows.slice(0, this.stories.length);
- rows.splice(2, 0, Object.assign(spocs[0], {pinned: true}));
- // Send a content update to the target tab
- const action = {type: at.SECTION_UPDATE, data: Object.assign({rows}, {id: SECTION_ID})};
- this.store.dispatch(ac.OnlyToOneContent(action, target));
- this.dispatchSpocDone(target);
- return false;
- };
- if (this.storiesLoaded) {
- updateContent();
- } else {
- // Delay updating tab content until initial data has been fetched
- this.contentUpdateQueue.push(updateContent);
- }
- }
- // Frequency caps are based on campaigns, which may include multiple spocs.
- // We currently support two types of frequency caps:
- // - lifetime: Indicates how many times spocs from a campaign can be shown in total
- // - period: Indicates how many times spocs from a campaign can be shown within a period
- //
- // So, for example, the feed configuration below defines that for campaign 1 no more
- // than 5 spocs can be show in total, and no more than 2 per hour.
- // "campaign_id": 1,
- // "caps": {
- // "lifetime": 5,
- // "campaign": {
- // "count": 2,
- // "period": 3600
- // }
- // }
- isBelowFrequencyCap(impressions, spoc) {
- const campaignImpressions = impressions[spoc.spoc_meta.campaign_id];
- if (!campaignImpressions) {
- return true;
- }
- const lifeTimeCap = Math.min(spoc.spoc_meta.caps && spoc.spoc_meta.caps.lifetime, MAX_LIFETIME_CAP);
- const lifeTimeCapExceeded = campaignImpressions.length >= lifeTimeCap;
- if (lifeTimeCapExceeded) {
- return false;
- }
- const campaignCap = (spoc.spoc_meta.caps && spoc.spoc_meta.caps.campaign) || {};
- const campaignCapExceeded = campaignImpressions
- .filter(i => (Date.now() - i) < (campaignCap.period * 1000)).length >= campaignCap.count;
- return !campaignCapExceeded;
- }
- // Clean up campaign impression pref by removing all campaigns that are no
- // longer part of the response, and are therefore considered inactive.
- cleanUpCampaignImpressionPref() {
- const campaignIds = new Set(this.spocCampaignMap.values());
- this.cleanUpImpressionPref(id => !campaignIds.has(id), SPOC_IMPRESSION_TRACKING_PREF);
- }
- // Clean up rec impression pref by removing all stories that are no
- // longer part of the response.
- cleanUpTopRecImpressionPref() {
- const activeStories = new Set(this.stories.map(s => `${s.guid}`));
- this.cleanUpImpressionPref(id => !activeStories.has(id), REC_IMPRESSION_TRACKING_PREF);
- }
- /**
- * Cleans up the provided impression pref (spocs or recs).
- *
- * @param isExpired predicate (boolean-valued function) that returns whether or not
- * the impression for the given key is expired.
- * @param pref the impression pref to clean up.
- */
- cleanUpImpressionPref(isExpired, pref) {
- const impressions = this.readImpressionsPref(pref);
- let changed = false;
- Object
- .keys(impressions)
- .forEach(id => {
- if (isExpired(id)) {
- changed = true;
- delete impressions[id];
- }
- });
- if (changed) {
- this.writeImpressionsPref(pref, impressions);
- }
- }
- // Sets a pref mapping campaign IDs to timestamp arrays.
- // The timestamps represent impressions which are used to calculate frequency caps.
- recordCampaignImpression(campaignId) {
- let impressions = this.readImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF);
- const timeStamps = impressions[campaignId] || [];
- timeStamps.push(Date.now());
- impressions = Object.assign(impressions, {[campaignId]: timeStamps});
- this.writeImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF, impressions);
- }
- // Sets a pref mapping story (rec) IDs to a single timestamp (time of first impression).
- // We use these timestamps to guarantee a story doesn't stay on top for longer than
- // configured in the feed settings (settings.recsExpireTime).
- recordTopRecImpressions(topItems) {
- let impressions = this.readImpressionsPref(REC_IMPRESSION_TRACKING_PREF);
- let changed = false;
- topItems.forEach(t => {
- if (!impressions[t]) {
- changed = true;
- impressions = Object.assign(impressions, {[t]: Date.now()});
- }
- });
- if (changed) {
- this.writeImpressionsPref(REC_IMPRESSION_TRACKING_PREF, impressions);
- }
- }
- readImpressionsPref(pref) {
- const prefVal = this._prefs.get(pref);
- return prefVal ? JSON.parse(prefVal) : {};
- }
- writeImpressionsPref(pref, impressions) {
- this._prefs.set(pref, JSON.stringify(impressions));
- }
- async removeSpocs() {
- // Quick hack so that SPOCS are removed from all open and preloaded tabs when
- // they are disabled. The longer term fix should probably be to remove them
- // in the Reducer.
- await this.clearCache();
- this.uninit();
- this.init();
- }
- /**
- * Decides if we need to change the personality provider version or not.
- * Changes the version if it determines we need to.
- *
- * @param data {object} The top stories pref, we need version and model_keys
- * @return {boolean} Returns true only if the version was changed.
- */
- processAffinityProividerVersion(data) {
- const version2 = data.version === 2 && !this.affinityProviderV2;
- const version1 = data.version === 1 && this.affinityProviderV2;
- if (version2 || version1) {
- if (version1) {
- this.affinityProviderV2 = null;
- } else {
- this.affinityProviderV2 = {
- use_v2: true,
- model_keys: data.model_keys,
- };
- }
- return true;
- }
- return false;
- }
- lazyLoadTopStories(dsPref) {
- try {
- this.discoveryStreamEnabled = JSON.parse(dsPref).enabled;
- } catch (e) {
- // Load activity stream top stories if fail to determine discovery stream state
- this.discoveryStreamEnabled = false;
- }
- // Return without invoking initialization if top stories are loaded
- if (this.storiesLoaded) {
- return;
- }
- if (!this.discoveryStreamEnabled && !this.propertiesInitialized) {
- this.initializeProperties();
- }
- this.init();
- }
- handleDisabled(action) {
- switch (action.type) {
- case at.PREFS_INITIAL_VALUES:
- this.lazyLoadTopStories(action.data[DISCOVERY_STREAM_PREF]);
- break;
- case at.PREF_CHANGED:
- if (action.data.name === DISCOVERY_STREAM_PREF) {
- this.lazyLoadTopStories(action.data.value);
- }
- break;
- case at.UNINIT:
- this.uninit();
- break;
- }
- }
- async onAction(action) {
- if (this.discoveryStreamEnabled) {
- this.handleDisabled(action);
- return;
- }
- switch (action.type) {
- // Check for pref initial values to lazy load activity stream top stories
- // Here we are not using usual INIT and relying on PREFS_INITIAL_VALUES
- // to check discoverystream pref and load activity stream top stories only if needed.
- case at.PREFS_INITIAL_VALUES:
- this.lazyLoadTopStories(action.data[DISCOVERY_STREAM_PREF]);
- break;
- case at.SYSTEM_TICK:
- let stories;
- let topics;
- if (Date.now() - this.storiesLastUpdated >= STORIES_UPDATE_TIME) {
- stories = await this.fetchStories();
- }
- if (Date.now() - this.topicsLastUpdated >= TOPICS_UPDATE_TIME) {
- topics = await this.fetchTopics();
- }
- this.doContentUpdate({stories, topics}, false);
- break;
- case at.UNINIT:
- this.uninit();
- break;
- case at.NEW_TAB_REHYDRATED:
- this.getPocketState(action.meta.fromTarget);
- this.maybeAddSpoc(action.meta.fromTarget);
- break;
- case at.SECTION_OPTIONS_CHANGED:
- if (action.data === SECTION_ID) {
- await this.clearCache();
- this.uninit();
- this.init();
- }
- break;
- case at.PLACES_LINK_BLOCKED:
- if (this.spocs) {
- this.spocs = this.spocs.filter(s => s.url !== action.data.url);
- }
- break;
- case at.PLACES_HISTORY_CLEARED:
- if (this.personalized) {
- this.resetDomainAffinityScores();
- }
- break;
- case at.TELEMETRY_IMPRESSION_STATS: {
- // We want to make sure we only track impressions from Top Stories,
- // otherwise unexpected things that are not properly handled can happen.
- // Example: Impressions from spocs on Discovery Stream can cause the
- // Top Stories impressions pref to continuously grow, see bug #1523408
- if (action.data.source === IMPRESSION_SOURCE) {
- const payload = action.data;
- const viewImpression = !("click" in payload || "block" in payload || "pocket" in payload);
- if (payload.tiles && viewImpression) {
- if (this.shouldShowSpocs()) {
- payload.tiles.forEach(t => {
- if (this.spocCampaignMap.has(t.id)) {
- this.recordCampaignImpression(this.spocCampaignMap.get(t.id));
- }
- });
- }
- if (this.personalized) {
- const topRecs = payload.tiles
- .filter(t => !this.spocCampaignMap.has(t.id))
- .map(t => t.id);
- this.recordTopRecImpressions(topRecs);
- }
- }
- }
- break;
- }
- case at.PREF_CHANGED:
- if (action.data.name === DISCOVERY_STREAM_PREF) {
- this.lazyLoadTopStories(action.data.value);
- }
- // Check if spocs was disabled. Remove them if they were.
- if (action.data.name === "showSponsored" && !action.data.value) {
- await this.removeSpocs();
- }
- if (action.data.name === "pocketCta") {
- this.dispatchPocketCta(action.data.value, true);
- }
- if (action.data.name === OPTIONS_PREF) {
- try {
- const options = JSON.parse(action.data.value);
- if (this.processAffinityProividerVersion(options)) {
- await this.clearCache();
- this.uninit();
- this.init();
- }
- } catch (e) {
- Cu.reportError(`Problem initializing affinity provider v2: ${e.message}`);
- }
- }
- break;
- }
- }
- };
- this.STORIES_UPDATE_TIME = STORIES_UPDATE_TIME;
- this.TOPICS_UPDATE_TIME = TOPICS_UPDATE_TIME;
- this.SECTION_ID = SECTION_ID;
- this.SPOC_IMPRESSION_TRACKING_PREF = SPOC_IMPRESSION_TRACKING_PREF;
- this.REC_IMPRESSION_TRACKING_PREF = REC_IMPRESSION_TRACKING_PREF;
- this.MIN_DOMAIN_AFFINITIES_UPDATE_TIME = MIN_DOMAIN_AFFINITIES_UPDATE_TIME;
- this.DEFAULT_RECS_EXPIRE_TIME = DEFAULT_RECS_EXPIRE_TIME;
- const EXPORTED_SYMBOLS = ["TopStoriesFeed", "STORIES_UPDATE_TIME", "TOPICS_UPDATE_TIME", "SECTION_ID", "SPOC_IMPRESSION_TRACKING_PREF", "MIN_DOMAIN_AFFINITIES_UPDATE_TIME", "REC_IMPRESSION_TRACKING_PREF", "DEFAULT_RECS_EXPIRE_TIME"];
|