tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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} />;