TopStoriesFeed.jsm 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
  6. const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
  7. const {NewTabUtils} = ChromeUtils.import("resource://gre/modules/NewTabUtils.jsm");
  8. XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
  9. const {actionTypes: at, actionCreators: ac} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
  10. const {Prefs} = ChromeUtils.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm");
  11. const {shortURL} = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm");
  12. const {SectionsManager} = ChromeUtils.import("resource://activity-stream/lib/SectionsManager.jsm");
  13. const {UserDomainAffinityProvider} = ChromeUtils.import("resource://activity-stream/lib/UserDomainAffinityProvider.jsm");
  14. const {PersonalityProvider} = ChromeUtils.import("resource://activity-stream/lib/PersonalityProvider.jsm");
  15. const {PersistentCache} = ChromeUtils.import("resource://activity-stream/lib/PersistentCache.jsm");
  16. ChromeUtils.defineModuleGetter(this, "perfService", "resource://activity-stream/common/PerfService.jsm");
  17. ChromeUtils.defineModuleGetter(this, "pktApi", "chrome://pocket/content/pktApi.jsm");
  18. const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
  19. const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours
  20. const STORIES_NOW_THRESHOLD = 24 * 60 * 60 * 1000; // 24 hours
  21. const MIN_DOMAIN_AFFINITIES_UPDATE_TIME = 12 * 60 * 60 * 1000; // 12 hours
  22. const DEFAULT_RECS_EXPIRE_TIME = 60 * 60 * 1000; // 1 hour
  23. const SECTION_ID = "topstories";
  24. const IMPRESSION_SOURCE = "TOP_STORIES";
  25. const SPOC_IMPRESSION_TRACKING_PREF = "feeds.section.topstories.spoc.impressions";
  26. const REC_IMPRESSION_TRACKING_PREF = "feeds.section.topstories.rec.impressions";
  27. const OPTIONS_PREF = "feeds.section.topstories.options";
  28. const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server
  29. const DISCOVERY_STREAM_PREF = "discoverystream.config";
  30. this.TopStoriesFeed = class TopStoriesFeed {
  31. constructor(ds) {
  32. // Use discoverystream config pref default values for fast path and
  33. // if needed lazy load activity stream top stories feed based on
  34. // actual user preference when PREFS_INITIAL_VALUES and PREF_CHANGED is invoked
  35. this.discoveryStreamEnabled = ds && ds.value && JSON.parse(ds.value).enabled;
  36. if (!this.discoveryStreamEnabled) {
  37. this.initializeProperties();
  38. }
  39. }
  40. initializeProperties() {
  41. this.contentUpdateQueue = [];
  42. this.spocCampaignMap = new Map();
  43. this.cache = new PersistentCache(SECTION_ID, true);
  44. this._prefs = new Prefs();
  45. this.propertiesInitialized = true;
  46. }
  47. async onInit() {
  48. SectionsManager.enableSection(SECTION_ID);
  49. if (this.discoveryStreamEnabled) {
  50. return;
  51. }
  52. try {
  53. const {options} = SectionsManager.sections.get(SECTION_ID);
  54. const apiKey = this.getApiKeyFromPref(options.api_key_pref);
  55. this.stories_endpoint = this.produceFinalEndpointUrl(options.stories_endpoint, apiKey);
  56. this.topics_endpoint = this.produceFinalEndpointUrl(options.topics_endpoint, apiKey);
  57. this.read_more_endpoint = options.read_more_endpoint;
  58. this.stories_referrer = options.stories_referrer;
  59. this.personalized = options.personalized;
  60. this.show_spocs = options.show_spocs;
  61. this.maxHistoryQueryResults = options.maxHistoryQueryResults;
  62. this.storiesLastUpdated = 0;
  63. this.topicsLastUpdated = 0;
  64. this.storiesLoaded = false;
  65. this.domainAffinitiesLastUpdated = 0;
  66. this.processAffinityProividerVersion(options);
  67. this.dispatchPocketCta(this._prefs.get("pocketCta"), false);
  68. Services.obs.addObserver(this, "idle-daily");
  69. // Cache is used for new page loads, which shouldn't have changed data.
  70. // If we have changed data, cache should be cleared,
  71. // and last updated should be 0, and we can fetch.
  72. let {stories, topics} = await this.loadCachedData();
  73. if (this.storiesLastUpdated === 0) {
  74. stories = await this.fetchStories();
  75. }
  76. if (this.topicsLastUpdated === 0) {
  77. topics = await this.fetchTopics();
  78. }
  79. this.doContentUpdate({stories, topics}, true);
  80. this.storiesLoaded = true;
  81. // This is filtered so an update function can return true to retry on the next run
  82. this.contentUpdateQueue = this.contentUpdateQueue.filter(update => update());
  83. } catch (e) {
  84. Cu.reportError(`Problem initializing top stories feed: ${e.message}`);
  85. }
  86. }
  87. init() {
  88. SectionsManager.onceInitialized(this.onInit.bind(this));
  89. }
  90. observe(subject, topic, data) {
  91. switch (topic) {
  92. case "idle-daily":
  93. this.updateDomainAffinityScores();
  94. break;
  95. }
  96. }
  97. async clearCache() {
  98. await this.cache.set("stories", {});
  99. await this.cache.set("topics", {});
  100. await this.cache.set("spocs", {});
  101. }
  102. uninit() {
  103. this.storiesLoaded = false;
  104. try {
  105. Services.obs.removeObserver(this, "idle-daily");
  106. } catch (e) {
  107. // Attempt to remove unassociated observer which is possible when discovery stream
  108. // is enabled and user never used activity stream experience
  109. }
  110. SectionsManager.disableSection(SECTION_ID);
  111. }
  112. getPocketState(target) {
  113. const action = {type: at.POCKET_LOGGED_IN, data: pktApi.isUserLoggedIn()};
  114. this.store.dispatch(ac.OnlyToOneContent(action, target));
  115. }
  116. dispatchPocketCta(data, shouldBroadcast) {
  117. const action = {type: at.POCKET_CTA, data: JSON.parse(data)};
  118. this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : ac.AlsoToPreloaded(action));
  119. }
  120. /**
  121. * doContentUpdate - Updates topics and stories in the topstories section.
  122. *
  123. * Sections have one update action for the whole section.
  124. * Redux creates a state race condition if you call the same action,
  125. * twice, concurrently. Because of this, doContentUpdate is
  126. * one place to update both topics and stories in a single action.
  127. *
  128. * Section updates used old topics if none are available,
  129. * but clear stories if none are available. Because of this, if no
  130. * stories are passed, we instead use the existing stories in state.
  131. *
  132. * @param {Object} This is an object with potential new stories or topics.
  133. * @param {Boolean} shouldBroadcast If we should update existing tabs or not. For first page
  134. * loads or pref changes, we want to update existing tabs,
  135. * for system tick or other updates we do not.
  136. */
  137. doContentUpdate({stories, topics}, shouldBroadcast) {
  138. let updateProps = {};
  139. if (stories) {
  140. updateProps.rows = stories;
  141. } else {
  142. const {Sections} = this.store.getState();
  143. if (Sections && Sections.find) {
  144. updateProps.rows = Sections.find(s => s.id === SECTION_ID).rows;
  145. }
  146. }
  147. if (topics) {
  148. Object.assign(updateProps, {topics, read_more_endpoint: this.read_more_endpoint});
  149. }
  150. // We should only be calling this once per init.
  151. this.dispatchUpdateEvent(shouldBroadcast, updateProps);
  152. }
  153. async onPersonalityProviderInit() {
  154. const data = await this.cache.get();
  155. let stories = data.stories && data.stories.recommendations;
  156. this.stories = this.rotate(this.transform(stories));
  157. this.doContentUpdate({stories: this.stories}, false);
  158. const affinities = this.affinityProvider.getAffinities();
  159. this.domainAffinitiesLastUpdated = Date.now();
  160. affinities._timestamp = this.domainAffinitiesLastUpdated;
  161. this.cache.set("domainAffinities", affinities);
  162. }
  163. affinityProividerSwitcher(...args) {
  164. const {affinityProviderV2} = this;
  165. if (affinityProviderV2 && affinityProviderV2.use_v2) {
  166. const provider = this.PersonalityProvider(...args, {modelKeys: affinityProviderV2.model_keys, dispatch: this.store.dispatch});
  167. provider.init(this.onPersonalityProviderInit.bind(this));
  168. return provider;
  169. }
  170. const start = perfService.absNow();
  171. const v1Provider = this.UserDomainAffinityProvider(...args);
  172. this.store.dispatch(ac.PerfEvent({
  173. event: "topstories.domain.affinity.calculation.ms",
  174. value: Math.round(perfService.absNow() - start),
  175. }));
  176. return v1Provider;
  177. }
  178. PersonalityProvider(...args) {
  179. return new PersonalityProvider(...args);
  180. }
  181. UserDomainAffinityProvider(...args) {
  182. return new UserDomainAffinityProvider(...args);
  183. }
  184. async fetchStories() {
  185. if (!this.stories_endpoint) {
  186. return null;
  187. }
  188. try {
  189. const response = await fetch(this.stories_endpoint, {credentials: "omit"});
  190. if (!response.ok) {
  191. throw new Error(`Stories endpoint returned unexpected status: ${response.status}`);
  192. }
  193. const body = await response.json();
  194. this.updateSettings(body.settings);
  195. this.stories = this.rotate(this.transform(body.recommendations));
  196. this.cleanUpTopRecImpressionPref();
  197. if (this.show_spocs && body.spocs) {
  198. this.spocCampaignMap = new Map(body.spocs.map(s => [s.id, `${s.campaign_id}`]));
  199. this.spocs = this.transform(body.spocs).filter(s => s.score >= s.min_score);
  200. this.cleanUpCampaignImpressionPref();
  201. }
  202. this.storiesLastUpdated = Date.now();
  203. body._timestamp = this.storiesLastUpdated;
  204. this.cache.set("stories", body);
  205. } catch (error) {
  206. Cu.reportError(`Failed to fetch content: ${error.message}`);
  207. }
  208. return this.stories;
  209. }
  210. async loadCachedData() {
  211. const data = await this.cache.get();
  212. let stories = data.stories && data.stories.recommendations;
  213. let topics = data.topics && data.topics.topics;
  214. let affinities = data.domainAffinities;
  215. if (this.personalized && affinities && affinities.scores) {
  216. this.affinityProvider = this.affinityProividerSwitcher(affinities.timeSegments,
  217. affinities.parameterSets, affinities.maxHistoryQueryResults, affinities.version, affinities.scores);
  218. this.domainAffinitiesLastUpdated = affinities._timestamp;
  219. }
  220. if (stories && stories.length > 0 && this.storiesLastUpdated === 0) {
  221. this.updateSettings(data.stories.settings);
  222. this.stories = this.rotate(this.transform(stories));
  223. this.storiesLastUpdated = data.stories._timestamp;
  224. if (data.stories.spocs && data.stories.spocs.length) {
  225. this.spocCampaignMap = new Map(data.stories.spocs.map(s => [s.id, `${s.campaign_id}`]));
  226. this.spocs = this.transform(data.stories.spocs).filter(s => s.score >= s.min_score);
  227. this.cleanUpCampaignImpressionPref();
  228. }
  229. }
  230. if (topics && topics.length > 0 && this.topicsLastUpdated === 0) {
  231. this.topics = topics;
  232. this.topicsLastUpdated = data.topics._timestamp;
  233. }
  234. return {topics: this.topics, stories: this.stories};
  235. }
  236. dispatchRelevanceScore(start) {
  237. let event = "PERSONALIZATION_V1_ITEM_RELEVANCE_SCORE_DURATION";
  238. let initialized = true;
  239. if (!this.personalized) {
  240. return;
  241. }
  242. const {affinityProviderV2} = this;
  243. if (affinityProviderV2 && affinityProviderV2.use_v2) {
  244. if (this.affinityProvider) {
  245. initialized = this.affinityProvider.initialized;
  246. event = "PERSONALIZATION_V2_ITEM_RELEVANCE_SCORE_DURATION";
  247. }
  248. }
  249. // If v2 is not yet initialized we don't bother tracking yet.
  250. // Before it is initialized it doesn't do any ranking.
  251. // Once it's initialized it ensures ranking is done.
  252. // v1 doesn't have any initialized issues around ranking,
  253. // and should be ready right away.
  254. if (initialized) {
  255. this.store.dispatch(ac.PerfEvent({
  256. event,
  257. value: Math.round(perfService.absNow() - start),
  258. }));
  259. }
  260. }
  261. transform(items) {
  262. if (!items) {
  263. return [];
  264. }
  265. const scoreStart = perfService.absNow();
  266. const calcResult = items
  267. .filter(s => !NewTabUtils.blockedLinks.isBlocked({"url": s.url}))
  268. .map(s => {
  269. let mapped = {
  270. "guid": s.id,
  271. "hostname": s.domain || shortURL(Object.assign({}, s, {url: s.url})),
  272. "type": (Date.now() - (s.published_timestamp * 1000)) <= STORIES_NOW_THRESHOLD ? "now" : "trending",
  273. "context": s.context,
  274. "icon": s.icon,
  275. "title": s.title,
  276. "description": s.excerpt,
  277. "image": this.normalizeUrl(s.image_src),
  278. "referrer": this.stories_referrer,
  279. "url": s.url,
  280. "min_score": s.min_score || 0,
  281. "score": this.personalized && this.affinityProvider ? this.affinityProvider.calculateItemRelevanceScore(s) : s.item_score || 1,
  282. "spoc_meta": this.show_spocs ? {campaign_id: s.campaign_id, caps: s.caps} : {},
  283. };
  284. // Very old cached spocs may not contain an `expiration_timestamp` property
  285. if (s.expiration_timestamp) {
  286. mapped.expiration_timestamp = s.expiration_timestamp;
  287. }
  288. return mapped;
  289. })
  290. .sort(this.personalized ? this.compareScore : (a, b) => 0);
  291. this.dispatchRelevanceScore(scoreStart);
  292. return calcResult;
  293. }
  294. async fetchTopics() {
  295. if (!this.topics_endpoint) {
  296. return null;
  297. }
  298. try {
  299. const response = await fetch(this.topics_endpoint, {credentials: "omit"});
  300. if (!response.ok) {
  301. throw new Error(`Topics endpoint returned unexpected status: ${response.status}`);
  302. }
  303. const body = await response.json();
  304. const {topics} = body;
  305. if (topics) {
  306. this.topics = topics;
  307. this.topicsLastUpdated = Date.now();
  308. body._timestamp = this.topicsLastUpdated;
  309. this.cache.set("topics", body);
  310. }
  311. } catch (error) {
  312. Cu.reportError(`Failed to fetch topics: ${error.message}`);
  313. }
  314. return this.topics;
  315. }
  316. dispatchUpdateEvent(shouldBroadcast, data) {
  317. SectionsManager.updateSection(SECTION_ID, data, shouldBroadcast);
  318. }
  319. compareScore(a, b) {
  320. return b.score - a.score;
  321. }
  322. updateSettings(settings) {
  323. if (!this.personalized) {
  324. return;
  325. }
  326. this.spocsPerNewTabs = settings.spocsPerNewTabs; // Probability of a new tab getting a spoc [0,1]
  327. this.timeSegments = settings.timeSegments;
  328. this.domainAffinityParameterSets = settings.domainAffinityParameterSets;
  329. this.recsExpireTime = settings.recsExpireTime;
  330. this.version = settings.version;
  331. if (this.affinityProvider && (this.affinityProvider.version !== this.version)) {
  332. this.resetDomainAffinityScores();
  333. }
  334. }
  335. updateDomainAffinityScores() {
  336. if (!this.personalized || !this.domainAffinityParameterSets ||
  337. Date.now() - this.domainAffinitiesLastUpdated < MIN_DOMAIN_AFFINITIES_UPDATE_TIME) {
  338. return;
  339. }
  340. this.affinityProvider = this.affinityProividerSwitcher(
  341. this.timeSegments,
  342. this.domainAffinityParameterSets,
  343. this.maxHistoryQueryResults,
  344. this.version, undefined);
  345. const affinities = this.affinityProvider.getAffinities();
  346. this.domainAffinitiesLastUpdated = Date.now();
  347. affinities._timestamp = this.domainAffinitiesLastUpdated;
  348. this.cache.set("domainAffinities", affinities);
  349. }
  350. resetDomainAffinityScores() {
  351. delete this.affinityProvider;
  352. this.cache.set("domainAffinities", {});
  353. }
  354. // If personalization is turned on, we have to rotate stories on the client so that
  355. // active stories are at the front of the list, followed by stories that have expired
  356. // impressions i.e. have been displayed for longer than recsExpireTime.
  357. rotate(items) {
  358. if (!this.personalized || items.length <= 3) {
  359. return items;
  360. }
  361. const maxImpressionAge = Math.max(this.recsExpireTime * 1000 || DEFAULT_RECS_EXPIRE_TIME, DEFAULT_RECS_EXPIRE_TIME);
  362. const impressions = this.readImpressionsPref(REC_IMPRESSION_TRACKING_PREF);
  363. const expired = [];
  364. const active = [];
  365. for (const item of items) {
  366. if (impressions[item.guid] && Date.now() - impressions[item.guid] >= maxImpressionAge) {
  367. expired.push(item);
  368. } else {
  369. active.push(item);
  370. }
  371. }
  372. return active.concat(expired);
  373. }
  374. getApiKeyFromPref(apiKeyPref) {
  375. if (!apiKeyPref) {
  376. return apiKeyPref;
  377. }
  378. return this._prefs.get(apiKeyPref) || Services.prefs.getCharPref(apiKeyPref);
  379. }
  380. produceFinalEndpointUrl(url, apiKey) {
  381. if (!url) {
  382. return url;
  383. }
  384. if (url.includes("$apiKey") && !apiKey) {
  385. throw new Error(`An API key was specified but none configured: ${url}`);
  386. }
  387. return url.replace("$apiKey", apiKey);
  388. }
  389. // Need to remove parenthesis from image URLs as React will otherwise
  390. // fail to render them properly as part of the card template.
  391. normalizeUrl(url) {
  392. if (url) {
  393. return url.replace(/\(/g, "%28").replace(/\)/g, "%29");
  394. }
  395. return url;
  396. }
  397. shouldShowSpocs() {
  398. return this.show_spocs && this.store.getState().Prefs.values.showSponsored;
  399. }
  400. dispatchSpocDone(target) {
  401. const action = {type: at.POCKET_WAITING_FOR_SPOC, data: false};
  402. this.store.dispatch(ac.OnlyToOneContent(action, target));
  403. }
  404. filterSpocs() {
  405. if (!this.shouldShowSpocs()) {
  406. return [];
  407. }
  408. if (Math.random() > this.spocsPerNewTabs) {
  409. return [];
  410. }
  411. if (!this.spocs || !this.spocs.length) {
  412. // We have stories but no spocs so there's nothing to do and this update can be
  413. // removed from the queue.
  414. return [];
  415. }
  416. // Filter spocs based on frequency caps
  417. const impressions = this.readImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF);
  418. let spocs = this.spocs.filter(s => this.isBelowFrequencyCap(impressions, s));
  419. // Filter out expired spocs based on `expiration_timestamp`
  420. spocs = spocs.filter(spoc => {
  421. // If cached data is so old it doesn't contain this property, assume the spoc is ok to show
  422. if (!(`expiration_timestamp` in spoc)) {
  423. return true;
  424. }
  425. // `expiration_timestamp` is the number of seconds elapsed since January 1, 1970 00:00:00 UTC
  426. return spoc.expiration_timestamp * 1000 > Date.now();
  427. });
  428. return spocs;
  429. }
  430. maybeAddSpoc(target) {
  431. const updateContent = () => {
  432. let spocs = this.filterSpocs();
  433. if (!spocs.length) {
  434. this.dispatchSpocDone(target);
  435. return false;
  436. }
  437. // Create a new array with a spoc inserted at index 2
  438. const section = this.store.getState().Sections.find(s => s.id === SECTION_ID);
  439. let rows = section.rows.slice(0, this.stories.length);
  440. rows.splice(2, 0, Object.assign(spocs[0], {pinned: true}));
  441. // Send a content update to the target tab
  442. const action = {type: at.SECTION_UPDATE, data: Object.assign({rows}, {id: SECTION_ID})};
  443. this.store.dispatch(ac.OnlyToOneContent(action, target));
  444. this.dispatchSpocDone(target);
  445. return false;
  446. };
  447. if (this.storiesLoaded) {
  448. updateContent();
  449. } else {
  450. // Delay updating tab content until initial data has been fetched
  451. this.contentUpdateQueue.push(updateContent);
  452. }
  453. }
  454. // Frequency caps are based on campaigns, which may include multiple spocs.
  455. // We currently support two types of frequency caps:
  456. // - lifetime: Indicates how many times spocs from a campaign can be shown in total
  457. // - period: Indicates how many times spocs from a campaign can be shown within a period
  458. //
  459. // So, for example, the feed configuration below defines that for campaign 1 no more
  460. // than 5 spocs can be show in total, and no more than 2 per hour.
  461. // "campaign_id": 1,
  462. // "caps": {
  463. // "lifetime": 5,
  464. // "campaign": {
  465. // "count": 2,
  466. // "period": 3600
  467. // }
  468. // }
  469. isBelowFrequencyCap(impressions, spoc) {
  470. const campaignImpressions = impressions[spoc.spoc_meta.campaign_id];
  471. if (!campaignImpressions) {
  472. return true;
  473. }
  474. const lifeTimeCap = Math.min(spoc.spoc_meta.caps && spoc.spoc_meta.caps.lifetime, MAX_LIFETIME_CAP);
  475. const lifeTimeCapExceeded = campaignImpressions.length >= lifeTimeCap;
  476. if (lifeTimeCapExceeded) {
  477. return false;
  478. }
  479. const campaignCap = (spoc.spoc_meta.caps && spoc.spoc_meta.caps.campaign) || {};
  480. const campaignCapExceeded = campaignImpressions
  481. .filter(i => (Date.now() - i) < (campaignCap.period * 1000)).length >= campaignCap.count;
  482. return !campaignCapExceeded;
  483. }
  484. // Clean up campaign impression pref by removing all campaigns that are no
  485. // longer part of the response, and are therefore considered inactive.
  486. cleanUpCampaignImpressionPref() {
  487. const campaignIds = new Set(this.spocCampaignMap.values());
  488. this.cleanUpImpressionPref(id => !campaignIds.has(id), SPOC_IMPRESSION_TRACKING_PREF);
  489. }
  490. // Clean up rec impression pref by removing all stories that are no
  491. // longer part of the response.
  492. cleanUpTopRecImpressionPref() {
  493. const activeStories = new Set(this.stories.map(s => `${s.guid}`));
  494. this.cleanUpImpressionPref(id => !activeStories.has(id), REC_IMPRESSION_TRACKING_PREF);
  495. }
  496. /**
  497. * Cleans up the provided impression pref (spocs or recs).
  498. *
  499. * @param isExpired predicate (boolean-valued function) that returns whether or not
  500. * the impression for the given key is expired.
  501. * @param pref the impression pref to clean up.
  502. */
  503. cleanUpImpressionPref(isExpired, pref) {
  504. const impressions = this.readImpressionsPref(pref);
  505. let changed = false;
  506. Object
  507. .keys(impressions)
  508. .forEach(id => {
  509. if (isExpired(id)) {
  510. changed = true;
  511. delete impressions[id];
  512. }
  513. });
  514. if (changed) {
  515. this.writeImpressionsPref(pref, impressions);
  516. }
  517. }
  518. // Sets a pref mapping campaign IDs to timestamp arrays.
  519. // The timestamps represent impressions which are used to calculate frequency caps.
  520. recordCampaignImpression(campaignId) {
  521. let impressions = this.readImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF);
  522. const timeStamps = impressions[campaignId] || [];
  523. timeStamps.push(Date.now());
  524. impressions = Object.assign(impressions, {[campaignId]: timeStamps});
  525. this.writeImpressionsPref(SPOC_IMPRESSION_TRACKING_PREF, impressions);
  526. }
  527. // Sets a pref mapping story (rec) IDs to a single timestamp (time of first impression).
  528. // We use these timestamps to guarantee a story doesn't stay on top for longer than
  529. // configured in the feed settings (settings.recsExpireTime).
  530. recordTopRecImpressions(topItems) {
  531. let impressions = this.readImpressionsPref(REC_IMPRESSION_TRACKING_PREF);
  532. let changed = false;
  533. topItems.forEach(t => {
  534. if (!impressions[t]) {
  535. changed = true;
  536. impressions = Object.assign(impressions, {[t]: Date.now()});
  537. }
  538. });
  539. if (changed) {
  540. this.writeImpressionsPref(REC_IMPRESSION_TRACKING_PREF, impressions);
  541. }
  542. }
  543. readImpressionsPref(pref) {
  544. const prefVal = this._prefs.get(pref);
  545. return prefVal ? JSON.parse(prefVal) : {};
  546. }
  547. writeImpressionsPref(pref, impressions) {
  548. this._prefs.set(pref, JSON.stringify(impressions));
  549. }
  550. async removeSpocs() {
  551. // Quick hack so that SPOCS are removed from all open and preloaded tabs when
  552. // they are disabled. The longer term fix should probably be to remove them
  553. // in the Reducer.
  554. await this.clearCache();
  555. this.uninit();
  556. this.init();
  557. }
  558. /**
  559. * Decides if we need to change the personality provider version or not.
  560. * Changes the version if it determines we need to.
  561. *
  562. * @param data {object} The top stories pref, we need version and model_keys
  563. * @return {boolean} Returns true only if the version was changed.
  564. */
  565. processAffinityProividerVersion(data) {
  566. const version2 = data.version === 2 && !this.affinityProviderV2;
  567. const version1 = data.version === 1 && this.affinityProviderV2;
  568. if (version2 || version1) {
  569. if (version1) {
  570. this.affinityProviderV2 = null;
  571. } else {
  572. this.affinityProviderV2 = {
  573. use_v2: true,
  574. model_keys: data.model_keys,
  575. };
  576. }
  577. return true;
  578. }
  579. return false;
  580. }
  581. lazyLoadTopStories(dsPref) {
  582. try {
  583. this.discoveryStreamEnabled = JSON.parse(dsPref).enabled;
  584. } catch (e) {
  585. // Load activity stream top stories if fail to determine discovery stream state
  586. this.discoveryStreamEnabled = false;
  587. }
  588. // Return without invoking initialization if top stories are loaded
  589. if (this.storiesLoaded) {
  590. return;
  591. }
  592. if (!this.discoveryStreamEnabled && !this.propertiesInitialized) {
  593. this.initializeProperties();
  594. }
  595. this.init();
  596. }
  597. handleDisabled(action) {
  598. switch (action.type) {
  599. case at.PREFS_INITIAL_VALUES:
  600. this.lazyLoadTopStories(action.data[DISCOVERY_STREAM_PREF]);
  601. break;
  602. case at.PREF_CHANGED:
  603. if (action.data.name === DISCOVERY_STREAM_PREF) {
  604. this.lazyLoadTopStories(action.data.value);
  605. }
  606. break;
  607. case at.UNINIT:
  608. this.uninit();
  609. break;
  610. }
  611. }
  612. async onAction(action) {
  613. if (this.discoveryStreamEnabled) {
  614. this.handleDisabled(action);
  615. return;
  616. }
  617. switch (action.type) {
  618. // Check for pref initial values to lazy load activity stream top stories
  619. // Here we are not using usual INIT and relying on PREFS_INITIAL_VALUES
  620. // to check discoverystream pref and load activity stream top stories only if needed.
  621. case at.PREFS_INITIAL_VALUES:
  622. this.lazyLoadTopStories(action.data[DISCOVERY_STREAM_PREF]);
  623. break;
  624. case at.SYSTEM_TICK:
  625. let stories;
  626. let topics;
  627. if (Date.now() - this.storiesLastUpdated >= STORIES_UPDATE_TIME) {
  628. stories = await this.fetchStories();
  629. }
  630. if (Date.now() - this.topicsLastUpdated >= TOPICS_UPDATE_TIME) {
  631. topics = await this.fetchTopics();
  632. }
  633. this.doContentUpdate({stories, topics}, false);
  634. break;
  635. case at.UNINIT:
  636. this.uninit();
  637. break;
  638. case at.NEW_TAB_REHYDRATED:
  639. this.getPocketState(action.meta.fromTarget);
  640. this.maybeAddSpoc(action.meta.fromTarget);
  641. break;
  642. case at.SECTION_OPTIONS_CHANGED:
  643. if (action.data === SECTION_ID) {
  644. await this.clearCache();
  645. this.uninit();
  646. this.init();
  647. }
  648. break;
  649. case at.PLACES_LINK_BLOCKED:
  650. if (this.spocs) {
  651. this.spocs = this.spocs.filter(s => s.url !== action.data.url);
  652. }
  653. break;
  654. case at.PLACES_HISTORY_CLEARED:
  655. if (this.personalized) {
  656. this.resetDomainAffinityScores();
  657. }
  658. break;
  659. case at.TELEMETRY_IMPRESSION_STATS: {
  660. // We want to make sure we only track impressions from Top Stories,
  661. // otherwise unexpected things that are not properly handled can happen.
  662. // Example: Impressions from spocs on Discovery Stream can cause the
  663. // Top Stories impressions pref to continuously grow, see bug #1523408
  664. if (action.data.source === IMPRESSION_SOURCE) {
  665. const payload = action.data;
  666. const viewImpression = !("click" in payload || "block" in payload || "pocket" in payload);
  667. if (payload.tiles && viewImpression) {
  668. if (this.shouldShowSpocs()) {
  669. payload.tiles.forEach(t => {
  670. if (this.spocCampaignMap.has(t.id)) {
  671. this.recordCampaignImpression(this.spocCampaignMap.get(t.id));
  672. }
  673. });
  674. }
  675. if (this.personalized) {
  676. const topRecs = payload.tiles
  677. .filter(t => !this.spocCampaignMap.has(t.id))
  678. .map(t => t.id);
  679. this.recordTopRecImpressions(topRecs);
  680. }
  681. }
  682. }
  683. break;
  684. }
  685. case at.PREF_CHANGED:
  686. if (action.data.name === DISCOVERY_STREAM_PREF) {
  687. this.lazyLoadTopStories(action.data.value);
  688. }
  689. // Check if spocs was disabled. Remove them if they were.
  690. if (action.data.name === "showSponsored" && !action.data.value) {
  691. await this.removeSpocs();
  692. }
  693. if (action.data.name === "pocketCta") {
  694. this.dispatchPocketCta(action.data.value, true);
  695. }
  696. if (action.data.name === OPTIONS_PREF) {
  697. try {
  698. const options = JSON.parse(action.data.value);
  699. if (this.processAffinityProividerVersion(options)) {
  700. await this.clearCache();
  701. this.uninit();
  702. this.init();
  703. }
  704. } catch (e) {
  705. Cu.reportError(`Problem initializing affinity provider v2: ${e.message}`);
  706. }
  707. }
  708. break;
  709. }
  710. }
  711. };
  712. this.STORIES_UPDATE_TIME = STORIES_UPDATE_TIME;
  713. this.TOPICS_UPDATE_TIME = TOPICS_UPDATE_TIME;
  714. this.SECTION_ID = SECTION_ID;
  715. this.SPOC_IMPRESSION_TRACKING_PREF = SPOC_IMPRESSION_TRACKING_PREF;
  716. this.REC_IMPRESSION_TRACKING_PREF = REC_IMPRESSION_TRACKING_PREF;
  717. this.MIN_DOMAIN_AFFINITIES_UPDATE_TIME = MIN_DOMAIN_AFFINITIES_UPDATE_TIME;
  718. this.DEFAULT_RECS_EXPIRE_TIME = DEFAULT_RECS_EXPIRE_TIME;
  719. 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"];