/* 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 { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx"; import { AdBanner } from "../AdBanner/AdBanner.jsx"; import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; import React, { useEffect, useRef } from "react"; import { connect } from "react-redux"; import { TrendingSearches } from "../TrendingSearches/TrendingSearches.jsx"; 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_BILLBOARD_ENABLED = "newtabAdSize.billboard"; const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position"; const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position"; const PREF_TRENDING_SEARCH = "trendingSearch.enabled"; const PREF_TRENDING_SEARCH_SYSTEM = "system.trendingSearch.enabled"; const PREF_SEARCH_ENGINE = "trendingSearch.defaultSearchEngine"; const PREF_TRENDING_SEARCH_VARIANT = "trendingSearch.variant"; const WIDGET_IDS = { TOPICS: 1, }; export function DSSubHeader({ children }) { return (

{children}

); } // 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
{children}
; } export class _CardGrid extends React.PureComponent { constructor(props) { super(props); this.state = { focusedIndex: 0, }; this.onCardFocus = this.onCardFocus.bind(this); this.handleCardKeyDown = this.handleCardKeyDown.bind(this); } onCardFocus(index) { this.setState({ focusedIndex: index }); } handleCardKeyDown(e) { if (e.key === "ArrowLeft" || e.key === "ArrowRight") { e.preventDefault(); const currentCardEl = e.target.closest("article.ds-card"); if (!currentCardEl) { return; } // Arrow direction should match visual navigation direction in RTL const isRTL = document.dir === "rtl"; const navigateToPrevious = isRTL ? e.key === "ArrowRight" : e.key === "ArrowLeft"; let targetCardEl = currentCardEl; // Walk through siblings to find the target card element while (targetCardEl) { targetCardEl = navigateToPrevious ? targetCardEl.previousElementSibling : targetCardEl.nextElementSibling; if (targetCardEl && targetCardEl.matches("article.ds-card")) { const link = targetCardEl.querySelector("a.ds-card-link"); if (link) { link.focus(); } break; } } } } // eslint-disable-next-line max-statements renderCards() { const prefs = this.props.Prefs.values; const { items, ctaButtonSponsors, ctaButtonVariant, widgets, DiscoveryStream, } = this.props; const { topicsLoading } = DiscoveryStream; 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 billboardEnabled = prefs[PREF_BILLBOARD_ENABLED]; const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED]; const trendingEnabled = prefs[PREF_TRENDING_SEARCH] && prefs[PREF_TRENDING_SEARCH_SYSTEM] && prefs[PREF_SEARCH_ENGINE]?.toLowerCase() === "google"; const trendingVariant = prefs[PREF_TRENDING_SEARCH_VARIANT]; const recs = this.props.data.recommendations.slice(0, items); const cards = []; let cardIndex = 0; for (let index = 0; index < items; index++) { const rec = recs[index]; const isPlaceholder = topicsLoading || this.props.placeholder || !rec || rec.placeholder || (rec.flight_id && !spocsStartupCacheEnabled && this.props.App.isForStartupCache.DiscoveryStream); if (isPlaceholder) { cards.push(); } else { const currentCardIndex = cardIndex; cardIndex++; cards.push( this.onCardFocus(currentCardIndex)} /> ); } } 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); } } } if (trendingEnabled && trendingVariant === "b") { const firstSpocPosition = this.props.spocPositions[0]?.index; // double check that a spoc/mrec is actually in the index it should be in const format = cards[firstSpocPosition]?.props?.format; const isSpoc = format === "spoc" || format === "rectangle"; // if the spoc is not in its position, place TrendingSearches in the 3rd position cards.splice(isSpoc ? firstSpocPosition + 1 : 2, 1, ); } // 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, ); }; 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()); } } const gridClassName = this.renderGridClassName(); return ( <> {cards?.length > 0 && (
{cards}
)} ); } 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 (
{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, App: state.App, DiscoveryStream: state.DiscoveryStream, }))(_CardGrid);