Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import { DSImage } from "../DSImage/DSImage.jsx";
import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu";
import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
import React from "react";
import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
import {
DSContextFooter,
SponsorLabel,
DSMessageFooter,
} from "../DSContextFooter/DSContextFooter.jsx";
import { DSThumbsUpDownButtons } from "../DSThumbsUpDownButtons/DSThumbsUpDownButtons.jsx";
import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
import { connect } from "react-redux";
import { LinkMenuOptions } from "content-src/lib/link-menu-options";
const READING_WPM = 220;
/**
* READ TIME FROM WORD COUNT
* @param {int} wordCount number of words in an article
* @returns {int} number of words per minute in minutes
*/
export function readTimeFromWordCount(wordCount) {
if (!wordCount) {
return false;
}
return Math.ceil(parseInt(wordCount, 10) / READING_WPM);
}
export const DSSource = ({
source,
timeToRead,
newSponsoredLabel,
context,
sponsor,
sponsored_by_override,
icon_src,
}) => {
// First try to display sponsored label or time to read here.
if (newSponsoredLabel) {
// If we can display something for spocs, do so.
if (sponsored_by_override || sponsor || context) {
return (
<SponsorLabel
context={context}
sponsor={sponsor}
sponsored_by_override={sponsored_by_override}
newSponsoredLabel="new-sponsored-label"
/>
);
}
}
// If we are not a spoc, and can display a time to read value.
if (source && timeToRead) {
return (
<p className="source clamp time-to-read">
<FluentOrText
message={{
id: `newtab-label-source-read-time`,
values: { source, timeToRead },
}}
/>
</p>
);
}
// Otherwise display a default source.
return (
<p className="source clamp">
{icon_src && <img src={icon_src} height="16" width="16" alt="" />}
{source}
</p>
);
};
export const DefaultMeta = ({
source,
title,
excerpt,
timeToRead,
newSponsoredLabel,
context,
context_type,
sponsor,
sponsored_by_override,
saveToPocketCard,
ctaButtonVariant,
dispatch,
spocMessageVariant,
mayHaveSectionsCards,
mayHaveThumbsUpDown,
onThumbsUpClick,
onThumbsDownClick,
isListCard,
state,
format,
topic,
isSectionsCard,
showTopics,
icon_src,
}) => {
const shouldHaveThumbs =
!isListCard &&
format !== "rectangle" &&
mayHaveSectionsCards &&
mayHaveThumbsUpDown;
const shouldHaveFooterSection =
isSectionsCard && (shouldHaveThumbs || showTopics);
return (
<div className="meta">
<div className="info-wrap">
{ctaButtonVariant !== "variant-b" && format !== "rectangle" && (
<DSSource
source={source}
timeToRead={timeToRead}
newSponsoredLabel={newSponsoredLabel}
context={context}
sponsor={sponsor}
sponsored_by_override={sponsored_by_override}
icon_src={icon_src}
/>
)}
{format !== "rectangle" && (
<>
<h3 className="title clamp">{title}</h3>
{excerpt && <p className="excerpt clamp">{excerpt}</p>}
</>
)}
{/* Rectangle format is returned for English clients only.*/}
{format === "rectangle" && (
<>
<h3 className="title clamp">Sponsored</h3>
<p className="excerpt clamp">
Sponsored content supports our mission to build a better web.
</p>
</>
)}
</div>
{!isListCard &&
format !== "rectangle" &&
!mayHaveSectionsCards &&
mayHaveThumbsUpDown && (
<DSThumbsUpDownButtons
onThumbsDownClick={onThumbsDownClick}
onThumbsUpClick={onThumbsUpClick}
sponsor={sponsor}
isThumbsDownActive={state.isThumbsDownActive}
isThumbsUpActive={state.isThumbsUpActive}
/>
)}
{shouldHaveFooterSection && (
<div className="sections-card-footer">
{shouldHaveThumbs && (
<DSThumbsUpDownButtons
onThumbsDownClick={onThumbsDownClick}
onThumbsUpClick={onThumbsUpClick}
sponsor={sponsor}
isThumbsDownActive={state.isThumbsDownActive}
isThumbsUpActive={state.isThumbsUpActive}
/>
)}
{showTopics && (
<span
className="ds-card-topic"
data-l10n-id={`newtab-topic-label-${topic}`}
/>
)}
</div>
)}
{!newSponsoredLabel && (
<DSContextFooter
context_type={context_type}
context={context}
sponsor={sponsor}
sponsored_by_override={sponsored_by_override}
cta_button_variant={ctaButtonVariant}
source={source}
dispatch={dispatch}
spocMessageVariant={spocMessageVariant}
mayHaveSectionsCards={mayHaveSectionsCards}
/>
)}
{/* Sponsored label is normally in the way of any message.
newSponsoredLabel cards sponsored label is moved to just under the thumbnail,
so we can display both, so we specifically don't pass in context. */}
{newSponsoredLabel && (
<DSMessageFooter
context_type={context_type}
context={null}
saveToPocketCard={saveToPocketCard}
/>
)}
</div>
);
};
export class _DSCard extends React.PureComponent {
constructor(props) {
super(props);
this.onLinkClick = this.onLinkClick.bind(this);
this.doesLinkTopicMatchSelectedTopic =
this.doesLinkTopicMatchSelectedTopic.bind(this);
this.onMenuUpdate = this.onMenuUpdate.bind(this);
this.onMenuShow = this.onMenuShow.bind(this);
this.onThumbsUpClick = this.onThumbsUpClick.bind(this);
this.onThumbsDownClick = this.onThumbsDownClick.bind(this);
this.setContextMenuButtonHostRef = element => {
this.contextMenuButtonHostElement = element;
};
this.setPlaceholderRef = element => {
this.placeholderElement = element;
};
this.state = {
isSeen: false,
isThumbsUpActive: false,
isThumbsDownActive: false,
};
// If this is for the about:home startup cache, then we always want
// to render the DSCard, regardless of whether or not its been seen.
if (props.App.isForStartupCache) {
this.state.isSeen = true;
}
// We want to choose the optimal thumbnail for the underlying DSImage, but
// want to do it in a performant way. The breakpoints used in the
// CSS of the page are, unfortuntely, not easy to retrieve without
// causing a style flush. To avoid that, we hardcode them here.
//
// The values chosen here were the dimensions of the card thumbnails as
// computed by getBoundingClientRect() for each type of viewport width
// across both high-density and normal-density displays.
this.dsImageSizes = [
{
mediaMatcher: "(min-width: 1122px)",
width: 296,
height: 148,
},
{
mediaMatcher: "(min-width: 866px)",
width: 218,
height: 109,
},
{
mediaMatcher: "(max-width: 610px)",
width: 202,
height: 101,
},
];
this.standardCardImageSizes = [
{
mediaMatcher: "default",
width: 296,
height: 148,
},
];
this.listCardImageSizes = [
{
mediaMatcher: "(min-width: 1122px)",
width: 75,
height: 75,
},
{
mediaMatcher: "default",
width: 50,
height: 50,
},
];
this.sectionsCardImagesSizes = {
small: {
width: 100,
height: 120,
},
medium: {
width: 300,
height: 150,
},
large: {
width: 265,
height: 265,
},
};
this.sectionsColumnMediaMatcher = {
1: "default",
2: "(min-width: 724px)",
3: "(min-width: 1122px)",
4: "(min-width: 1390px)",
};
}
getSectionImageSize(column, size) {
const cardImageSize = {
mediaMatcher: this.sectionsColumnMediaMatcher[column],
width: this.sectionsCardImagesSizes[size].width,
height: this.sectionsCardImagesSizes[size].height,
};
return cardImageSize;
}
doesLinkTopicMatchSelectedTopic() {
// Edge case for clicking on a card when topic selections have not be set
if (!this.props.selectedTopics) {
return "not-set";
}
// Edge case the topic of the card is not one of the available topics
if (!this.props.availableTopics.includes(this.props.topic)) {
return "topic-not-selectable";
}
if (this.props.selectedTopics.includes(this.props.topic)) {
return "true";
}
return "false";
}
onLinkClick() {
const matchesSelectedTopic = this.doesLinkTopicMatchSelectedTopic();
if (this.props.dispatch) {
if (this.props.isFakespot) {
this.props.dispatch(
ac.DiscoveryStreamUserEvent({
event: "FAKESPOT_CLICK",
value: {
product_id: this.props.id,
category: this.props.category || "",
},
})
);
} else {
this.props.dispatch(
ac.DiscoveryStreamUserEvent({
event: "CLICK",
source: this.props.type.toUpperCase(),
action_position: this.props.pos,
value: {
card_type: this.props.flightId ? "spoc" : "organic",
recommendation_id: this.props.recommendation_id,
tile_id: this.props.id,
...(this.props.shim && this.props.shim.click
? { shim: this.props.shim.click }
: {}),
fetchTimestamp: this.props.fetchTimestamp,
firstVisibleTimestamp: this.props.firstVisibleTimestamp,
corpus_item_id: this.props.corpus_item_id,
scheduled_corpus_item_id: this.props.scheduled_corpus_item_id,
recommended_at: this.props.recommended_at,
received_rank: this.props.received_rank,
topic: this.props.topic,
matches_selected_topic: matchesSelectedTopic,
selected_topics: this.props.selectedTopics,
is_list_card: this.props.isListCard,
...(this.props.format ? { format: this.props.format } : {}),
...(this.props.section
? {
section: this.props.section,
section_position: this.props.sectionPosition,
is_secton_followed: this.props.sectionFollowed,
}
: {}),
},
})
);
this.props.dispatch(
ac.ImpressionStats({
source: this.props.type.toUpperCase(),
click: 0,
window_inner_width: this.props.windowObj.innerWidth,
window_inner_height: this.props.windowObj.innerHeight,
tiles: [
{
id: this.props.id,
pos: this.props.pos,
...(this.props.shim && this.props.shim.click
? { shim: this.props.shim.click }
: {}),
type: this.props.flightId ? "spoc" : "organic",
recommendation_id: this.props.recommendation_id,
topic: this.props.topic,
selected_topics: this.props.selectedTopics,
is_list_card: this.props.isListCard,
...(this.props.format ? { format: this.props.format } : {}),
...(this.props.section
? {
section: this.props.section,
section_position: this.props.sectionPosition,
is_secton_followed: this.props.sectionFollowed,
}
: {}),
},
],
})
);
}
}
}
onThumbsUpClick(event) {
event.stopPropagation();
event.preventDefault();
// Toggle active state for thumbs up button to show CSS animation
const currentState = this.state.isThumbsUpActive;
// If thumbs up has been clicked already, do nothing.
if (currentState) {
return;
}
this.setState({ isThumbsUpActive: !currentState });
// Record thumbs up telemetry event
this.props.dispatch(
ac.DiscoveryStreamUserEvent({
event: "POCKET_THUMBS_UP",
source: "THUMBS_UI",
value: {
recommendation_id: this.props.recommendation_id,
tile_id: this.props.id,
corpus_item_id: this.props.corpus_item_id,
scheduled_corpus_item_id: this.props.scheduled_corpus_item_id,
recommended_at: this.props.recommended_at,
received_rank: this.props.received_rank,
thumbs_up: true,
thumbs_down: false,
topic: this.props.topic,
...(this.props.section
? {
section: this.props.section,
section_position: this.props.sectionPosition,
is_secton_followed: this.props.sectionFollowed,
}
: {}),
},
})
);
// Show Toast
this.props.dispatch(
ac.OnlyToOneContent(
{
type: at.SHOW_TOAST_MESSAGE,
data: {
showNotifications: true,
toastId: "thumbsUpToast",
},
},
"ActivityStream:Content"
)
);
}
onThumbsDownClick(event) {
event.stopPropagation();
event.preventDefault();
// Toggle active state for thumbs down button to show CSS animation
const currentState = this.state.isThumbsDownActive;
this.setState({ isThumbsDownActive: !currentState });
// Run dismiss event after 0.5 second delay
if (
this.props.dispatch &&
this.props.type &&
this.props.id &&
this.props.url
) {
const index = this.props.pos;
const source = this.props.type.toUpperCase();
const spocData = {
url: this.props.url,
guid: this.props.id,
type: "CardGrid",
card_type: "organic",
recommendation_id: this.props.recommendation_id,
tile_id: this.props.id,
corpus_item_id: this.props.corpus_item_id,
scheduled_corpus_item_id: this.props.scheduled_corpus_item_id,
recommended_at: this.props.recommended_at,
received_rank: this.props.received_rank,
};
const blockUrlOption = LinkMenuOptions.BlockUrl(spocData, index, source);
const { action, impression, userEvent } = blockUrlOption;
setTimeout(() => {
this.props.dispatch(action);
this.props.dispatch(
ac.DiscoveryStreamUserEvent({
event: userEvent,
source,
action_position: index,
})
);
}, 500);
if (impression) {
this.props.dispatch(impression);
}
// Record thumbs down telemetry event
this.props.dispatch(
ac.DiscoveryStreamUserEvent({
event: "POCKET_THUMBS_DOWN",
source: "THUMBS_UI",
value: {
recommendation_id: this.props.recommendation_id,
tile_id: this.props.id,
corpus_item_id: this.props.corpus_item_id,
scheduled_corpus_item_id: this.props.scheduled_corpus_item_id,
recommended_at: this.props.recommended_at,
received_rank: this.props.received_rank,
thumbs_up: false,
thumbs_down: true,
topic: this.props.topic,
...(this.props.section
? {
section: this.props.section,
section_position: this.props.sectionPosition,
is_secton_followed: this.props.sectionFollowed,
}
: {}),
},
})
);
// Show Toast
this.props.dispatch(
ac.OnlyToOneContent(
{
type: at.SHOW_TOAST_MESSAGE,
data: {
showNotifications: true,
toastId: "thumbsDownToast",
},
},
"ActivityStream:Content"
)
);
}
}
onMenuUpdate(showContextMenu) {
if (!showContextMenu) {
const dsLinkMenuHostDiv = this.contextMenuButtonHostElement;
if (dsLinkMenuHostDiv) {
dsLinkMenuHostDiv.classList.remove("active", "last-item");
}
}
}
async onMenuShow() {
const dsLinkMenuHostDiv = this.contextMenuButtonHostElement;
if (dsLinkMenuHostDiv) {
// Force translation so we can be sure it's ready before measuring.
await this.props.windowObj.document.l10n.translateFragment(
dsLinkMenuHostDiv
);
if (this.props.windowObj.scrollMaxX > 0) {
dsLinkMenuHostDiv.classList.add("last-item");
}
dsLinkMenuHostDiv.classList.add("active");
}
}
onSeen(entries) {
if (this.state) {
const entry = entries.find(e => e.isIntersecting);
if (entry) {
if (this.placeholderElement) {
this.observer.unobserve(this.placeholderElement);
}
// Stop observing since element has been seen
this.setState({
isSeen: true,
});
}
}
}
onIdleCallback() {
if (!this.state.isSeen) {
if (this.observer && this.placeholderElement) {
this.observer.unobserve(this.placeholderElement);
}
this.setState({
isSeen: true,
});
}
}
componentDidMount() {
this.idleCallbackId = this.props.windowObj.requestIdleCallback(
this.onIdleCallback.bind(this)
);
if (this.placeholderElement) {
this.observer = new IntersectionObserver(this.onSeen.bind(this));
this.observer.observe(this.placeholderElement);
}
}
componentWillUnmount() {
// Remove observer on unmount
if (this.observer && this.placeholderElement) {
this.observer.unobserve(this.placeholderElement);
}
if (this.idleCallbackId) {
this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
}
}
render() {
const {
isRecentSave,
DiscoveryStream,
Prefs,
saveToPocketCard,
isListCard,
isFakespot,
mayHaveSectionsCards,
format,
alt_text,
} = this.props;
if (this.props.placeholder || !this.state.isSeen) {
// placeholder-seen is used to ensure the loading animation is only used if the card is visible.
const placeholderClassName = this.state.isSeen ? `placeholder-seen` : ``;
return (
<div
className={`ds-card placeholder ${placeholderClassName} ${
isListCard ? "list-card-placeholder" : ""
}`}
ref={this.setPlaceholderRef}
>
<div className="placeholder-image placeholder-fill" />
<div className="placeholder-label placeholder-fill" />
<div className="placeholder-header placeholder-fill" />
<div className="placeholder-description placeholder-fill" />
</div>
);
}
let source = this.props.source || this.props.publisher;
if (!source) {
try {
source = new URL(this.props.url).hostname;
} catch (e) {}
}
const {
pocketButtonEnabled,
hideDescriptions,
compactImages,
imageGradient,
newSponsoredLabel,
titleLines = 3,
descLines = 3,
readTime: displayReadTime,
} = DiscoveryStream;
const layoutsVariantAEnabled = Prefs.values["newtabLayouts.variant-a"];
const layoutsVariantBEnabled = Prefs.values["newtabLayouts.variant-b"];
const sectionsEnabled = Prefs.values["discoverystream.sections.enabled"];
const layoutsVariantAorB = layoutsVariantAEnabled || layoutsVariantBEnabled;
const smartCrop = Prefs.values["images.smart"];
const faviconEnabled =
Prefs.values["discoverystream.publisherFavicon.enabled"];
const excerpt = !hideDescriptions ? this.props.excerpt : "";
let timeToRead;
if (displayReadTime) {
timeToRead =
this.props.time_to_read || readTimeFromWordCount(this.props.word_count);
}
const ctaButtonEnabled = this.props.ctaButtonSponsors?.includes(
this.props.sponsor?.toLowerCase()
);
let ctaButtonVariant = "";
if (ctaButtonEnabled) {
ctaButtonVariant = this.props.ctaButtonVariant;
}
let ctaButtonVariantClassName = ctaButtonVariant;
const ctaButtonClassName = ctaButtonEnabled ? `ds-card-cta-button` : ``;
const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``;
const imageGradientClassName = imageGradient
? `ds-card-image-gradient`
: ``;
const listCardClassName = isListCard ? `list-feed-card` : ``;
const fakespotClassName = isFakespot ? `fakespot` : ``;
const sectionsCardsClassName = [
mayHaveSectionsCards ? `sections-card-ui` : ``,
this.props.sectionsClassNames,
].join(" ");
const sectionsCardsImageSizes = this.props.sectionsCardImageSizes;
const titleLinesName = `ds-card-title-lines-${titleLines}`;
const descLinesClassName = `ds-card-desc-lines-${descLines}`;
const isMediumRectangle = format === "rectangle";
const spocFormatClassName = isMediumRectangle ? `ds-spoc-rectangle` : ``;
let sizes = [];
if (!isMediumRectangle) {
sizes = this.dsImageSizes;
if (sectionsEnabled) {
sizes = [
this.getSectionImageSize("4", sectionsCardsImageSizes["4"]),
this.getSectionImageSize("3", sectionsCardsImageSizes["3"]),
this.getSectionImageSize("2", sectionsCardsImageSizes["2"]),
this.getSectionImageSize("1", sectionsCardsImageSizes["1"]),
];
} else if (layoutsVariantAorB) {
sizes = this.standardCardImageSizes;
}
if (isListCard) {
sizes = this.listCardImageSizes;
}
}
return (
<article
className={`ds-card ${listCardClassName} ${fakespotClassName} ${sectionsCardsClassName} ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName} ${spocFormatClassName} ${ctaButtonClassName} ${ctaButtonVariantClassName}`}
ref={this.setContextMenuButtonHostRef}
data-position-one={this.props["data-position-one"]}
data-position-two={this.props["data-position-one"]}
data-position-three={this.props["data-position-one"]}
data-position-four={this.props["data-position-one"]}
>
<SafeAnchor
className="ds-card-link"
dispatch={this.props.dispatch}
onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined}
url={this.props.url}
title={this.props.title}
>
{this.props.showTopics &&
!this.props.mayHaveSectionsCards &&
this.props.topic &&
!isListCard && (
<span
className="ds-card-topic"
data-l10n-id={`newtab-topic-label-${this.props.topic}`}
/>
)}
<div className="img-wrapper">
<DSImage
extraClassNames="img"
source={this.props.image_src}
rawSource={this.props.raw_image_src}
sizes={sizes}
url={this.props.url}
title={this.props.title}
isRecentSave={isRecentSave}
alt_text={alt_text}
smartCrop={smartCrop}
/>
</div>
<ImpressionStats
flightId={this.props.flightId}
rows={[
{
id: this.props.id,
pos: this.props.pos,
...(this.props.shim && this.props.shim.impression
? { shim: this.props.shim.impression }
: {}),
recommendation_id: this.props.recommendation_id,
fetchTimestamp: this.props.fetchTimestamp,
corpus_item_id: this.props.corpus_item_id,
scheduled_corpus_item_id: this.props.scheduled_corpus_item_id,
recommended_at: this.props.recommended_at,
received_rank: this.props.received_rank,
topic: this.props.topic,
is_list_card: isListCard,
...(format ? { format } : {}),
isFakespot,
category: this.props.category,
...(this.props.section
? {
section: this.props.section,
section_position: this.props.sectionPosition,
is_secton_followed: this.props.sectionFollowed,
}
: {}),
},
]}
dispatch={this.props.dispatch}
isFakespot={isFakespot}
source={this.props.type}
firstVisibleTimestamp={this.props.firstVisibleTimestamp}
/>
{ctaButtonVariant === "variant-b" && (
<div className="cta-header">Shop Now</div>
)}
{isFakespot ? (
<div className="meta">
<div className="info-wrap">
<h3 className="title clamp">{this.props.title}</h3>
</div>
</div>
) : (
<DefaultMeta
source={source}
title={this.props.title}
excerpt={excerpt}
newSponsoredLabel={newSponsoredLabel}
timeToRead={timeToRead}
context={this.props.context}
context_type={this.props.context_type}
sponsor={this.props.sponsor}
sponsored_by_override={this.props.sponsored_by_override}
saveToPocketCard={saveToPocketCard}
ctaButtonVariant={ctaButtonVariant}
dispatch={this.props.dispatch}
spocMessageVariant={this.props.spocMessageVariant}
mayHaveThumbsUpDown={this.props.mayHaveThumbsUpDown}
mayHaveSectionsCards={this.props.mayHaveSectionsCards}
onThumbsUpClick={this.onThumbsUpClick}
onThumbsDownClick={this.onThumbsDownClick}
state={this.state}
isListCard={isListCard}
showTopics={this.props.showTopics}
isSectionsCard={
this.props.mayHaveSectionsCards &&
this.props.topic &&
!isListCard
}
format={format}
topic={this.props.topic}
icon_src={faviconEnabled && this.props.icon_src}
/>
)}
</SafeAnchor>
<div className="card-stp-button-hover-background">
<div className="card-stp-button-position-wrapper">
{!isFakespot && (
<DSLinkMenu
id={this.props.id}
index={this.props.pos}
dispatch={this.props.dispatch}
url={this.props.url}
title={this.props.title}
source={source}
type={this.props.type}
card_type={this.props.flightId ? "spoc" : "organic"}
pocket_id={this.props.pocket_id}
shim={this.props.shim}
bookmarkGuid={this.props.bookmarkGuid}
flightId={
!this.props.is_collection ? this.props.flightId : undefined
}
showPrivacyInfo={!!this.props.flightId}
onMenuUpdate={this.onMenuUpdate}
onMenuShow={this.onMenuShow}
saveToPocketCard={saveToPocketCard}
pocket_button_enabled={pocketButtonEnabled}
isRecentSave={isRecentSave}
recommendation_id={this.props.recommendation_id}
tile_id={this.props.id}
block_key={this.props.id}
corpus_item_id={this.props.corpus_item_id}
scheduled_corpus_item_id={this.props.scheduled_corpus_item_id}
recommended_at={this.props.recommended_at}
received_rank={this.props.received_rank}
is_list_card={this.props.isListCard}
section={this.props.section}
section_position={this.props.sectionPosition}
is_secton_followed={this.props.sectionFollowed}
format={format}
isSectionsCard={this.props.mayHaveSectionsCards}
/>
)}
</div>
</div>
</article>
);
}
}
_DSCard.defaultProps = {
windowObj: window, // Added to support unit tests
};
export const DSCard = connect(state => ({
App: state.App,
DiscoveryStream: state.DiscoveryStream,
Prefs: state.Prefs,
}))(_DSCard);
export const PlaceholderDSCard = () => <DSCard placeholder={true} />;