CardSections.jsx (21166B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 5 import React, { useCallback, useState } from "react"; 6 import { DSEmptyState } from "../DSEmptyState/DSEmptyState"; 7 import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard"; 8 import { useSelector } from "react-redux"; 9 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; 10 import { 11 selectWeatherPlacement, 12 useIntersectionObserver, 13 getActiveColumnLayout, 14 } from "../../../lib/utils"; 15 import { SectionContextMenu } from "../SectionContextMenu/SectionContextMenu"; 16 import { InterestPicker } from "../InterestPicker/InterestPicker"; 17 import { AdBanner } from "../AdBanner/AdBanner.jsx"; 18 import { PersonalizedCard } from "../PersonalizedCard/PersonalizedCard"; 19 import { FollowSectionButtonHighlight } from "../FeatureHighlight/FollowSectionButtonHighlight"; 20 import { MessageWrapper } from "content-src/components/MessageWrapper/MessageWrapper"; 21 import { Weather } from "../../Weather/Weather.jsx"; 22 23 // Prefs 24 const PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled"; 25 const PREF_SECTIONS_PERSONALIZATION_ENABLED = 26 "discoverystream.sections.personalization.enabled"; 27 const PREF_TOPICS_ENABLED = "discoverystream.topicLabels.enabled"; 28 const PREF_TOPICS_SELECTED = "discoverystream.topicSelection.selectedTopics"; 29 const PREF_TOPICS_AVAILABLE = "discoverystream.topicSelection.topics"; 30 const PREF_INTEREST_PICKER_ENABLED = 31 "discoverystream.sections.interestPicker.enabled"; 32 const PREF_VISIBLE_SECTIONS = 33 "discoverystream.sections.interestPicker.visibleSections"; 34 const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard"; 35 const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position"; 36 const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; 37 const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position"; 38 const PREF_REFINED_CARDS_ENABLED = "discoverystream.refinedCardsLayout.enabled"; 39 const PREF_INFERRED_PERSONALIZATION_USER = 40 "discoverystream.sections.personalization.inferred.user.enabled"; 41 const PREF_DAILY_BRIEF_SECTIONID = "discoverystream.dailyBrief.sectionId"; 42 const PREF_SPOCS_STARTUPCACHE_ENABLED = 43 "discoverystream.spocs.startupCache.enabled"; 44 45 function getLayoutData(responsiveLayouts, index, refinedCardsLayout) { 46 let layoutData = { 47 classNames: [], 48 imageSizes: {}, 49 }; 50 51 responsiveLayouts.forEach(layout => { 52 layout.tiles.forEach((tile, tileIndex) => { 53 if (tile.position === index) { 54 layoutData.classNames.push(`col-${layout.columnCount}-${tile.size}`); 55 layoutData.classNames.push( 56 `col-${layout.columnCount}-position-${tileIndex}` 57 ); 58 layoutData.imageSizes[layout.columnCount] = tile.size; 59 60 // The API tells us whether the tile should show the excerpt or not. 61 // Apply extra styles accordingly. 62 if (tile.hasExcerpt) { 63 if (tile.size === "medium" && refinedCardsLayout) { 64 layoutData.classNames.push( 65 `col-${layout.columnCount}-hide-excerpt` 66 ); 67 } else { 68 layoutData.classNames.push( 69 `col-${layout.columnCount}-show-excerpt` 70 ); 71 } 72 } else { 73 layoutData.classNames.push(`col-${layout.columnCount}-hide-excerpt`); 74 } 75 } 76 }); 77 }); 78 79 return layoutData; 80 } 81 82 // function to determine amount of tiles shown per section per viewport 83 function getMaxTiles(responsiveLayouts) { 84 return responsiveLayouts 85 .flatMap(responsiveLayout => responsiveLayout) 86 .reduce((acc, t) => { 87 acc[t.columnCount] = t.tiles.length; 88 89 // Update maxTile if current tile count is greater 90 if (!acc.maxTile || t.tiles.length > acc.maxTile) { 91 acc.maxTile = t.tiles.length; 92 } 93 return acc; 94 }, {}); 95 } 96 97 /** 98 * Transforms a comma-separated string in user preferences 99 * into a cleaned-up array. 100 * 101 * @param {string} pref - The comma-separated pref to be converted. 102 * @returns {string[]} An array of trimmed strings, excluding empty values. 103 */ 104 105 const prefToArray = (pref = "") => { 106 return pref 107 .split(",") 108 .map(item => item.trim()) 109 .filter(item => item); 110 }; 111 112 function shouldShowOMCHighlight(messageData, componentId) { 113 if (!messageData || Object.keys(messageData).length === 0) { 114 return false; 115 } 116 return messageData?.content?.messageType === componentId; 117 } 118 119 function CardSection({ 120 sectionPosition, 121 section, 122 dispatch, 123 type, 124 firstVisibleTimestamp, 125 ctaButtonVariant, 126 ctaButtonSponsors, 127 anySectionsFollowed, 128 showWeather, 129 placeholder, 130 }) { 131 const prefs = useSelector(state => state.Prefs.values); 132 133 const { messageData } = useSelector(state => state.Messages); 134 135 const { sectionPersonalization } = useSelector( 136 state => state.DiscoveryStream 137 ); 138 const { isForStartupCache } = useSelector(state => state.App); 139 140 const [focusedIndex, setFocusedIndex] = useState(0); 141 142 const onCardFocus = index => { 143 setFocusedIndex(index); 144 }; 145 146 const handleCardKeyDown = e => { 147 if (e.key === "ArrowLeft" || e.key === "ArrowRight") { 148 e.preventDefault(); 149 150 const currentCardEl = e.target.closest("article.ds-card"); 151 if (!currentCardEl) { 152 return; 153 } 154 155 const activeColumn = getActiveColumnLayout(window.innerWidth); 156 157 // Arrow direction should match visual navigation direction in RTL 158 const isRTL = document.dir === "rtl"; 159 const navigateToPrevious = isRTL 160 ? e.key === "ArrowRight" 161 : e.key === "ArrowLeft"; 162 163 // Extract current position from classList 164 let currentPosition = null; 165 const positionPrefix = `${activeColumn}-position-`; 166 for (let className of currentCardEl.classList) { 167 if (className.startsWith(positionPrefix)) { 168 currentPosition = parseInt( 169 className.substring(positionPrefix.length), 170 10 171 ); 172 break; 173 } 174 } 175 176 if (currentPosition === null) { 177 return; 178 } 179 180 const targetPosition = navigateToPrevious 181 ? currentPosition - 1 182 : currentPosition + 1; 183 184 // Find card with target position 185 const parentEl = currentCardEl.parentElement; 186 if (parentEl) { 187 const targetSelector = `article.ds-card.${activeColumn}-position-${targetPosition}`; 188 const targetCardEl = parentEl.querySelector(targetSelector); 189 190 if (targetCardEl) { 191 const link = targetCardEl.querySelector("a.ds-card-link"); 192 if (link) { 193 link.focus(); 194 } 195 } 196 } 197 } 198 }; 199 200 const showTopics = prefs[PREF_TOPICS_ENABLED]; 201 const mayHaveSectionsCards = prefs[PREF_SECTIONS_CARDS_ENABLED]; 202 const selectedTopics = prefs[PREF_TOPICS_SELECTED]; 203 const availableTopics = prefs[PREF_TOPICS_AVAILABLE]; 204 const refinedCardsLayout = prefs[PREF_REFINED_CARDS_ENABLED]; 205 const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED]; 206 207 const mayHaveSectionsPersonalization = 208 prefs[PREF_SECTIONS_PERSONALIZATION_ENABLED]; 209 210 const { sectionKey, title, subtitle } = section; 211 const { responsiveLayouts, name: layoutName } = section.layout; 212 213 const following = sectionPersonalization[sectionKey]?.isFollowed; 214 215 const handleIntersection = useCallback(() => { 216 dispatch( 217 ac.AlsoToMain({ 218 type: at.CARD_SECTION_IMPRESSION, 219 data: { 220 section: sectionKey, 221 section_position: sectionPosition, 222 is_section_followed: following, 223 layout_name: layoutName, 224 }, 225 }) 226 ); 227 }, [dispatch, sectionKey, sectionPosition, following, layoutName]); 228 229 // Ref to hold the section element 230 const sectionRefs = useIntersectionObserver(handleIntersection); 231 232 const onFollowClick = useCallback(() => { 233 const updatedSectionData = { 234 ...sectionPersonalization, 235 [sectionKey]: { 236 isFollowed: true, 237 isBlocked: false, 238 followedAt: new Date().toISOString(), 239 }, 240 }; 241 dispatch( 242 ac.AlsoToMain({ 243 type: at.SECTION_PERSONALIZATION_SET, 244 data: updatedSectionData, 245 }) 246 ); 247 // Telemetry Event Dispatch 248 dispatch( 249 ac.OnlyToMain({ 250 type: "FOLLOW_SECTION", 251 data: { 252 section: sectionKey, 253 section_position: sectionPosition, 254 event_source: "MOZ_BUTTON", 255 }, 256 }) 257 ); 258 }, [dispatch, sectionPersonalization, sectionKey, sectionPosition]); 259 260 const onUnfollowClick = useCallback(() => { 261 const updatedSectionData = { ...sectionPersonalization }; 262 delete updatedSectionData[sectionKey]; 263 dispatch( 264 ac.AlsoToMain({ 265 type: at.SECTION_PERSONALIZATION_SET, 266 data: updatedSectionData, 267 }) 268 ); 269 270 // Telemetry Event Dispatch 271 dispatch( 272 ac.OnlyToMain({ 273 type: "UNFOLLOW_SECTION", 274 data: { 275 section: sectionKey, 276 section_position: sectionPosition, 277 event_source: "MOZ_BUTTON", 278 }, 279 }) 280 ); 281 }, [dispatch, sectionPersonalization, sectionKey, sectionPosition]); 282 283 let { maxTile } = getMaxTiles(responsiveLayouts); 284 if (placeholder) { 285 // We need a number that divides evenly by 2, 3, and 4. 286 // So it can be displayed without orphans in grids with 2, 3, and 4 columns. 287 maxTile = 12; 288 } 289 290 const displaySections = section.data.slice(0, maxTile); 291 const isSectionEmpty = !displaySections?.length; 292 const shouldShowLabels = sectionKey === "top_stories_section" && showTopics; 293 294 if (isSectionEmpty) { 295 return null; 296 } 297 298 const sectionContextWrapper = ( 299 <div className="section-context-wrapper"> 300 <div 301 className={following ? "section-follow following" : "section-follow"} 302 > 303 {!anySectionsFollowed && 304 sectionPosition === 0 && 305 shouldShowOMCHighlight( 306 messageData, 307 "FollowSectionButtonHighlight" 308 ) && ( 309 <MessageWrapper dispatch={dispatch}> 310 <FollowSectionButtonHighlight 311 verticalPosition="inset-block-center" 312 position="arrow-inline-start" 313 dispatch={dispatch} 314 feature="FEATURE_FOLLOW_SECTION_BUTTON" 315 messageData={messageData} 316 /> 317 </MessageWrapper> 318 )} 319 {!anySectionsFollowed && 320 sectionPosition === 0 && 321 shouldShowOMCHighlight( 322 messageData, 323 "FollowSectionButtonAltHighlight" 324 ) && ( 325 <MessageWrapper dispatch={dispatch}> 326 <FollowSectionButtonHighlight 327 verticalPosition="inset-block-center" 328 position="arrow-inline-start" 329 dispatch={dispatch} 330 feature="FEATURE_ALT_FOLLOW_SECTION_BUTTON" 331 /> 332 </MessageWrapper> 333 )} 334 <moz-button 335 onClick={following ? onUnfollowClick : onFollowClick} 336 type="default" 337 index={sectionPosition} 338 section={sectionKey} 339 > 340 <span 341 className="section-button-follow-text" 342 data-l10n-id="newtab-section-follow-button" 343 /> 344 <span 345 className="section-button-following-text" 346 data-l10n-id="newtab-section-following-button" 347 /> 348 <span 349 className="section-button-unfollow-text" 350 data-l10n-id="newtab-section-unfollow-button" 351 /> 352 </moz-button> 353 </div> 354 <SectionContextMenu 355 dispatch={dispatch} 356 index={sectionPosition} 357 following={following} 358 sectionPersonalization={sectionPersonalization} 359 sectionKey={sectionKey} 360 title={title} 361 type={type} 362 sectionPosition={sectionPosition} 363 /> 364 </div> 365 ); 366 return ( 367 <section 368 className="ds-section" 369 ref={el => { 370 sectionRefs.current[0] = el; 371 }} 372 > 373 <div className="section-heading"> 374 <div className="section-heading-inline-start"> 375 <div className="section-title-wrapper"> 376 <h2 className="section-title">{title}</h2> 377 {subtitle && <p className="section-subtitle">{subtitle}</p>} 378 </div> 379 {showWeather && <Weather isInSection={true} />} 380 </div> 381 {mayHaveSectionsPersonalization ? sectionContextWrapper : null} 382 </div> 383 <div 384 className={`ds-section-grid ds-card-grid`} 385 onKeyDown={handleCardKeyDown} 386 > 387 {section.data.slice(0, maxTile).map((rec, index) => { 388 const layoutData = getLayoutData( 389 responsiveLayouts, 390 index, 391 refinedCardsLayout 392 ); 393 394 const { classNames, imageSizes } = layoutData; 395 // Render a placeholder card when: 396 // 1. No recommendation is available. 397 // 2. The item is flagged as a placeholder. 398 // 3. Spocs are loading for with spocs startup cache disabled. 399 if ( 400 !rec || 401 rec.placeholder || 402 placeholder || 403 (rec.flight_id && 404 !spocsStartupCacheEnabled && 405 isForStartupCache.DiscoveryStream) 406 ) { 407 return <PlaceholderDSCard key={`dscard-${index}`} />; 408 } 409 410 const card = ( 411 <DSCard 412 key={`dscard-${rec.id}`} 413 pos={rec.pos} 414 flightId={rec.flight_id} 415 image_src={rec.image_src} 416 raw_image_src={rec.raw_image_src} 417 icon_src={rec.icon_src} 418 word_count={rec.word_count} 419 time_to_read={rec.time_to_read} 420 title={rec.title} 421 topic={rec.topic} 422 features={rec.features} 423 excerpt={rec.excerpt} 424 url={rec.url} 425 id={rec.id} 426 shim={rec.shim} 427 fetchTimestamp={rec.fetchTimestamp} 428 type={type} 429 context={rec.context} 430 sponsor={rec.sponsor} 431 sponsored_by_override={rec.sponsored_by_override} 432 dispatch={dispatch} 433 source={rec.domain} 434 publisher={rec.publisher} 435 pocket_id={rec.pocket_id} 436 context_type={rec.context_type} 437 bookmarkGuid={rec.bookmarkGuid} 438 recommendation_id={rec.recommendation_id} 439 firstVisibleTimestamp={firstVisibleTimestamp} 440 corpus_item_id={rec.corpus_item_id} 441 scheduled_corpus_item_id={rec.scheduled_corpus_item_id} 442 recommended_at={rec.recommended_at} 443 received_rank={rec.received_rank} 444 format={rec.format} 445 alt_text={rec.alt_text} 446 mayHaveSectionsCards={mayHaveSectionsCards} 447 showTopics={shouldShowLabels} 448 selectedTopics={selectedTopics} 449 availableTopics={availableTopics} 450 ctaButtonSponsors={ctaButtonSponsors} 451 ctaButtonVariant={ctaButtonVariant} 452 sectionsClassNames={classNames.join(" ")} 453 sectionsCardImageSizes={imageSizes} 454 section={sectionKey} 455 sectionPosition={sectionPosition} 456 sectionFollowed={following} 457 sectionLayoutName={layoutName} 458 isTimeSensitive={rec.isTimeSensitive} 459 tabIndex={index === focusedIndex ? 0 : -1} 460 onFocus={() => onCardFocus(index)} 461 attribution={rec.attribution} 462 /> 463 ); 464 return [card]; 465 })} 466 </div> 467 </section> 468 ); 469 } 470 471 function CardSections({ 472 data, 473 feed, 474 dispatch, 475 type, 476 firstVisibleTimestamp, 477 ctaButtonVariant, 478 ctaButtonSponsors, 479 placeholder, 480 }) { 481 const prefs = useSelector(state => state.Prefs.values); 482 const { spocs, sectionPersonalization } = useSelector( 483 state => state.DiscoveryStream 484 ); 485 const { messageData } = useSelector(state => state.Messages); 486 const weatherPlacement = useSelector(selectWeatherPlacement); 487 const dailyBriefSectionId = 488 prefs.trainhopConfig?.dailyBriefing?.sectionId || 489 prefs[PREF_DAILY_BRIEF_SECTIONID]; 490 const weatherEnabled = prefs.showWeather; 491 const personalizationEnabled = prefs[PREF_SECTIONS_PERSONALIZATION_ENABLED]; 492 const interestPickerEnabled = prefs[PREF_INTEREST_PICKER_ENABLED]; 493 494 // Handle a render before feed has been fetched by displaying nothing 495 if (!data) { 496 return null; 497 } 498 499 const visibleSections = prefToArray(prefs[PREF_VISIBLE_SECTIONS]); 500 const { interestPicker } = data; 501 502 // Used to determine if we should show FollowSectionButtonHighlight 503 const anySectionsFollowed = 504 sectionPersonalization && 505 Object.values(sectionPersonalization).some(section => section?.isFollowed); 506 507 let sectionsData = data.sections; 508 509 if (placeholder) { 510 // To clean up the placeholder state for sections if the whole section is loading still. 511 sectionsData = [ 512 { 513 ...sectionsData[0], 514 title: "", 515 subtitle: "", 516 }, 517 { 518 ...sectionsData[1], 519 title: "", 520 subtitle: "", 521 }, 522 ]; 523 } 524 525 let filteredSections = sectionsData.filter( 526 section => !sectionPersonalization[section.sectionKey]?.isBlocked 527 ); 528 529 if (interestPickerEnabled && visibleSections.length) { 530 filteredSections = visibleSections.reduce((acc, visibleSection) => { 531 const found = filteredSections.find( 532 ({ sectionKey }) => sectionKey === visibleSection 533 ); 534 if (found) { 535 acc.push(found); 536 } 537 return acc; 538 }, []); 539 } 540 541 let sectionsToRender = filteredSections.map((section, sectionPosition) => ( 542 <CardSection 543 key={`section-${section.sectionKey}`} 544 sectionPosition={sectionPosition} 545 section={section} 546 dispatch={dispatch} 547 type={type} 548 firstVisibleTimestamp={firstVisibleTimestamp} 549 ctaButtonVariant={ctaButtonVariant} 550 ctaButtonSponsors={ctaButtonSponsors} 551 anySectionsFollowed={anySectionsFollowed} 552 placeholder={placeholder} 553 showWeather={ 554 weatherEnabled && 555 weatherPlacement === "section" && 556 sectionPosition === 0 && 557 section.sectionKey === dailyBriefSectionId 558 } 559 /> 560 )); 561 562 // Add a billboard/leaderboard IAB ad to the sectionsToRender array (if enabled/possible). 563 const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED]; 564 const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED]; 565 566 if ( 567 (billboardEnabled || leaderboardEnabled) && 568 spocs?.data?.newtab_spocs?.items 569 ) { 570 const spocToRender = 571 spocs.data.newtab_spocs.items.find( 572 ({ format }) => format === "leaderboard" && leaderboardEnabled 573 ) || 574 spocs.data.newtab_spocs.items.find( 575 ({ format }) => format === "billboard" && billboardEnabled 576 ); 577 578 if (spocToRender && !spocs.blocked.includes(spocToRender.url)) { 579 const row = 580 spocToRender.format === "leaderboard" 581 ? prefs[PREF_LEADERBOARD_POSITION] 582 : prefs[PREF_BILLBOARD_POSITION]; 583 584 sectionsToRender.splice( 585 // Math.min is used here to ensure the given row stays within the bounds of the sectionsToRender array. 586 Math.min(sectionsToRender.length - 1, row), 587 0, 588 <AdBanner 589 spoc={spocToRender} 590 key={`dscard-${spocToRender.id}`} 591 dispatch={dispatch} 592 type={type} 593 firstVisibleTimestamp={firstVisibleTimestamp} 594 row={row} 595 prefs={prefs} 596 /> 597 ); 598 } 599 } 600 601 // Add the interest picker to the sectionsToRender array (if enabled/possible). 602 if ( 603 interestPickerEnabled && 604 personalizationEnabled && 605 interestPicker?.sections 606 ) { 607 const index = interestPicker.receivedFeedRank - 1; 608 609 sectionsToRender.splice( 610 // Math.min is used here to ensure the given row stays within the bounds of the sectionsToRender array. 611 Math.min(sectionsToRender.length - 1, index), 612 0, 613 <InterestPicker 614 title={interestPicker.title} 615 subtitle={interestPicker.subtitle} 616 interests={interestPicker.sections || []} 617 receivedFeedRank={interestPicker.receivedFeedRank} 618 /> 619 ); 620 } 621 622 function displayP13nCard() { 623 if (messageData && Object.keys(messageData).length >= 1) { 624 if ( 625 shouldShowOMCHighlight(messageData, "PersonalizedCard") && 626 prefs[PREF_INFERRED_PERSONALIZATION_USER] 627 ) { 628 const row = messageData.content.position; 629 sectionsToRender.splice( 630 row, 631 0, 632 <MessageWrapper dispatch={dispatch} onDismiss={() => {}}> 633 <PersonalizedCard 634 position={row} 635 dispatch={dispatch} 636 messageData={messageData} 637 /> 638 </MessageWrapper> 639 ); 640 } 641 } 642 } 643 644 displayP13nCard(); 645 646 const isEmpty = sectionsToRender.length === 0; 647 648 return isEmpty ? ( 649 <div className="ds-card-grid empty"> 650 <DSEmptyState status={data.status} dispatch={dispatch} feed={feed} /> 651 </div> 652 ) : ( 653 <div className="ds-section-wrapper">{sectionsToRender}</div> 654 ); 655 } 656 657 export { CardSections };