summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/lib/TopStoriesFeed.jsm
blob: aa51b2a4f8f22f8b6e4cef615fb97548130ec0b8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
/* 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 { actionTypes: at, actionCreators: ac } = ChromeUtils.importESModule(
  "resource://activity-stream/common/Actions.sys.mjs"
);
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 { PersistentCache } = ChromeUtils.importESModule(
  "resource://activity-stream/lib/PersistentCache.sys.mjs"
);

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
  pktApi: "chrome://pocket/content/pktApi.sys.mjs",
});

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 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 DISCOVERY_STREAM_PREF_ENABLED = "discoverystream.enabled";
const DISCOVERY_STREAM_PREF_ENABLED_PATH =
  "browser.newtabpage.activity-stream.discoverystream.enabled";
const REC_IMPRESSION_TRACKING_PREF = "feeds.section.topstories.rec.impressions";
const PREF_USER_TOPSTORIES = "feeds.section.topstories";
const MAX_LIFETIME_CAP = 500; // Guard against misconfiguration on the server
const DISCOVERY_STREAM_PREF = "discoverystream.config";

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 INIT and PREF_CHANGED is invoked
    this.discoveryStreamEnabled =
      ds &&
      ds.value &&
      JSON.parse(ds.value).enabled &&
      Services.prefs.getBoolPref(DISCOVERY_STREAM_PREF_ENABLED_PATH, false);
    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, true /* isStartup */);
    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.show_spocs = options.show_spocs;
      this.storiesLastUpdated = 0;
      this.topicsLastUpdated = 0;
      this.storiesLoaded = false;
      this.dispatchPocketCta(this._prefs.get("pocketCta"), false);

      // 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) {
      console.error(`Problem initializing top stories feed: ${e.message}`);
    }
  }

  init() {
    SectionsManager.onceInitialized(this.onInit.bind(this));
  }

  async clearCache() {
    await this.cache.set("stories", {});
    await this.cache.set("topics", {});
    await this.cache.set("spocs", {});
  }

  uninit() {
    this.storiesLoaded = false;
    SectionsManager.disableSection(SECTION_ID);
  }

  getPocketState(target) {
    const action = {
      type: at.POCKET_LOGGED_IN,
      data: lazy.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 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);
        this.cleanUpCampaignImpressionPref();
      }
      this.storiesLastUpdated = Date.now();
      body._timestamp = this.storiesLastUpdated;
      this.cache.set("stories", body);
    } catch (error) {
      console.error(`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;

    if (stories && !!stories.length && 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);
        this.cleanUpCampaignImpressionPref();
      }
    }
    if (topics && !!topics.length && this.topicsLastUpdated === 0) {
      this.topics = topics;
      this.topicsLastUpdated = data.topics._timestamp;
    }

    return { topics: this.topics, stories: this.stories };
  }

  transform(items) {
    if (!items) {
      return [];
    }

    const calcResult = items
      .filter(s => !lazy.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,
          score: 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.compareScore);

    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) {
      console.error(`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 = {}) {
    this.spocsPerNewTabs = settings.spocsPerNewTabs || 1; // Probability of a new tab getting a spoc [0,1]
    this.recsExpireTime = settings.recsExpireTime;
  }

  // We 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 (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();
  }

  lazyLoadTopStories(options = {}) {
    let { dsPref, userPref } = options;
    if (!dsPref) {
      dsPref = this.store.getState().Prefs.values[DISCOVERY_STREAM_PREF];
    }
    if (!userPref) {
      userPref = this.store.getState().Prefs.values[PREF_USER_TOPSTORIES];
    }

    try {
      this.discoveryStreamEnabled =
        JSON.parse(dsPref).enabled &&
        this.store.getState().Prefs.values[DISCOVERY_STREAM_PREF_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, or preffed off.
    if (this.storiesLoaded || !userPref) {
      return;
    }

    if (!this.discoveryStreamEnabled && !this.propertiesInitialized) {
      this.initializeProperties();
    }
    this.init();
  }

  handleDisabled(action) {
    switch (action.type) {
      case at.INIT:
        this.lazyLoadTopStories();
        break;
      case at.PREF_CHANGED:
        if (action.data.name === DISCOVERY_STREAM_PREF) {
          this.lazyLoadTopStories({ dsPref: action.data.value });
        }
        if (action.data.name === DISCOVERY_STREAM_PREF_ENABLED) {
          this.lazyLoadTopStories();
        }
        if (action.data.name === PREF_USER_TOPSTORIES) {
          if (action.data.value) {
            // init topstories if value if true.
            this.lazyLoadTopStories({ userPref: action.data.value });
          } else {
            this.uninit();
          }
        }
        break;
      case at.UNINIT:
        this.uninit();
        break;
    }
  }

  async onAction(action) {
    if (this.discoveryStreamEnabled) {
      this.handleDisabled(action);
      return;
    }
    switch (action.type) {
      // Check discoverystream pref and load activity stream top stories only if needed
      case at.INIT:
        this.lazyLoadTopStories();
        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.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));
                }
              });
            }
            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({ dsPref: action.data.value });
        }
        if (action.data.name === PREF_USER_TOPSTORIES) {
          if (action.data.value) {
            // init topstories if value if true.
            this.lazyLoadTopStories({ userPref: action.data.value });
          } else {
            this.uninit();
          }
        }
        // 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);
        }
        break;
    }
  }
}

const EXPORTED_SYMBOLS = [
  "TopStoriesFeed",
  "STORIES_UPDATE_TIME",
  "TOPICS_UPDATE_TIME",
  "SECTION_ID",
  "SPOC_IMPRESSION_TRACKING_PREF",
  "REC_IMPRESSION_TRACKING_PREF",
  "DEFAULT_RECS_EXPIRE_TIME",
];