1
0
Fork 0
firefox/browser/extensions/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

704 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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="Editors 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);