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 }