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 5import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; 6import { DSImage } from "../DSImage/DSImage.jsx"; 7import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu"; 8import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; 9import React from "react"; 10import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; 11import { 12 DSContextFooter, 13 SponsorLabel, 14 DSMessageFooter, 15} from "../DSContextFooter/DSContextFooter.jsx"; 16import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; 17import { connect } from "react-redux"; 18 19const READING_WPM = 220; 20 21/** 22 * READ TIME FROM WORD COUNT 23 * @param {int} wordCount number of words in an article 24 * @returns {int} number of words per minute in minutes 25 */ 26export function readTimeFromWordCount(wordCount) { 27 if (!wordCount) return false; 28 return Math.ceil(parseInt(wordCount, 10) / READING_WPM); 29} 30 31export const DSSource = ({ 32 source, 33 timeToRead, 34 newSponsoredLabel, 35 context, 36 sponsor, 37 sponsored_by_override, 38}) => { 39 // First try to display sponsored label or time to read here. 40 if (newSponsoredLabel) { 41 // If we can display something for spocs, do so. 42 if (sponsored_by_override || sponsor || context) { 43 return ( 44 <SponsorLabel 45 context={context} 46 sponsor={sponsor} 47 sponsored_by_override={sponsored_by_override} 48 newSponsoredLabel="new-sponsored-label" 49 /> 50 ); 51 } 52 } 53 54 // If we are not a spoc, and can display a time to read value. 55 if (timeToRead) { 56 return ( 57 <p className="source clamp time-to-read"> 58 <FluentOrText 59 message={{ 60 id: `newtab-label-source-read-time`, 61 values: { source, timeToRead }, 62 }} 63 /> 64 </p> 65 ); 66 } 67 68 // Otherwise display a default source. 69 return <p className="source clamp">{source}</p>; 70}; 71 72// Default Meta that displays CTA as link if cta_variant in layout is set as "link" 73export const DefaultMeta = ({ 74 display_engagement_labels, 75 source, 76 title, 77 excerpt, 78 timeToRead, 79 newSponsoredLabel, 80 context, 81 context_type, 82 cta, 83 engagement, 84 cta_variant, 85 sponsor, 86 sponsored_by_override, 87 saveToPocketCard, 88}) => ( 89 <div className="meta"> 90 <div className="info-wrap"> 91 <DSSource 92 source={source} 93 timeToRead={timeToRead} 94 newSponsoredLabel={newSponsoredLabel} 95 context={context} 96 sponsor={sponsor} 97 sponsored_by_override={sponsored_by_override} 98 /> 99 <header title={title} className="title clamp"> 100 {title} 101 </header> 102 {excerpt && <p className="excerpt clamp">{excerpt}</p>} 103 {cta_variant === "link" && cta && ( 104 <div role="link" className="cta-link icon icon-arrow" tabIndex="0"> 105 {cta} 106 </div> 107 )} 108 </div> 109 {!newSponsoredLabel && ( 110 <DSContextFooter 111 context_type={context_type} 112 context={context} 113 sponsor={sponsor} 114 sponsored_by_override={sponsored_by_override} 115 display_engagement_labels={display_engagement_labels} 116 engagement={engagement} 117 /> 118 )} 119 {/* Sponsored label is normally in the way of any message. 120 newSponsoredLabel cards sponsored label is moved to just under the thumbnail, 121 so we can display both, so we specifically don't pass in context. */} 122 {newSponsoredLabel && ( 123 <DSMessageFooter 124 context_type={context_type} 125 context={null} 126 display_engagement_labels={display_engagement_labels} 127 engagement={engagement} 128 saveToPocketCard={saveToPocketCard} 129 /> 130 )} 131 </div> 132); 133 134export const CTAButtonMeta = ({ 135 display_engagement_labels, 136 source, 137 title, 138 excerpt, 139 context, 140 context_type, 141 cta, 142 engagement, 143 sponsor, 144 sponsored_by_override, 145}) => ( 146 <div className="meta"> 147 <div className="info-wrap"> 148 <p className="source clamp"> 149 {context && ( 150 <FluentOrText 151 message={{ 152 id: `newtab-label-sponsored`, 153 values: { sponsorOrSource: sponsor ? sponsor : source }, 154 }} 155 /> 156 )} 157 158 {!context && (sponsor ? sponsor : source)} 159 </p> 160 <header title={title} className="title clamp"> 161 {title} 162 </header> 163 {excerpt && <p className="excerpt clamp">{excerpt}</p>} 164 </div> 165 {context && cta && <button className="button cta-button">{cta}</button>} 166 {!context && ( 167 <DSContextFooter 168 context_type={context_type} 169 context={context} 170 sponsor={sponsor} 171 sponsored_by_override={sponsored_by_override} 172 display_engagement_labels={display_engagement_labels} 173 engagement={engagement} 174 /> 175 )} 176 </div> 177); 178 179export class _DSCard extends React.PureComponent { 180 constructor(props) { 181 super(props); 182 183 this.onLinkClick = this.onLinkClick.bind(this); 184 this.onSaveClick = this.onSaveClick.bind(this); 185 this.onMenuUpdate = this.onMenuUpdate.bind(this); 186 this.onMenuShow = this.onMenuShow.bind(this); 187 188 this.setContextMenuButtonHostRef = element => { 189 this.contextMenuButtonHostElement = element; 190 }; 191 this.setPlaceholderRef = element => { 192 this.placeholderElement = element; 193 }; 194 195 this.state = { 196 isSeen: false, 197 }; 198 199 // If this is for the about:home startup cache, then we always want 200 // to render the DSCard, regardless of whether or not its been seen. 201 if (props.App.isForStartupCache) { 202 this.state.isSeen = true; 203 } 204 205 // We want to choose the optimal thumbnail for the underlying DSImage, but 206 // want to do it in a performant way. The breakpoints used in the 207 // CSS of the page are, unfortuntely, not easy to retrieve without 208 // causing a style flush. To avoid that, we hardcode them here. 209 // 210 // The values chosen here were the dimensions of the card thumbnails as 211 // computed by getBoundingClientRect() for each type of viewport width 212 // across both high-density and normal-density displays. 213 this.dsImageSizes = [ 214 { 215 mediaMatcher: "(min-width: 1122px)", 216 width: 296, 217 height: 148, 218 }, 219 220 { 221 mediaMatcher: "(min-width: 866px)", 222 width: 218, 223 height: 109, 224 }, 225 226 { 227 mediaMatcher: "(max-width: 610px)", 228 width: 202, 229 height: 101, 230 }, 231 ]; 232 } 233 234 onLinkClick(event) { 235 if (this.props.dispatch) { 236 this.props.dispatch( 237 ac.UserEvent({ 238 event: "CLICK", 239 source: this.props.is_video 240 ? "CARDGRID_VIDEO" 241 : this.props.type.toUpperCase(), 242 action_position: this.props.pos, 243 value: { card_type: this.props.flightId ? "spoc" : "organic" }, 244 }) 245 ); 246 247 this.props.dispatch( 248 ac.ImpressionStats({ 249 source: this.props.is_video 250 ? "CARDGRID_VIDEO" 251 : this.props.type.toUpperCase(), 252 click: 0, 253 tiles: [ 254 { 255 id: this.props.id, 256 pos: this.props.pos, 257 ...(this.props.shim && this.props.shim.click 258 ? { shim: this.props.shim.click } 259 : {}), 260 }, 261 ], 262 }) 263 ); 264 } 265 } 266 267 onSaveClick(event) { 268 if (this.props.dispatch) { 269 this.props.dispatch( 270 ac.AlsoToMain({ 271 type: at.SAVE_TO_POCKET, 272 data: { site: { url: this.props.url, title: this.props.title } }, 273 }) 274 ); 275 276 this.props.dispatch( 277 ac.UserEvent({ 278 event: "SAVE_TO_POCKET", 279 source: "CARDGRID_HOVER", 280 action_position: this.props.pos, 281 }) 282 ); 283 284 this.props.dispatch( 285 ac.ImpressionStats({ 286 source: "CARDGRID_HOVER", 287 pocket: 0, 288 tiles: [ 289 { 290 id: this.props.id, 291 pos: this.props.pos, 292 ...(this.props.shim && this.props.shim.save 293 ? { shim: this.props.shim.save } 294 : {}), 295 }, 296 ], 297 }) 298 ); 299 } 300 } 301 302 onMenuUpdate(showContextMenu) { 303 if (!showContextMenu) { 304 const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; 305 if (dsLinkMenuHostDiv) { 306 dsLinkMenuHostDiv.classList.remove("active", "last-item"); 307 } 308 } 309 } 310 311 async onMenuShow() { 312 const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; 313 if (dsLinkMenuHostDiv) { 314 // Force translation so we can be sure it's ready before measuring. 315 await this.props.windowObj.document.l10n.translateFragment( 316 dsLinkMenuHostDiv 317 ); 318 if (this.props.windowObj.scrollMaxX > 0) { 319 dsLinkMenuHostDiv.classList.add("last-item"); 320 } 321 dsLinkMenuHostDiv.classList.add("active"); 322 } 323 } 324 325 onSeen(entries) { 326 if (this.state) { 327 const entry = entries.find(e => e.isIntersecting); 328 329 if (entry) { 330 if (this.placeholderElement) { 331 this.observer.unobserve(this.placeholderElement); 332 } 333 334 // Stop observing since element has been seen 335 this.setState({ 336 isSeen: true, 337 }); 338 } 339 } 340 } 341 342 onIdleCallback() { 343 if (!this.state.isSeen) { 344 if (this.observer && this.placeholderElement) { 345 this.observer.unobserve(this.placeholderElement); 346 } 347 this.setState({ 348 isSeen: true, 349 }); 350 } 351 } 352 353 componentDidMount() { 354 this.idleCallbackId = this.props.windowObj.requestIdleCallback( 355 this.onIdleCallback.bind(this) 356 ); 357 if (this.placeholderElement) { 358 this.observer = new IntersectionObserver(this.onSeen.bind(this)); 359 this.observer.observe(this.placeholderElement); 360 } 361 } 362 363 componentWillUnmount() { 364 // Remove observer on unmount 365 if (this.observer && this.placeholderElement) { 366 this.observer.unobserve(this.placeholderElement); 367 } 368 if (this.idleCallbackId) { 369 this.props.windowObj.cancelIdleCallback(this.idleCallbackId); 370 } 371 } 372 373 render() { 374 if (this.props.placeholder || !this.state.isSeen) { 375 return ( 376 <div className="ds-card placeholder" ref={this.setPlaceholderRef} /> 377 ); 378 } 379 380 if (this.props.lastCard) { 381 return ( 382 <div className="ds-card last-card-message"> 383 <div className="img-wrapper"> 384 <picture className="ds-image img loaded"> 385 <img 386 data-l10n-id="newtab-pocket-last-card-image" 387 className="last-card-message-image" 388 src="chrome://activity-stream/content/data/content/assets/caught-up-illustration.svg" 389 alt="You’re all caught up" 390 /> 391 </picture> 392 </div> 393 <div className="meta"> 394 <div className="info-wrap"> 395 <header 396 className="title clamp" 397 data-l10n-id="newtab-pocket-last-card-title" 398 /> 399 <p 400 className="ds-last-card-desc" 401 data-l10n-id="newtab-pocket-last-card-desc" 402 /> 403 </div> 404 </div> 405 </div> 406 ); 407 } 408 const isButtonCTA = this.props.cta_variant === "button"; 409 410 const { 411 is_video, 412 saveToPocketCard, 413 hideDescriptions, 414 compactImages, 415 imageGradient, 416 titleLines = 3, 417 descLines = 3, 418 displayReadTime, 419 } = this.props; 420 const excerpt = !hideDescriptions ? this.props.excerpt : ""; 421 422 let timeToRead; 423 if (displayReadTime) { 424 timeToRead = 425 this.props.time_to_read || readTimeFromWordCount(this.props.word_count); 426 } 427 428 const videoCardClassName = is_video ? `video-card` : ``; 429 const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``; 430 const imageGradientClassName = imageGradient 431 ? `ds-card-image-gradient` 432 : ``; 433 const titleLinesName = `ds-card-title-lines-${titleLines}`; 434 const descLinesClassName = `ds-card-desc-lines-${descLines}`; 435 436 return ( 437 <div 438 className={`ds-card ${videoCardClassName} ${videoCardClassName} ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName}`} 439 ref={this.setContextMenuButtonHostRef} 440 > 441 <SafeAnchor 442 className="ds-card-link" 443 dispatch={this.props.dispatch} 444 onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined} 445 url={this.props.url} 446 > 447 <div className="img-wrapper"> 448 <DSImage 449 extraClassNames="img" 450 source={this.props.image_src} 451 rawSource={this.props.raw_image_src} 452 sizes={this.dsImageSizes} 453 /> 454 {this.props.is_video && ( 455 <div className="playhead"> 456 <span>Video Content</span> 457 </div> 458 )} 459 </div> 460 {isButtonCTA ? ( 461 <CTAButtonMeta 462 display_engagement_labels={this.props.display_engagement_labels} 463 source={this.props.source} 464 title={this.props.title} 465 excerpt={excerpt} 466 timeToRead={timeToRead} 467 context={this.props.context} 468 context_type={this.props.context_type} 469 engagement={this.props.engagement} 470 cta={this.props.cta} 471 sponsor={this.props.sponsor} 472 sponsored_by_override={this.props.sponsored_by_override} 473 /> 474 ) : ( 475 <DefaultMeta 476 display_engagement_labels={this.props.display_engagement_labels} 477 source={this.props.source} 478 title={this.props.title} 479 excerpt={excerpt} 480 newSponsoredLabel={this.props.newSponsoredLabel} 481 timeToRead={timeToRead} 482 context={this.props.context} 483 engagement={this.props.engagement} 484 context_type={this.props.context_type} 485 cta={this.props.cta} 486 cta_variant={this.props.cta_variant} 487 sponsor={this.props.sponsor} 488 sponsored_by_override={this.props.sponsored_by_override} 489 saveToPocketCard={saveToPocketCard} 490 /> 491 )} 492 <ImpressionStats 493 flightId={this.props.flightId} 494 rows={[ 495 { 496 id: this.props.id, 497 pos: this.props.pos, 498 ...(this.props.shim && this.props.shim.impression 499 ? { shim: this.props.shim.impression } 500 : {}), 501 }, 502 ]} 503 dispatch={this.props.dispatch} 504 source={this.props.is_video ? "CARDGRID_VIDEO" : this.props.type} 505 /> 506 </SafeAnchor> 507 {saveToPocketCard && ( 508 <div className="card-stp-button-hover-background"> 509 <div className="card-stp-button-position-wrapper"> 510 <button className="card-stp-button" onClick={this.onSaveClick}> 511 {this.props.context_type === "pocket" ? ( 512 <> 513 <span className="story-badge-icon icon icon-pocket" /> 514 <span data-l10n-id="newtab-pocket-saved-to-pocket" /> 515 </> 516 ) : ( 517 <> 518 <span className="story-badge-icon icon icon-pocket-save" /> 519 <span data-l10n-id="newtab-pocket-save-to-pocket" /> 520 </> 521 )} 522 </button> 523 <DSLinkMenu 524 id={this.props.id} 525 index={this.props.pos} 526 dispatch={this.props.dispatch} 527 url={this.props.url} 528 title={this.props.title} 529 source={this.props.source} 530 type={this.props.type} 531 pocket_id={this.props.pocket_id} 532 shim={this.props.shim} 533 bookmarkGuid={this.props.bookmarkGuid} 534 flightId={ 535 !this.props.is_collection ? this.props.flightId : undefined 536 } 537 showPrivacyInfo={!!this.props.flightId} 538 onMenuUpdate={this.onMenuUpdate} 539 onMenuShow={this.onMenuShow} 540 saveToPocketCard={saveToPocketCard} 541 /> 542 </div> 543 </div> 544 )} 545 {!saveToPocketCard && ( 546 <DSLinkMenu 547 id={this.props.id} 548 index={this.props.pos} 549 dispatch={this.props.dispatch} 550 url={this.props.url} 551 title={this.props.title} 552 source={this.props.source} 553 type={this.props.type} 554 pocket_id={this.props.pocket_id} 555 shim={this.props.shim} 556 bookmarkGuid={this.props.bookmarkGuid} 557 flightId={ 558 !this.props.is_collection ? this.props.flightId : undefined 559 } 560 showPrivacyInfo={!!this.props.flightId} 561 hostRef={this.contextMenuButtonHostRef} 562 onMenuUpdate={this.onMenuUpdate} 563 onMenuShow={this.onMenuShow} 564 /> 565 )} 566 </div> 567 ); 568 } 569} 570 571_DSCard.defaultProps = { 572 windowObj: window, // Added to support unit tests 573}; 574 575export const DSCard = connect(state => ({ 576 App: state.App, 577}))(_DSCard); 578 579export const PlaceholderDSCard = props => <DSCard placeholder={true} />; 580export const LastCardMessage = props => <DSCard lastCard={true} />; 581