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