From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../CardGrid/CardGrid.jsx | 536 +++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx (limited to 'browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx') diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx new file mode 100644 index 0000000000..09dc657cbb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -0,0 +1,536 @@ +/* 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); -- cgit v1.2.3