/* 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 https://mozilla.org/MPL/2.0/. */ import React, { useCallback } from "react"; import { DSEmptyState } from "../DSEmptyState/DSEmptyState"; import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard"; import { useSelector } from "react-redux"; import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { useIntersectionObserver } from "../../../lib/utils"; import { SectionContextMenu } from "../SectionContextMenu/SectionContextMenu"; import { InterestPicker } from "../InterestPicker/InterestPicker"; import { AdBanner } from "../AdBanner/AdBanner.jsx"; import { PersonalizedCard } from "../PersonalizedCard/PersonalizedCard"; import { MessageWrapper } from "content-src/components/MessageWrapper/MessageWrapper"; // Prefs const PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled"; const PREF_SECTIONS_CARDS_THUMBS_UP_DOWN_ENABLED = "discoverystream.sections.cards.thumbsUpDown.enabled"; const PREF_SECTIONS_PERSONALIZATION_ENABLED = "discoverystream.sections.personalization.enabled"; const PREF_TOPICS_ENABLED = "discoverystream.topicLabels.enabled"; const PREF_TOPICS_SELECTED = "discoverystream.topicSelection.selectedTopics"; const PREF_TOPICS_AVAILABLE = "discoverystream.topicSelection.topics"; const PREF_THUMBS_UP_DOWN_ENABLED = "discoverystream.thumbsUpDown.enabled"; const PREF_INTEREST_PICKER_ENABLED = "discoverystream.sections.interestPicker.enabled"; const PREF_VISIBLE_SECTIONS = "discoverystream.sections.interestPicker.visibleSections"; 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 PREF_REFINED_CARDS_ENABLED = "discoverystream.refinedCardsLayout.enabled"; const PREF_INFERRED_PERSONALIZATION_USER = "discoverystream.sections.personalization.inferred.user.enabled"; function getLayoutData(responsiveLayouts, index, refinedCardsLayout) { let layoutData = { classNames: [], imageSizes: {}, }; responsiveLayouts.forEach(layout => { layout.tiles.forEach((tile, tileIndex) => { if (tile.position === index) { layoutData.classNames.push(`col-${layout.columnCount}-${tile.size}`); layoutData.classNames.push( `col-${layout.columnCount}-position-${tileIndex}` ); layoutData.imageSizes[layout.columnCount] = tile.size; // The API tells us whether the tile should show the excerpt or not. // Apply extra styles accordingly. if (tile.hasExcerpt) { if (tile.size === "medium" && refinedCardsLayout) { layoutData.classNames.push( `col-${layout.columnCount}-hide-excerpt` ); } else { layoutData.classNames.push( `col-${layout.columnCount}-show-excerpt` ); } } else { layoutData.classNames.push(`col-${layout.columnCount}-hide-excerpt`); } } }); }); return layoutData; } // function to determine amount of tiles shown per section per viewport function getMaxTiles(responsiveLayouts) { return responsiveLayouts .flatMap(responsiveLayout => responsiveLayout) .reduce((acc, t) => { acc[t.columnCount] = t.tiles.length; // Update maxTile if current tile count is greater if (!acc.maxTile || t.tiles.length > acc.maxTile) { acc.maxTile = t.tiles.length; } return acc; }, {}); } /** * Transforms a comma-separated string in user preferences * into a cleaned-up array. * * @param {string} pref - The comma-separated pref to be converted. * @returns {string[]} An array of trimmed strings, excluding empty values. */ const prefToArray = (pref = "") => { return pref .split(",") .map(item => item.trim()) .filter(item => item); }; function CardSection({ sectionPosition, section, dispatch, type, firstVisibleTimestamp, is_collection, spocMessageVariant, ctaButtonVariant, ctaButtonSponsors, }) { const prefs = useSelector(state => state.Prefs.values); const { sectionPersonalization } = useSelector( state => state.DiscoveryStream ); const showTopics = prefs[PREF_TOPICS_ENABLED]; const mayHaveSectionsCards = prefs[PREF_SECTIONS_CARDS_ENABLED]; const mayHaveSectionsCardsThumbsUpDown = prefs[PREF_SECTIONS_CARDS_THUMBS_UP_DOWN_ENABLED]; const mayHaveThumbsUpDown = prefs[PREF_THUMBS_UP_DOWN_ENABLED]; const selectedTopics = prefs[PREF_TOPICS_SELECTED]; const availableTopics = prefs[PREF_TOPICS_AVAILABLE]; const refinedCardsLayout = prefs[PREF_REFINED_CARDS_ENABLED]; const { saveToPocketCard } = useSelector(state => state.DiscoveryStream); const mayHaveSectionsPersonalization = prefs[PREF_SECTIONS_PERSONALIZATION_ENABLED]; const { sectionKey, title, subtitle } = section; const { responsiveLayouts } = section.layout; const following = sectionPersonalization[sectionKey]?.isFollowed; const handleIntersection = useCallback(() => { dispatch( ac.AlsoToMain({ type: at.CARD_SECTION_IMPRESSION, data: { section: sectionKey, section_position: sectionPosition, is_section_followed: following, }, }) ); }, [dispatch, sectionKey, sectionPosition, following]); // Ref to hold the section element const sectionRefs = useIntersectionObserver(handleIntersection); // Only show thumbs up/down buttons if both default thumbs and sections thumbs prefs are enabled const mayHaveCombinedThumbsUpDown = mayHaveSectionsCardsThumbsUpDown && mayHaveThumbsUpDown; const onFollowClick = useCallback(() => { const updatedSectionData = { ...sectionPersonalization, [sectionKey]: { isFollowed: true, isBlocked: false, followedAt: new Date().toISOString(), }, }; dispatch( ac.AlsoToMain({ type: at.SECTION_PERSONALIZATION_SET, data: updatedSectionData, }) ); // Telemetry Event Dispatch dispatch( ac.OnlyToMain({ type: "FOLLOW_SECTION", data: { section: sectionKey, section_position: sectionPosition, event_source: "MOZ_BUTTON", }, }) ); }, [dispatch, sectionPersonalization, sectionKey, sectionPosition]); const onUnfollowClick = useCallback(() => { const updatedSectionData = { ...sectionPersonalization }; delete updatedSectionData[sectionKey]; dispatch( ac.AlsoToMain({ type: at.SECTION_PERSONALIZATION_SET, data: updatedSectionData, }) ); // Telemetry Event Dispatch dispatch( ac.OnlyToMain({ type: "UNFOLLOW_SECTION", data: { section: sectionKey, section_position: sectionPosition, event_source: "MOZ_BUTTON", }, }) ); }, [dispatch, sectionPersonalization, sectionKey, sectionPosition]); const { maxTile } = getMaxTiles(responsiveLayouts); const displaySections = section.data.slice(0, maxTile); const isSectionEmpty = !displaySections?.length; const shouldShowLabels = sectionKey === "top_stories_section" && showTopics; if (isSectionEmpty) { return null; } const sectionContextWrapper = (
); return (
{ sectionRefs.current[0] = el; }} >

{title}

{subtitle &&

{subtitle}

}
{mayHaveSectionsPersonalization ? sectionContextWrapper : null}
{section.data.slice(0, maxTile).map((rec, index) => { const { classNames, imageSizes } = getLayoutData( responsiveLayouts, index, refinedCardsLayout ); if (!rec || rec.placeholder) { return ; } return ( ); })}
); } function CardSections({ data, feed, dispatch, type, firstVisibleTimestamp, is_collection, spocMessageVariant, ctaButtonVariant, ctaButtonSponsors, }) { const prefs = useSelector(state => state.Prefs.values); const { spocs, sectionPersonalization } = useSelector( state => state.DiscoveryStream ); const { messageData } = useSelector(state => state.Messages); const personalizationEnabled = prefs[PREF_SECTIONS_PERSONALIZATION_ENABLED]; const interestPickerEnabled = prefs[PREF_INTEREST_PICKER_ENABLED]; // Handle a render before feed has been fetched by displaying nothing if (!data) { return null; } const visibleSections = prefToArray(prefs[PREF_VISIBLE_SECTIONS]); const { interestPicker } = data; let filteredSections = data.sections.filter( section => !sectionPersonalization[section.sectionKey]?.isBlocked ); if (interestPickerEnabled && visibleSections.length) { filteredSections = visibleSections.reduce((acc, visibleSection) => { const found = filteredSections.find( ({ sectionKey }) => sectionKey === visibleSection ); if (found) { acc.push(found); } return acc; }, []); } let sectionsToRender = filteredSections.map((section, sectionPosition) => ( )); // Add a billboard/leaderboard IAB ad to the sectionsToRender array (if enabled/possible). const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED]; const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED]; if ( (billboardEnabled || leaderboardEnabled) && spocs?.data?.newtab_spocs?.items ) { 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]; sectionsToRender.splice( // Math.min is used here to ensure the given row stays within the bounds of the sectionsToRender array. Math.min(sectionsToRender.length - 1, row), 0, ); } } // Add the interest picker to the sectionsToRender array (if enabled/possible). if ( interestPickerEnabled && personalizationEnabled && interestPicker?.sections ) { const index = interestPicker.receivedFeedRank - 1; sectionsToRender.splice( // Math.min is used here to ensure the given row stays within the bounds of the sectionsToRender array. Math.min(sectionsToRender.length - 1, index), 0, ); } function displayP13nCard() { if (messageData && Object.keys(messageData).length >= 1) { if ( messageData?.content?.messageType === "PersonalizedCard" && prefs[PREF_INFERRED_PERSONALIZATION_USER] ) { const row = messageData.content.position; sectionsToRender.splice( row, 0, {}}> ); } } } displayP13nCard(); const isEmpty = sectionsToRender.length === 0; return isEmpty ? (
) : (
{sectionsToRender}
); } export { CardSections };