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