/* 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 { SafeAnchor } from "../SafeAnchor/SafeAnchor";
import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
import {
actionCreators as ac,
actionTypes as at,
} from "common/Actions.sys.mjs";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { connect, useSelector } from "react-redux";
const PREF_ONBOARDING_EXPERIENCE_DISMISSED =
"discoverystream.onboardingExperience.dismissed";
const INTERSECTION_RATIO = 0.5;
const VISIBLE = "visible";
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
const WIDGET_IDS = {
TOPICS: 1,
};
export function DSSubHeader({ children }) {
return (
{children}
);
}
export function OnboardingExperience({
children,
dispatch,
windowObj = global,
}) {
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 (
);
}
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 {children}
;
}
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 (
);
}
// 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 (
);
}
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();
} 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 (
<>
{recentSavesCards}
>
);
}
export class _CardGrid extends React.PureComponent {
renderCards() {
const prefs = this.props.Prefs.values;
const {
items,
hybridLayout,
hideCardBackground,
fourCardLayout,
compactGrid,
essentialReadsHeader,
editorsPicksHeader,
onboardingExperience,
widgets,
recentSavesEnabled,
hideDescriptions,
DiscoveryStream,
} = this.props;
const { saveToPocketCard } = DiscoveryStream;
const showRecentSaves = prefs.showRecentSaves && recentSavesEnabled;
const isOnboardingExperienceDismissed =
prefs[PREF_ONBOARDING_EXPERIENCE_DISMISSED];
const recs = this.props.data.recommendations.slice(0, items);
const cards = [];
let essentialReadsCards = [];
let editorsPicksCards = [];
for (let index = 0; index < items; index++) {
const rec = recs[index];
cards.push(
!rec || rec.placeholder ? (
) : (
)
);
}
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 = (
);
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);
}
}
}
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 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 (
<>
{!isOnboardingExperienceDismissed && onboardingExperience && (
)}
{essentialReadsCards?.length > 0 && (
{essentialReadsCards}
)}
{showRecentSaves && (
)}
{editorsPicksCards?.length > 0 && (
<>
{editorsPicksCards}
>
)}
{cards?.length > 0 && (
<>
{moreRecsHeader && (
)}
{cards}
>
)}
>
);
}
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 (
{this.props.title && (
{this.props.title}
{this.props.context && (
)}
)}
{isEmpty ? (
) : (
this.renderCards()
)}
);
}
}
_CardGrid.defaultProps = {
items: 4, // Number of stories to display
};
export const CardGrid = connect(state => ({
Prefs: state.Prefs,
DiscoveryStream: state.DiscoveryStream,
}))(_CardGrid);