704 lines
22 KiB
JavaScript
704 lines
22 KiB
JavaScript
/* 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,
|
||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||
|
||
import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx";
|
||
import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
|
||
import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
|
||
import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx";
|
||
import { ListFeed } from "../ListFeed/ListFeed.jsx";
|
||
import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
|
||
import { AdBanner } from "../AdBanner/AdBanner.jsx";
|
||
import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
|
||
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
|
||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||
import { connect, useSelector } from "react-redux";
|
||
const PREF_ONBOARDING_EXPERIENCE_DISMISSED =
|
||
"discoverystream.onboardingExperience.dismissed";
|
||
const PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled";
|
||
const PREF_THUMBS_UP_DOWN_ENABLED = "discoverystream.thumbsUpDown.enabled";
|
||
const PREF_TOPICS_ENABLED = "discoverystream.topicLabels.enabled";
|
||
const PREF_TOPICS_SELECTED = "discoverystream.topicSelection.selectedTopics";
|
||
const PREF_TOPICS_AVAILABLE = "discoverystream.topicSelection.topics";
|
||
const PREF_SPOCS_STARTUPCACHE_ENABLED =
|
||
"discoverystream.spocs.startupCache.enabled";
|
||
const PREF_LIST_FEED_ENABLED = "discoverystream.contextualContent.enabled";
|
||
const PREF_LIST_FEED_SELECTED_FEED =
|
||
"discoverystream.contextualContent.selectedFeed";
|
||
const PREF_FAKESPOT_ENABLED =
|
||
"discoverystream.contextualContent.fakespot.enabled";
|
||
const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard";
|
||
const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard";
|
||
const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position";
|
||
const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position";
|
||
const INTERSECTION_RATIO = 0.5;
|
||
const VISIBLE = "visible";
|
||
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
|
||
const WIDGET_IDS = {
|
||
TOPICS: 1,
|
||
};
|
||
|
||
export function DSSubHeader({ children }) {
|
||
return (
|
||
<div className="section-top-bar ds-sub-header">
|
||
<h3 className="section-title-container">{children}</h3>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OnboardingExperience({ dispatch, windowObj = globalThis }) {
|
||
const [dismissed, setDismissed] = useState(false);
|
||
const [maxHeight, setMaxHeight] = useState(null);
|
||
const heightElement = useRef(null);
|
||
|
||
const onDismissClick = useCallback(() => {
|
||
// We update this as state and redux.
|
||
// The state update is for this newtab,
|
||
// and the redux update is for other tabs, offscreen tabs, and future tabs.
|
||
// We need the state update for this tab to support the transition.
|
||
setDismissed(true);
|
||
dispatch(ac.SetPref(PREF_ONBOARDING_EXPERIENCE_DISMISSED, true));
|
||
dispatch(
|
||
ac.DiscoveryStreamUserEvent({
|
||
event: "BLOCK",
|
||
source: "POCKET_ONBOARDING",
|
||
})
|
||
);
|
||
}, [dispatch]);
|
||
|
||
useEffect(() => {
|
||
const resizeObserver = new windowObj.ResizeObserver(() => {
|
||
if (heightElement.current) {
|
||
setMaxHeight(heightElement.current.offsetHeight);
|
||
}
|
||
});
|
||
|
||
const options = { threshold: INTERSECTION_RATIO };
|
||
const intersectionObserver = new windowObj.IntersectionObserver(entries => {
|
||
if (
|
||
entries.some(
|
||
entry =>
|
||
entry.isIntersecting &&
|
||
entry.intersectionRatio >= INTERSECTION_RATIO
|
||
)
|
||
) {
|
||
dispatch(
|
||
ac.DiscoveryStreamUserEvent({
|
||
event: "IMPRESSION",
|
||
source: "POCKET_ONBOARDING",
|
||
})
|
||
);
|
||
// Once we have observed an impression, we can stop for this instance of newtab.
|
||
intersectionObserver.unobserve(heightElement.current);
|
||
}
|
||
}, options);
|
||
|
||
const onVisibilityChange = () => {
|
||
intersectionObserver.observe(heightElement.current);
|
||
windowObj.document.removeEventListener(
|
||
VISIBILITY_CHANGE_EVENT,
|
||
onVisibilityChange
|
||
);
|
||
};
|
||
|
||
if (heightElement.current) {
|
||
resizeObserver.observe(heightElement.current);
|
||
// Check visibility or setup a visibility event to make
|
||
// sure we don't fire this for off screen pre loaded tabs.
|
||
if (windowObj.document.visibilityState === VISIBLE) {
|
||
intersectionObserver.observe(heightElement.current);
|
||
} else {
|
||
windowObj.document.addEventListener(
|
||
VISIBILITY_CHANGE_EVENT,
|
||
onVisibilityChange
|
||
);
|
||
}
|
||
setMaxHeight(heightElement.current.offsetHeight);
|
||
}
|
||
|
||
// Return unmount callback to clean up observers.
|
||
return () => {
|
||
resizeObserver?.disconnect();
|
||
intersectionObserver?.disconnect();
|
||
windowObj.document.removeEventListener(
|
||
VISIBILITY_CHANGE_EVENT,
|
||
onVisibilityChange
|
||
);
|
||
};
|
||
}, [dispatch, windowObj]);
|
||
|
||
const style = {};
|
||
if (dismissed) {
|
||
style.maxHeight = "0";
|
||
style.opacity = "0";
|
||
style.transition = "max-height 0.26s ease, opacity 0.26s ease";
|
||
} else if (maxHeight) {
|
||
style.maxHeight = `${maxHeight}px`;
|
||
}
|
||
|
||
return (
|
||
<div style={style}>
|
||
<div className="ds-onboarding-ref" ref={heightElement}>
|
||
<div className="ds-onboarding-container">
|
||
<DSDismiss
|
||
onDismissClick={onDismissClick}
|
||
extraClasses={`ds-onboarding`}
|
||
>
|
||
<div>
|
||
<header>
|
||
<span className="icon icon-pocket" />
|
||
<span data-l10n-id="newtab-pocket-onboarding-discover" />
|
||
</header>
|
||
<p data-l10n-id="newtab-pocket-onboarding-cta" />
|
||
</div>
|
||
<div className="ds-onboarding-graphic" />
|
||
</DSDismiss>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// eslint-disable-next-line no-shadow
|
||
export function IntersectionObserver({
|
||
children,
|
||
windowObj = window,
|
||
onIntersecting,
|
||
}) {
|
||
const intersectionElement = useRef(null);
|
||
|
||
useEffect(() => {
|
||
let observer;
|
||
if (!observer && onIntersecting && intersectionElement.current) {
|
||
observer = new windowObj.IntersectionObserver(entries => {
|
||
const entry = entries.find(e => e.isIntersecting);
|
||
|
||
if (entry) {
|
||
// Stop observing since element has been seen
|
||
if (observer && intersectionElement.current) {
|
||
observer.unobserve(intersectionElement.current);
|
||
}
|
||
|
||
onIntersecting();
|
||
}
|
||
});
|
||
observer.observe(intersectionElement.current);
|
||
}
|
||
// Cleanup
|
||
return () => observer?.disconnect();
|
||
}, [windowObj, onIntersecting]);
|
||
|
||
return <div ref={intersectionElement}>{children}</div>;
|
||
}
|
||
|
||
export function RecentSavesContainer({
|
||
gridClassName = "",
|
||
dispatch,
|
||
windowObj = window,
|
||
items = 3,
|
||
source = "CARDGRID_RECENT_SAVES",
|
||
}) {
|
||
const {
|
||
recentSavesData,
|
||
isUserLoggedIn,
|
||
experimentData: { utmCampaign, utmContent, utmSource },
|
||
} = useSelector(state => state.DiscoveryStream);
|
||
|
||
const [visible, setVisible] = useState(false);
|
||
const onIntersecting = useCallback(() => setVisible(true), []);
|
||
|
||
useEffect(() => {
|
||
if (visible) {
|
||
dispatch(
|
||
ac.AlsoToMain({
|
||
type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
|
||
})
|
||
);
|
||
}
|
||
}, [visible, dispatch]);
|
||
|
||
// The user has not yet scrolled to this section,
|
||
// so wait before potentially requesting Pocket data.
|
||
if (!visible) {
|
||
return (
|
||
<IntersectionObserver
|
||
windowObj={windowObj}
|
||
onIntersecting={onIntersecting}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Intersection observer has finished, but we're not yet logged in.
|
||
if (visible && !isUserLoggedIn) {
|
||
return null;
|
||
}
|
||
|
||
let queryParams = `?utm_source=${utmSource}`;
|
||
// We really only need to add these params to urls we own.
|
||
if (utmCampaign && utmContent) {
|
||
queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`;
|
||
}
|
||
|
||
function renderCard(rec, index) {
|
||
const url = new URL(rec.url);
|
||
const urlSearchParams = new URLSearchParams(queryParams);
|
||
if (rec?.id && !url.href.match(/getpocket\.com\/read/)) {
|
||
url.href = `https://getpocket.com/read/${rec.id}`;
|
||
}
|
||
|
||
for (let [key, val] of urlSearchParams.entries()) {
|
||
url.searchParams.set(key, val);
|
||
}
|
||
|
||
return (
|
||
<DSCard
|
||
key={`dscard-${rec?.id || index}`}
|
||
id={rec.id}
|
||
pos={index}
|
||
type={source}
|
||
image_src={rec.image_src}
|
||
raw_image_src={rec.raw_image_src}
|
||
icon_src={rec.icon_src}
|
||
word_count={rec.word_count}
|
||
time_to_read={rec.time_to_read}
|
||
title={rec.title}
|
||
excerpt={rec.excerpt}
|
||
url={url.href}
|
||
source={rec.domain}
|
||
isRecentSave={true}
|
||
dispatch={dispatch}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function onMyListClicked() {
|
||
dispatch(
|
||
ac.DiscoveryStreamUserEvent({
|
||
event: "CLICK",
|
||
source: `${source}_VIEW_LIST`,
|
||
})
|
||
);
|
||
}
|
||
|
||
const recentSavesCards = [];
|
||
// We fill the cards with a for loop over an inline map because
|
||
// we want empty placeholders if there are not enough cards.
|
||
for (let index = 0; index < items; index++) {
|
||
const recentSave = recentSavesData[index];
|
||
if (!recentSave) {
|
||
recentSavesCards.push(<PlaceholderDSCard key={`dscard-${index}`} />);
|
||
} else {
|
||
recentSavesCards.push(
|
||
renderCard(
|
||
{
|
||
id: recentSave.id,
|
||
image_src: recentSave.top_image_url,
|
||
raw_image_src: recentSave.top_image_url,
|
||
word_count: recentSave.word_count,
|
||
time_to_read: recentSave.time_to_read,
|
||
title: recentSave.resolved_title || recentSave.given_title,
|
||
url: recentSave.resolved_url || recentSave.given_url,
|
||
domain: recentSave.domain_metadata?.name,
|
||
excerpt: recentSave.excerpt,
|
||
},
|
||
index
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
// We are visible and logged in.
|
||
return (
|
||
<>
|
||
<DSSubHeader>
|
||
<span className="section-title">
|
||
<FluentOrText message="Recently Saved to your List" />
|
||
</span>
|
||
<SafeAnchor
|
||
onLinkClick={onMyListClicked}
|
||
className="section-sub-link"
|
||
url={`https://getpocket.com/a${queryParams}`}
|
||
>
|
||
<FluentOrText message="View My List" />
|
||
</SafeAnchor>
|
||
</DSSubHeader>
|
||
<div className={`ds-card-grid-recent-saves ${gridClassName}`}>
|
||
{recentSavesCards}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export class _CardGrid extends React.PureComponent {
|
||
// eslint-disable-next-line max-statements
|
||
renderCards() {
|
||
const prefs = this.props.Prefs.values;
|
||
const {
|
||
items,
|
||
fourCardLayout,
|
||
essentialReadsHeader,
|
||
editorsPicksHeader,
|
||
onboardingExperience,
|
||
ctaButtonSponsors,
|
||
ctaButtonVariant,
|
||
spocMessageVariant,
|
||
widgets,
|
||
recentSavesEnabled,
|
||
DiscoveryStream,
|
||
} = this.props;
|
||
|
||
const { saveToPocketCard, topicsLoading } = DiscoveryStream;
|
||
const showRecentSaves = prefs.showRecentSaves && recentSavesEnabled;
|
||
const isOnboardingExperienceDismissed =
|
||
prefs[PREF_ONBOARDING_EXPERIENCE_DISMISSED];
|
||
const mayHaveSectionsCards = prefs[PREF_SECTIONS_CARDS_ENABLED];
|
||
const mayHaveThumbsUpDown = prefs[PREF_THUMBS_UP_DOWN_ENABLED];
|
||
const showTopics = prefs[PREF_TOPICS_ENABLED];
|
||
const selectedTopics = prefs[PREF_TOPICS_SELECTED];
|
||
const availableTopics = prefs[PREF_TOPICS_AVAILABLE];
|
||
const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED];
|
||
const listFeedEnabled = prefs[PREF_LIST_FEED_ENABLED];
|
||
const listFeedSelectedFeed = prefs[PREF_LIST_FEED_SELECTED_FEED];
|
||
const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED];
|
||
const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED];
|
||
|
||
// filter out recs that should be in ListFeed
|
||
const recs = this.props.data.recommendations
|
||
.filter(item => !item.feedName)
|
||
.slice(0, items);
|
||
const cards = [];
|
||
|
||
let essentialReadsCards = [];
|
||
let editorsPicksCards = [];
|
||
|
||
for (let index = 0; index < items; index++) {
|
||
const rec = recs[index];
|
||
cards.push(
|
||
topicsLoading ||
|
||
!rec ||
|
||
rec.placeholder ||
|
||
(rec.flight_id &&
|
||
!spocsStartupCacheEnabled &&
|
||
this.props.App.isForStartupCache.App) ? (
|
||
<PlaceholderDSCard key={`dscard-${index}`} />
|
||
) : (
|
||
<DSCard
|
||
key={`dscard-${rec.id}`}
|
||
pos={rec.pos}
|
||
flightId={rec.flight_id}
|
||
image_src={rec.image_src}
|
||
raw_image_src={rec.raw_image_src}
|
||
icon_src={rec.icon_src}
|
||
word_count={rec.word_count}
|
||
time_to_read={rec.time_to_read}
|
||
title={rec.title}
|
||
topic={rec.topic}
|
||
features={rec.features}
|
||
showTopics={showTopics}
|
||
selectedTopics={selectedTopics}
|
||
excerpt={rec.excerpt}
|
||
availableTopics={availableTopics}
|
||
url={rec.url}
|
||
id={rec.id}
|
||
shim={rec.shim}
|
||
fetchTimestamp={rec.fetchTimestamp}
|
||
type={this.props.type}
|
||
context={rec.context}
|
||
sponsor={rec.sponsor}
|
||
sponsored_by_override={rec.sponsored_by_override}
|
||
dispatch={this.props.dispatch}
|
||
source={rec.domain}
|
||
publisher={rec.publisher}
|
||
pocket_id={rec.pocket_id}
|
||
context_type={rec.context_type}
|
||
bookmarkGuid={rec.bookmarkGuid}
|
||
is_collection={this.props.is_collection}
|
||
saveToPocketCard={saveToPocketCard}
|
||
ctaButtonSponsors={ctaButtonSponsors}
|
||
ctaButtonVariant={ctaButtonVariant}
|
||
spocMessageVariant={spocMessageVariant}
|
||
recommendation_id={rec.recommendation_id}
|
||
firstVisibleTimestamp={this.props.firstVisibleTimestamp}
|
||
mayHaveThumbsUpDown={mayHaveThumbsUpDown}
|
||
mayHaveSectionsCards={mayHaveSectionsCards}
|
||
corpus_item_id={rec.corpus_item_id}
|
||
scheduled_corpus_item_id={rec.scheduled_corpus_item_id}
|
||
recommended_at={rec.recommended_at}
|
||
received_rank={rec.received_rank}
|
||
format={rec.format}
|
||
alt_text={rec.alt_text}
|
||
isTimeSensitive={rec.isTimeSensitive}
|
||
/>
|
||
)
|
||
);
|
||
}
|
||
|
||
if (widgets?.positions?.length && widgets?.data?.length) {
|
||
let positionIndex = 0;
|
||
const source = "CARDGRID_WIDGET";
|
||
|
||
for (const widget of widgets.data) {
|
||
let widgetComponent = null;
|
||
const position = widgets.positions[positionIndex];
|
||
|
||
// Stop if we run out of positions to place widgets.
|
||
if (!position) {
|
||
break;
|
||
}
|
||
|
||
switch (widget?.type) {
|
||
case "TopicsWidget":
|
||
widgetComponent = (
|
||
<TopicsWidget
|
||
position={position.index}
|
||
dispatch={this.props.dispatch}
|
||
source={source}
|
||
id={WIDGET_IDS.TOPICS}
|
||
/>
|
||
);
|
||
break;
|
||
}
|
||
|
||
if (widgetComponent) {
|
||
// We found a widget, so up the position for next try.
|
||
positionIndex++;
|
||
// We replace an existing card with the widget.
|
||
cards.splice(position.index, 1, widgetComponent);
|
||
}
|
||
}
|
||
}
|
||
if (listFeedEnabled) {
|
||
const isFakespot = listFeedSelectedFeed === "fakespot";
|
||
const fakespotEnabled = prefs[PREF_FAKESPOT_ENABLED];
|
||
if (!isFakespot || (isFakespot && fakespotEnabled)) {
|
||
// Place the list feed as the 3rd element in the card grid
|
||
cards.splice(
|
||
2,
|
||
1,
|
||
this.renderListFeed(
|
||
this.props.data.recommendations,
|
||
listFeedSelectedFeed
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
// if a banner ad is enabled and we have any available, place them in the grid
|
||
const { spocs } = this.props.DiscoveryStream;
|
||
|
||
if (
|
||
(billboardEnabled || leaderboardEnabled) &&
|
||
spocs?.data?.newtab_spocs?.items
|
||
) {
|
||
// Only render one AdBanner in the grid -
|
||
// Prioritize rendering a leaderboard if it exists,
|
||
// otherwise render a billboard
|
||
const spocToRender =
|
||
spocs.data.newtab_spocs.items.find(
|
||
({ format }) => format === "leaderboard" && leaderboardEnabled
|
||
) ||
|
||
spocs.data.newtab_spocs.items.find(
|
||
({ format }) => format === "billboard" && billboardEnabled
|
||
);
|
||
|
||
if (spocToRender && !spocs.blocked.includes(spocToRender.url)) {
|
||
const row =
|
||
spocToRender.format === "leaderboard"
|
||
? prefs[PREF_LEADERBOARD_POSITION]
|
||
: prefs[PREF_BILLBOARD_POSITION];
|
||
|
||
function displayCardsPerRow() {
|
||
// Determines the number of cards per row based on the window width:
|
||
// width <= 1122px: 2 cards per row
|
||
// width 1123px to 1697px: 3 cards per row
|
||
// width >= 1698px: 4 cards per row
|
||
if (window.innerWidth <= 1122) {
|
||
return 2;
|
||
} else if (window.innerWidth > 1122 && window.innerWidth < 1698) {
|
||
return 3;
|
||
}
|
||
return 4;
|
||
}
|
||
|
||
const injectAdBanner = bannerIndex => {
|
||
// .splice() inserts the AdBanner at the desired index, ensuring correct DOM order for accessibility and keyboard navigation.
|
||
// .push() would place it at the end, which is visually incorrect even if adjusted with CSS.
|
||
cards.splice(
|
||
bannerIndex,
|
||
0,
|
||
<AdBanner
|
||
spoc={spocToRender}
|
||
key={`dscard-${spocToRender.id}`}
|
||
dispatch={this.props.dispatch}
|
||
type={this.props.type}
|
||
firstVisibleTimestamp={this.props.firstVisibleTimestamp}
|
||
row={row}
|
||
prefs={prefs}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const getBannerIndex = () => {
|
||
// Calculate the index for where the AdBanner should be added, depending on number of cards per row on the grid
|
||
const cardsPerRow = displayCardsPerRow();
|
||
let bannerIndex = (row - 1) * cardsPerRow;
|
||
return bannerIndex;
|
||
};
|
||
|
||
injectAdBanner(getBannerIndex());
|
||
}
|
||
}
|
||
|
||
let moreRecsHeader = "";
|
||
// For now this is English only.
|
||
if (showRecentSaves || (essentialReadsHeader && editorsPicksHeader)) {
|
||
let spliceAt = 6;
|
||
// For 4 card row layouts, second row is 8 cards, and regular it is 6 cards.
|
||
if (fourCardLayout) {
|
||
spliceAt = 8;
|
||
}
|
||
// If we have a custom header, ensure the more recs section also has a header.
|
||
moreRecsHeader = "More Recommendations";
|
||
// Put the first 2 rows into essentialReadsCards.
|
||
essentialReadsCards = [...cards.splice(0, spliceAt)];
|
||
// Put the rest into editorsPicksCards.
|
||
if (essentialReadsHeader && editorsPicksHeader) {
|
||
editorsPicksCards = [...cards.splice(0, cards.length)];
|
||
}
|
||
}
|
||
|
||
const gridClassName = this.renderGridClassName();
|
||
|
||
return (
|
||
<>
|
||
{!isOnboardingExperienceDismissed && onboardingExperience && (
|
||
<OnboardingExperience dispatch={this.props.dispatch} />
|
||
)}
|
||
{essentialReadsCards?.length > 0 && (
|
||
<div className={gridClassName}>{essentialReadsCards}</div>
|
||
)}
|
||
{showRecentSaves && (
|
||
<RecentSavesContainer
|
||
gridClassName={gridClassName}
|
||
dispatch={this.props.dispatch}
|
||
/>
|
||
)}
|
||
{editorsPicksCards?.length > 0 && (
|
||
<>
|
||
<DSSubHeader>
|
||
<span className="section-title">
|
||
<FluentOrText message="Editor’s Picks" />
|
||
</span>
|
||
</DSSubHeader>
|
||
<div className={gridClassName}>{editorsPicksCards}</div>
|
||
</>
|
||
)}
|
||
{cards?.length > 0 && (
|
||
<>
|
||
{moreRecsHeader && (
|
||
<DSSubHeader>
|
||
<span className="section-title">
|
||
<FluentOrText message={moreRecsHeader} />
|
||
</span>
|
||
</DSSubHeader>
|
||
)}
|
||
<div className={gridClassName}>{cards}</div>
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
renderListFeed(recommendations, selectedFeed) {
|
||
const recs = recommendations.filter(item => item.feedName === selectedFeed);
|
||
const isFakespot = selectedFeed === "fakespot";
|
||
// remove duplicates from category list
|
||
const categories = [...new Set(recs.map(({ category }) => category))];
|
||
const listFeed = (
|
||
<ListFeed
|
||
// only display recs that match selectedFeed for ListFeed
|
||
recs={recs}
|
||
categories={isFakespot ? categories : []}
|
||
firstVisibleTimestamp={this.props.firstVisibleTimestamp}
|
||
type={this.props.type}
|
||
dispatch={this.props.dispatch}
|
||
/>
|
||
);
|
||
return listFeed;
|
||
}
|
||
|
||
renderGridClassName() {
|
||
const {
|
||
hybridLayout,
|
||
hideCardBackground,
|
||
fourCardLayout,
|
||
compactGrid,
|
||
hideDescriptions,
|
||
} = this.props;
|
||
|
||
const hideCardBackgroundClass = hideCardBackground
|
||
? `ds-card-grid-hide-background`
|
||
: ``;
|
||
const fourCardLayoutClass = fourCardLayout
|
||
? `ds-card-grid-four-card-variant`
|
||
: ``;
|
||
const hideDescriptionsClassName = !hideDescriptions
|
||
? `ds-card-grid-include-descriptions`
|
||
: ``;
|
||
const compactGridClassName = compactGrid ? `ds-card-grid-compact` : ``;
|
||
const hybridLayoutClassName = hybridLayout
|
||
? `ds-card-grid-hybrid-layout`
|
||
: ``;
|
||
|
||
const gridClassName = `ds-card-grid ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`;
|
||
return gridClassName;
|
||
}
|
||
|
||
render() {
|
||
const { data } = this.props;
|
||
|
||
// Handle a render before feed has been fetched by displaying nothing
|
||
if (!data) {
|
||
return null;
|
||
}
|
||
|
||
// Handle the case where a user has dismissed all recommendations
|
||
const isEmpty = data.recommendations.length === 0;
|
||
|
||
return (
|
||
<div>
|
||
{this.props.title && (
|
||
<div className="ds-header">
|
||
<div className="title">{this.props.title}</div>
|
||
{this.props.context && (
|
||
<FluentOrText message={this.props.context}>
|
||
<div className="ds-context" />
|
||
</FluentOrText>
|
||
)}
|
||
</div>
|
||
)}
|
||
{isEmpty ? (
|
||
<div className="ds-card-grid empty">
|
||
<DSEmptyState
|
||
status={data.status}
|
||
dispatch={this.props.dispatch}
|
||
feed={this.props.feed}
|
||
/>
|
||
</div>
|
||
) : (
|
||
this.renderCards()
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
_CardGrid.defaultProps = {
|
||
items: 4, // Number of stories to display
|
||
};
|
||
|
||
export const CardGrid = connect(state => ({
|
||
Prefs: state.Prefs,
|
||
App: state.App,
|
||
DiscoveryStream: state.DiscoveryStream,
|
||
}))(_CardGrid);
|