DSCard.jsx (27185B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { actionCreators as ac } from "common/Actions.mjs"; 6 import { DSImage } from "../DSImage/DSImage.jsx"; 7 import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu"; 8 import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; 9 import { getActiveCardSize } from "../../../lib/utils"; 10 import React from "react"; 11 import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; 12 import { 13 DSContextFooter, 14 SponsorLabel, 15 DSMessageFooter, 16 } from "../DSContextFooter/DSContextFooter.jsx"; 17 import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; 18 import { connect } from "react-redux"; 19 const READING_WPM = 220; 20 const PREF_OHTTP_MERINO = "discoverystream.merino-provider.ohttp.enabled"; 21 const PREF_OHTTP_UNIFIED_ADS = "unifiedAds.ohttp.enabled"; 22 const PREF_SECTIONS_ENABLED = "discoverystream.sections.enabled"; 23 const PREF_FAVICONS_ENABLED = "discoverystream.publisherFavicon.enabled"; 24 25 /** 26 * READ TIME FROM WORD COUNT 27 * 28 * @param {int} wordCount number of words in an article 29 * @returns {int} number of words per minute in minutes 30 */ 31 export function readTimeFromWordCount(wordCount) { 32 if (!wordCount) { 33 return false; 34 } 35 return Math.ceil(parseInt(wordCount, 10) / READING_WPM); 36 } 37 38 export const DSSource = ({ 39 source, 40 timeToRead, 41 newSponsoredLabel, 42 context, 43 sponsor, 44 sponsored_by_override, 45 icon_src, 46 refinedCardsLayout, 47 }) => { 48 // refinedCard styles will have a larger favicon size 49 const faviconSize = refinedCardsLayout ? 20 : 16; 50 51 // First try to display sponsored label or time to read here. 52 if (newSponsoredLabel) { 53 // If we can display something for spocs, do so. 54 if (sponsored_by_override || sponsor || context) { 55 return ( 56 <SponsorLabel 57 context={context} 58 sponsor={sponsor} 59 sponsored_by_override={sponsored_by_override} 60 newSponsoredLabel="new-sponsored-label" 61 /> 62 ); 63 } 64 } 65 66 // If we are not a spoc, and can display a time to read value. 67 if (source && timeToRead) { 68 return ( 69 <p className="source clamp time-to-read"> 70 <FluentOrText 71 message={{ 72 id: `newtab-label-source-read-time`, 73 values: { source, timeToRead }, 74 }} 75 /> 76 </p> 77 ); 78 } 79 80 // Otherwise display a default source. 81 return ( 82 <div className="source-wrapper"> 83 {icon_src && ( 84 <img src={icon_src} height={faviconSize} width={faviconSize} alt="" /> 85 )} 86 <p className="source clamp">{source}</p> 87 </div> 88 ); 89 }; 90 91 export const DefaultMeta = ({ 92 source, 93 title, 94 excerpt, 95 timeToRead, 96 newSponsoredLabel, 97 context, 98 context_type, 99 sponsor, 100 sponsored_by_override, 101 ctaButtonVariant, 102 dispatch, 103 mayHaveSectionsCards, 104 format, 105 topic, 106 isSectionsCard, 107 showTopics, 108 icon_src, 109 refinedCardsLayout, 110 }) => { 111 const shouldHaveFooterSection = isSectionsCard && showTopics; 112 113 return ( 114 <div className="meta"> 115 <div className="info-wrap"> 116 {ctaButtonVariant !== "variant-b" && 117 format !== "rectangle" && 118 !refinedCardsLayout && ( 119 <DSSource 120 source={source} 121 timeToRead={timeToRead} 122 newSponsoredLabel={newSponsoredLabel} 123 context={context} 124 sponsor={sponsor} 125 sponsored_by_override={sponsored_by_override} 126 icon_src={icon_src} 127 /> 128 )} 129 <h3 className="title clamp"> 130 {format === "rectangle" ? "Sponsored" : title} 131 </h3> 132 {format === "rectangle" ? ( 133 <p className="excerpt clamp"> 134 Sponsored content supports our mission to build a better web. 135 </p> 136 ) : ( 137 excerpt && <p className="excerpt clamp">{excerpt}</p> 138 )} 139 </div> 140 {(shouldHaveFooterSection || refinedCardsLayout) && ( 141 <div className="sections-card-footer"> 142 {refinedCardsLayout && 143 format !== "rectangle" && 144 format !== "spoc" && ( 145 <DSSource 146 source={source} 147 timeToRead={timeToRead} 148 newSponsoredLabel={newSponsoredLabel} 149 context={context} 150 sponsor={sponsor} 151 sponsored_by_override={sponsored_by_override} 152 icon_src={icon_src} 153 refinedCardsLayout={refinedCardsLayout} 154 /> 155 )} 156 {showTopics && ( 157 <span 158 className="ds-card-topic" 159 data-l10n-id={`newtab-topic-label-${topic}`} 160 /> 161 )} 162 </div> 163 )} 164 {!newSponsoredLabel && ( 165 <DSContextFooter 166 context_type={context_type} 167 context={context} 168 sponsor={sponsor} 169 sponsored_by_override={sponsored_by_override} 170 cta_button_variant={ctaButtonVariant} 171 source={source} 172 dispatch={dispatch} 173 mayHaveSectionsCards={mayHaveSectionsCards} 174 /> 175 )} 176 {/* Sponsored label is normally in the way of any message. 177 newSponsoredLabel cards sponsored label is moved to just under the thumbnail, 178 so we can display both, so we specifically don't pass in context. */} 179 {newSponsoredLabel && ( 180 <DSMessageFooter context_type={context_type} context={null} /> 181 )} 182 </div> 183 ); 184 }; 185 186 export class _DSCard extends React.PureComponent { 187 constructor(props) { 188 super(props); 189 190 this.onLinkClick = this.onLinkClick.bind(this); 191 this.doesLinkTopicMatchSelectedTopic = 192 this.doesLinkTopicMatchSelectedTopic.bind(this); 193 this.onMenuUpdate = this.onMenuUpdate.bind(this); 194 this.onMenuShow = this.onMenuShow.bind(this); 195 const refinedCardsLayout = 196 this.props.Prefs.values["discoverystream.refinedCardsLayout.enabled"]; 197 198 this.setContextMenuButtonHostRef = element => { 199 this.contextMenuButtonHostElement = element; 200 }; 201 this.setPlaceholderRef = element => { 202 this.placeholderElement = element; 203 }; 204 205 this.state = { 206 isSeen: false, 207 }; 208 209 // If this is for the about:home startup cache, then we always want 210 // to render the DSCard, regardless of whether or not its been seen. 211 if (props.App.isForStartupCache.App) { 212 this.state.isSeen = true; 213 } 214 215 // We want to choose the optimal thumbnail for the underlying DSImage, but 216 // want to do it in a performant way. The breakpoints used in the 217 // CSS of the page are, unfortuntely, not easy to retrieve without 218 // causing a style flush. To avoid that, we hardcode them here. 219 // 220 // The values chosen here were the dimensions of the card thumbnails as 221 // computed by getBoundingClientRect() for each type of viewport width 222 // across both high-density and normal-density displays. 223 this.standardCardImageSizes = [ 224 { 225 mediaMatcher: "default", 226 width: 296, 227 height: refinedCardsLayout ? 160 : 148, 228 }, 229 ]; 230 231 this.listCardImageSizes = [ 232 { 233 mediaMatcher: "(min-width: 1122px)", 234 width: 75, 235 height: 75, 236 }, 237 { 238 mediaMatcher: "default", 239 width: 50, 240 height: 50, 241 }, 242 ]; 243 244 this.sectionsCardImagesSizes = { 245 small: { 246 width: 110, 247 height: 117, 248 }, 249 medium: { 250 width: 300, 251 height: refinedCardsLayout ? 160 : 150, 252 }, 253 large: { 254 width: 190, 255 height: 250, 256 }, 257 }; 258 259 this.sectionsColumnMediaMatcher = { 260 1: "default", 261 2: "(min-width: 724px)", 262 3: "(min-width: 1122px)", 263 4: "(min-width: 1390px)", 264 }; 265 } 266 267 getSectionImageSize(column, size) { 268 const cardImageSize = { 269 mediaMatcher: this.sectionsColumnMediaMatcher[column], 270 width: this.sectionsCardImagesSizes[size].width, 271 height: this.sectionsCardImagesSizes[size].height, 272 }; 273 return cardImageSize; 274 } 275 276 doesLinkTopicMatchSelectedTopic() { 277 // Edge case for clicking on a card when topic selections have not be set 278 if (!this.props.selectedTopics) { 279 return "not-set"; 280 } 281 282 // Edge case the topic of the card is not one of the available topics 283 if (!this.props.availableTopics.includes(this.props.topic)) { 284 return "topic-not-selectable"; 285 } 286 287 if (this.props.selectedTopics.includes(this.props.topic)) { 288 return "true"; 289 } 290 291 return "false"; 292 } 293 294 onLinkClick() { 295 const matchesSelectedTopic = this.doesLinkTopicMatchSelectedTopic(); 296 if (this.props.dispatch) { 297 this.props.dispatch( 298 ac.DiscoveryStreamUserEvent({ 299 event: "CLICK", 300 source: this.props.type.toUpperCase(), 301 action_position: this.props.pos, 302 value: { 303 event_source: "card", 304 card_type: this.props.flightId ? "spoc" : "organic", 305 recommendation_id: this.props.recommendation_id, 306 tile_id: this.props.id, 307 ...(this.props.shim && this.props.shim.click 308 ? { shim: this.props.shim.click } 309 : {}), 310 fetchTimestamp: this.props.fetchTimestamp, 311 firstVisibleTimestamp: this.props.firstVisibleTimestamp, 312 corpus_item_id: this.props.corpus_item_id, 313 scheduled_corpus_item_id: this.props.scheduled_corpus_item_id, 314 recommended_at: this.props.recommended_at, 315 received_rank: this.props.received_rank, 316 topic: this.props.topic, 317 features: this.props.features, 318 matches_selected_topic: matchesSelectedTopic, 319 selected_topics: this.props.selectedTopics, 320 attribution: this.props.attribution, 321 ...(this.props.format 322 ? { format: this.props.format } 323 : { 324 format: getActiveCardSize( 325 window.innerWidth, 326 this.props.sectionsClassNames, 327 this.props.section, 328 this.props.flightId 329 ), 330 }), 331 ...(this.props.section 332 ? { 333 section: this.props.section, 334 section_position: this.props.sectionPosition, 335 is_section_followed: this.props.sectionFollowed, 336 layout_name: this.props.sectionLayoutName, 337 } 338 : {}), 339 }, 340 }) 341 ); 342 343 this.props.dispatch( 344 ac.ImpressionStats({ 345 source: this.props.type.toUpperCase(), 346 click: 0, 347 window_inner_width: this.props.windowObj.innerWidth, 348 window_inner_height: this.props.windowObj.innerHeight, 349 tiles: [ 350 { 351 id: this.props.id, 352 pos: this.props.pos, 353 ...(this.props.shim && this.props.shim.click 354 ? { shim: this.props.shim.click } 355 : {}), 356 type: this.props.flightId ? "spoc" : "organic", 357 recommendation_id: this.props.recommendation_id, 358 topic: this.props.topic, 359 selected_topics: this.props.selectedTopics, 360 ...(this.props.format 361 ? { format: this.props.format } 362 : { 363 format: getActiveCardSize( 364 window.innerWidth, 365 this.props.sectionsClassNames, 366 this.props.section, 367 this.props.flightId 368 ), 369 }), 370 ...(this.props.section 371 ? { 372 section: this.props.section, 373 section_position: this.props.sectionPosition, 374 is_section_followed: this.props.sectionFollowed, 375 } 376 : {}), 377 }, 378 ], 379 }) 380 ); 381 } 382 } 383 384 onMenuUpdate(showContextMenu) { 385 if (!showContextMenu) { 386 const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; 387 if (dsLinkMenuHostDiv) { 388 dsLinkMenuHostDiv.classList.remove("active", "last-item"); 389 } 390 } 391 } 392 393 async onMenuShow() { 394 const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; 395 if (dsLinkMenuHostDiv) { 396 // Force translation so we can be sure it's ready before measuring. 397 await this.props.windowObj.document.l10n.translateFragment( 398 dsLinkMenuHostDiv 399 ); 400 if (this.props.windowObj.scrollMaxX > 0) { 401 dsLinkMenuHostDiv.classList.add("last-item"); 402 } 403 dsLinkMenuHostDiv.classList.add("active"); 404 } 405 } 406 407 onSeen(entries) { 408 if (this.state) { 409 const entry = entries.find(e => e.isIntersecting); 410 411 if (entry) { 412 if (this.placeholderElement) { 413 this.observer.unobserve(this.placeholderElement); 414 } 415 416 // Stop observing since element has been seen 417 this.setState({ 418 isSeen: true, 419 }); 420 } 421 } 422 } 423 424 onIdleCallback() { 425 if (!this.state.isSeen) { 426 // To improve responsiveness without impacting performance, 427 // we start rendering stories on idle. 428 // To reduce the number of requests for secure OHTTP images, 429 // we skip idle-time loading. 430 if (!this.secureImage) { 431 if (this.observer && this.placeholderElement) { 432 this.observer.unobserve(this.placeholderElement); 433 } 434 435 this.setState({ 436 isSeen: true, 437 }); 438 } 439 } 440 } 441 442 componentDidMount() { 443 this.idleCallbackId = this.props.windowObj.requestIdleCallback( 444 this.onIdleCallback.bind(this) 445 ); 446 if (this.placeholderElement) { 447 this.observer = new IntersectionObserver(this.onSeen.bind(this)); 448 this.observer.observe(this.placeholderElement); 449 } 450 } 451 452 componentWillUnmount() { 453 // Remove observer on unmount 454 if (this.observer && this.placeholderElement) { 455 this.observer.unobserve(this.placeholderElement); 456 } 457 if (this.idleCallbackId) { 458 this.props.windowObj.cancelIdleCallback(this.idleCallbackId); 459 } 460 } 461 462 // Wraps the image URL with the moz-cached-ohttp:// protocol. 463 // This enables Firefox to load resources over Oblivious HTTP (OHTTP), 464 // providing privacy-preserving resource loading. 465 // Applied only when inferred personalization is enabled. 466 // See: https://firefox-source-docs.mozilla.org/browser/components/mozcachedohttp/docs/index.html 467 secureImageURL(url) { 468 return `moz-cached-ohttp://newtab-image/?url=${encodeURIComponent(url)}`; 469 } 470 471 getRawImageSrc() { 472 let rawImageSrc = ""; 473 // There is no point in fetching images for startup cache. 474 if (!this.props.App.isForStartupCache.App) { 475 rawImageSrc = this.props.raw_image_src; 476 } 477 return rawImageSrc; 478 } 479 480 getFaviconSrc() { 481 let faviconSrc = ""; 482 const faviconEnabled = this.props.Prefs.values[PREF_FAVICONS_ENABLED]; 483 // There is no point in fetching favicons for startup cache. 484 if ( 485 !this.props.App.isForStartupCache.App && 486 faviconEnabled && 487 this.props.icon_src 488 ) { 489 faviconSrc = this.props.icon_src; 490 if (this.secureImage) { 491 faviconSrc = this.secureImageURL(this.props.icon_src); 492 } 493 } 494 return faviconSrc; 495 } 496 497 get secureImage() { 498 const { Prefs, flightId } = this.props; 499 500 let ohttpEnabled = false; 501 if (flightId) { 502 ohttpEnabled = Prefs.values[PREF_OHTTP_UNIFIED_ADS]; 503 } else { 504 ohttpEnabled = Prefs.values[PREF_OHTTP_MERINO]; 505 } 506 507 const ohttpImagesEnabled = Prefs.values.ohttpImagesConfig?.enabled; 508 const includeTopStoriesSection = 509 Prefs.values.ohttpImagesConfig?.includeTopStoriesSection; 510 511 const nonPersonalizedSections = ["top_stories_section"]; 512 const sectionPersonalized = 513 !nonPersonalizedSections.includes(this.props.section) || 514 includeTopStoriesSection; 515 516 const secureImage = 517 ohttpImagesEnabled && ohttpEnabled && sectionPersonalized; 518 519 return secureImage; 520 } 521 522 renderImage({ sizes = [], classNames = "" } = {}) { 523 const { Prefs } = this.props; 524 525 const rawImageSrc = this.getRawImageSrc(); 526 const smartCrop = Prefs.values["images.smart"]; 527 return ( 528 <DSImage 529 extraClassNames={`img ${classNames}`} 530 source={this.props.image_src} 531 rawSource={rawImageSrc} 532 sizes={sizes} 533 url={this.props.url} 534 title={this.props.title} 535 isRecentSave={this.props.isRecentSave} 536 alt_text={this.props.alt_text} 537 smartCrop={smartCrop} 538 secureImage={this.secureImage} 539 /> 540 ); 541 } 542 543 renderSectionCardImages() { 544 const { sectionsCardImageSizes } = this.props; 545 546 const columns = ["1", "2", "3", "4"]; 547 const images = []; 548 549 for (const column of columns) { 550 const size = sectionsCardImageSizes[column]; 551 const sizes = [this.getSectionImageSize(column, size)]; 552 const image = this.renderImage({ sizes, classNames: `image-${column}` }); 553 images.push(image); 554 } 555 556 return <>{images}</>; 557 } 558 559 render() { 560 const { 561 isRecentSave, 562 DiscoveryStream, 563 Prefs, 564 mayHaveSectionsCards, 565 format, 566 } = this.props; 567 568 const refinedCardsLayout = 569 Prefs.values["discoverystream.refinedCardsLayout.enabled"]; 570 const refinedCardsClassName = refinedCardsLayout ? `refined-cards` : ``; 571 572 if (this.props.placeholder || !this.state.isSeen) { 573 // placeholder-seen is used to ensure the loading animation is only used if the card is visible. 574 const placeholderClassName = this.state.isSeen ? `placeholder-seen` : ``; 575 let placeholderElements = ( 576 <> 577 <div className="placeholder-image placeholder-fill" /> 578 <div className="placeholder-label placeholder-fill" /> 579 <div className="placeholder-header placeholder-fill" /> 580 <div className="placeholder-description placeholder-fill" /> 581 </> 582 ); 583 584 if (refinedCardsLayout) { 585 placeholderElements = ( 586 <> 587 <div className="placeholder-image placeholder-fill" /> 588 <div className="placeholder-description placeholder-fill" /> 589 <div className="placeholder-header placeholder-fill" /> 590 </> 591 ); 592 } 593 return ( 594 <div 595 className={`ds-card placeholder ${placeholderClassName} ${refinedCardsClassName}`} 596 ref={this.setPlaceholderRef} 597 > 598 {placeholderElements} 599 </div> 600 ); 601 } 602 603 let source = this.props.source || this.props.publisher; 604 if (!source) { 605 try { 606 source = new URL(this.props.url).hostname; 607 } catch (e) {} 608 } 609 610 const { 611 hideDescriptions, 612 compactImages, 613 imageGradient, 614 newSponsoredLabel, 615 titleLines = 3, 616 descLines = 3, 617 readTime: displayReadTime, 618 } = DiscoveryStream; 619 620 const sectionsEnabled = Prefs.values[PREF_SECTIONS_ENABLED]; 621 // Refined cards have their own excerpt hiding logic. 622 // We can ignore hideDescriptions if we are in sections and refined cards. 623 const excerpt = 624 !hideDescriptions || (sectionsEnabled && refinedCardsLayout) 625 ? this.props.excerpt 626 : ""; 627 628 let timeToRead; 629 if (displayReadTime) { 630 timeToRead = 631 this.props.time_to_read || readTimeFromWordCount(this.props.word_count); 632 } 633 634 const ctaButtonEnabled = this.props.ctaButtonSponsors?.includes( 635 this.props.sponsor?.toLowerCase() 636 ); 637 let ctaButtonVariant = ""; 638 if (ctaButtonEnabled) { 639 ctaButtonVariant = this.props.ctaButtonVariant; 640 } 641 let ctaButtonVariantClassName = ctaButtonVariant; 642 643 const ctaButtonClassName = ctaButtonEnabled ? `ds-card-cta-button` : ``; 644 const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``; 645 const imageGradientClassName = imageGradient 646 ? `ds-card-image-gradient` 647 : ``; 648 const sectionsCardsClassName = [ 649 mayHaveSectionsCards ? `sections-card-ui` : ``, 650 this.props.sectionsClassNames, 651 ].join(" "); 652 const titleLinesName = `ds-card-title-lines-${titleLines}`; 653 const descLinesClassName = `ds-card-desc-lines-${descLines}`; 654 const isMediumRectangle = format === "rectangle"; 655 const spocFormatClassName = isMediumRectangle ? `ds-spoc-rectangle` : ``; 656 const faviconSrc = this.getFaviconSrc(); 657 658 let images = this.renderImage({ sizes: this.standardCardImageSizes }); 659 if (isMediumRectangle) { 660 images = this.renderImage(); 661 } else if (sectionsEnabled) { 662 images = this.renderSectionCardImages(); 663 } 664 665 return ( 666 <article 667 className={`ds-card ${sectionsCardsClassName} ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName} ${spocFormatClassName} ${ctaButtonClassName} ${ctaButtonVariantClassName} ${refinedCardsClassName}`} 668 ref={this.setContextMenuButtonHostRef} 669 data-position-one={this.props["data-position-one"]} 670 data-position-two={this.props["data-position-one"]} 671 data-position-three={this.props["data-position-one"]} 672 data-position-four={this.props["data-position-one"]} 673 > 674 <SafeAnchor 675 className="ds-card-link" 676 dispatch={this.props.dispatch} 677 onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined} 678 url={this.props.url} 679 title={this.props.title} 680 isSponsored={!!this.props.flightId} 681 tabIndex={this.props.tabIndex} 682 onFocus={this.props.onFocus} 683 > 684 {this.props.showTopics && 685 !this.props.mayHaveSectionsCards && 686 this.props.topic && 687 !refinedCardsLayout && ( 688 <span 689 className="ds-card-topic" 690 data-l10n-id={`newtab-topic-label-${this.props.topic}`} 691 /> 692 )} 693 <div className="img-wrapper">{images}</div> 694 <ImpressionStats 695 flightId={this.props.flightId} 696 rows={[ 697 { 698 id: this.props.id, 699 pos: this.props.pos, 700 ...(this.props.shim && this.props.shim.impression 701 ? { shim: this.props.shim.impression } 702 : {}), 703 recommendation_id: this.props.recommendation_id, 704 fetchTimestamp: this.props.fetchTimestamp, 705 corpus_item_id: this.props.corpus_item_id, 706 scheduled_corpus_item_id: this.props.scheduled_corpus_item_id, 707 recommended_at: this.props.recommended_at, 708 received_rank: this.props.received_rank, 709 topic: this.props.topic, 710 features: this.props.features, 711 ...(format ? { format } : {}), 712 category: this.props.category, 713 attribution: this.props.attribution, 714 ...(this.props.section 715 ? { 716 section: this.props.section, 717 section_position: this.props.sectionPosition, 718 is_section_followed: this.props.sectionFollowed, 719 sectionLayoutName: this.props.sectionLayoutName, 720 } 721 : {}), 722 ...(!format && this.props.section 723 ? // Note: sectionsCardsClassName is passed to ImpressionStats.jsx in order to calculate format 724 { class_names: sectionsCardsClassName } 725 : {}), 726 }, 727 ]} 728 dispatch={this.props.dispatch} 729 source={this.props.type} 730 firstVisibleTimestamp={this.props.firstVisibleTimestamp} 731 /> 732 733 {ctaButtonVariant === "variant-b" && ( 734 <div className="cta-header">Shop Now</div> 735 )} 736 <DefaultMeta 737 source={source} 738 title={this.props.title} 739 excerpt={excerpt} 740 newSponsoredLabel={newSponsoredLabel} 741 timeToRead={timeToRead} 742 context={this.props.context} 743 context_type={this.props.context_type} 744 sponsor={this.props.sponsor} 745 sponsored_by_override={this.props.sponsored_by_override} 746 ctaButtonVariant={ctaButtonVariant} 747 dispatch={this.props.dispatch} 748 mayHaveSectionsCards={this.props.mayHaveSectionsCards} 749 state={this.state} 750 showTopics={!refinedCardsLayout && this.props.showTopics} 751 isSectionsCard={this.props.mayHaveSectionsCards && this.props.topic} 752 format={format} 753 topic={this.props.topic} 754 icon_src={faviconSrc} 755 refinedCardsLayout={refinedCardsLayout} 756 tabIndex={this.props.tabIndex} 757 /> 758 </SafeAnchor> 759 <div className="card-stp-button-hover-background"> 760 <div className="card-stp-button-position-wrapper"> 761 <DSLinkMenu 762 id={this.props.id} 763 index={this.props.pos} 764 dispatch={this.props.dispatch} 765 url={this.props.url} 766 title={this.props.title} 767 source={source} 768 type={this.props.type} 769 card_type={this.props.flightId ? "spoc" : "organic"} 770 pocket_id={this.props.pocket_id} 771 shim={this.props.shim} 772 bookmarkGuid={this.props.bookmarkGuid} 773 flightId={this.props.flightId} 774 showPrivacyInfo={!!this.props.flightId} 775 onMenuUpdate={this.onMenuUpdate} 776 onMenuShow={this.onMenuShow} 777 isRecentSave={isRecentSave} 778 recommendation_id={this.props.recommendation_id} 779 tile_id={this.props.id} 780 block_key={this.props.id} 781 corpus_item_id={this.props.corpus_item_id} 782 scheduled_corpus_item_id={this.props.scheduled_corpus_item_id} 783 recommended_at={this.props.recommended_at} 784 received_rank={this.props.received_rank} 785 section={this.props.section} 786 section_position={this.props.sectionPosition} 787 is_section_followed={this.props.sectionFollowed} 788 fetchTimestamp={this.props.fetchTimestamp} 789 firstVisibleTimestamp={this.props.firstVisibleTimestamp} 790 format={ 791 format 792 ? format 793 : getActiveCardSize( 794 window.innerWidth, 795 this.props.sectionsClassNames, 796 this.props.section, 797 this.props.flightId 798 ) 799 } 800 isSectionsCard={this.props.mayHaveSectionsCards} 801 topic={this.props.topic} 802 selected_topics={this.props.selected_topics} 803 tabIndex={this.props.tabIndex} 804 /> 805 </div> 806 </div> 807 </article> 808 ); 809 } 810 } 811 812 _DSCard.defaultProps = { 813 windowObj: window, // Added to support unit tests 814 }; 815 816 export const DSCard = connect(state => ({ 817 App: state.App, 818 DiscoveryStream: state.DiscoveryStream, 819 Prefs: state.Prefs, 820 }))(_DSCard); 821 822 export const PlaceholderDSCard = () => <DSCard placeholder={true} />;