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