tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

TopStoriesFeed.sys.mjs (23343B)


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