CardGrid.jsx (12709B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx"; 6 import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx"; 7 import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx"; 8 import { AdBanner } from "../AdBanner/AdBanner.jsx"; 9 import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; 10 import React, { useEffect, useRef } from "react"; 11 import { connect } from "react-redux"; 12 const PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled"; 13 const PREF_TOPICS_ENABLED = "discoverystream.topicLabels.enabled"; 14 const PREF_TOPICS_SELECTED = "discoverystream.topicSelection.selectedTopics"; 15 const PREF_TOPICS_AVAILABLE = "discoverystream.topicSelection.topics"; 16 const PREF_SPOCS_STARTUPCACHE_ENABLED = 17 "discoverystream.spocs.startupCache.enabled"; 18 const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard"; 19 const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position"; 20 const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard"; 21 const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position"; 22 const WIDGET_IDS = { 23 TOPICS: 1, 24 }; 25 26 export function DSSubHeader({ children }) { 27 return ( 28 <div className="section-top-bar ds-sub-header"> 29 <h3 className="section-title-container">{children}</h3> 30 </div> 31 ); 32 } 33 34 // eslint-disable-next-line no-shadow 35 export function IntersectionObserver({ 36 children, 37 windowObj = window, 38 onIntersecting, 39 }) { 40 const intersectionElement = useRef(null); 41 42 useEffect(() => { 43 let observer; 44 if (!observer && onIntersecting && intersectionElement.current) { 45 observer = new windowObj.IntersectionObserver(entries => { 46 const entry = entries.find(e => e.isIntersecting); 47 48 if (entry) { 49 // Stop observing since element has been seen 50 if (observer && intersectionElement.current) { 51 observer.unobserve(intersectionElement.current); 52 } 53 54 onIntersecting(); 55 } 56 }); 57 observer.observe(intersectionElement.current); 58 } 59 // Cleanup 60 return () => observer?.disconnect(); 61 }, [windowObj, onIntersecting]); 62 63 return <div ref={intersectionElement}>{children}</div>; 64 } 65 66 export class _CardGrid extends React.PureComponent { 67 constructor(props) { 68 super(props); 69 this.state = { 70 focusedIndex: 0, 71 }; 72 this.onCardFocus = this.onCardFocus.bind(this); 73 this.handleCardKeyDown = this.handleCardKeyDown.bind(this); 74 } 75 76 onCardFocus(index) { 77 this.setState({ focusedIndex: index }); 78 } 79 80 handleCardKeyDown(e) { 81 if (e.key === "ArrowLeft" || e.key === "ArrowRight") { 82 e.preventDefault(); 83 84 const currentCardEl = e.target.closest("article.ds-card"); 85 if (!currentCardEl) { 86 return; 87 } 88 89 // Arrow direction should match visual navigation direction in RTL 90 const isRTL = document.dir === "rtl"; 91 const navigateToPrevious = isRTL 92 ? e.key === "ArrowRight" 93 : e.key === "ArrowLeft"; 94 95 let targetCardEl = currentCardEl; 96 97 // Walk through siblings to find the target card element 98 while (targetCardEl) { 99 targetCardEl = navigateToPrevious 100 ? targetCardEl.previousElementSibling 101 : targetCardEl.nextElementSibling; 102 103 if (targetCardEl && targetCardEl.matches("article.ds-card")) { 104 const link = targetCardEl.querySelector("a.ds-card-link"); 105 if (link) { 106 link.focus(); 107 } 108 break; 109 } 110 } 111 } 112 } 113 114 // eslint-disable-next-line max-statements 115 renderCards() { 116 const prefs = this.props.Prefs.values; 117 const { 118 items, 119 ctaButtonSponsors, 120 ctaButtonVariant, 121 widgets, 122 DiscoveryStream, 123 } = this.props; 124 125 const { topicsLoading } = DiscoveryStream; 126 const mayHaveSectionsCards = prefs[PREF_SECTIONS_CARDS_ENABLED]; 127 const showTopics = prefs[PREF_TOPICS_ENABLED]; 128 const selectedTopics = prefs[PREF_TOPICS_SELECTED]; 129 const availableTopics = prefs[PREF_TOPICS_AVAILABLE]; 130 const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED]; 131 const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED]; 132 const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED]; 133 134 const recs = this.props.data.recommendations.slice(0, items); 135 const cards = []; 136 let cardIndex = 0; 137 138 for (let index = 0; index < items; index++) { 139 const rec = recs[index]; 140 const isPlaceholder = 141 topicsLoading || 142 this.props.placeholder || 143 !rec || 144 rec.placeholder || 145 (rec.flight_id && 146 !spocsStartupCacheEnabled && 147 this.props.App.isForStartupCache.DiscoveryStream); 148 149 if (isPlaceholder) { 150 cards.push(<PlaceholderDSCard key={`dscard-${index}`} />); 151 } else { 152 const currentCardIndex = cardIndex; 153 cardIndex++; 154 cards.push( 155 <DSCard 156 key={`dscard-${rec.id}`} 157 pos={rec.pos} 158 flightId={rec.flight_id} 159 image_src={rec.image_src} 160 raw_image_src={rec.raw_image_src} 161 icon_src={rec.icon_src} 162 word_count={rec.word_count} 163 time_to_read={rec.time_to_read} 164 title={rec.title} 165 topic={rec.topic} 166 features={rec.features} 167 showTopics={showTopics} 168 selectedTopics={selectedTopics} 169 excerpt={rec.excerpt} 170 availableTopics={availableTopics} 171 url={rec.url} 172 id={rec.id} 173 shim={rec.shim} 174 fetchTimestamp={rec.fetchTimestamp} 175 type={this.props.type} 176 context={rec.context} 177 sponsor={rec.sponsor} 178 sponsored_by_override={rec.sponsored_by_override} 179 dispatch={this.props.dispatch} 180 source={rec.domain} 181 publisher={rec.publisher} 182 pocket_id={rec.pocket_id} 183 context_type={rec.context_type} 184 bookmarkGuid={rec.bookmarkGuid} 185 ctaButtonSponsors={ctaButtonSponsors} 186 ctaButtonVariant={ctaButtonVariant} 187 recommendation_id={rec.recommendation_id} 188 firstVisibleTimestamp={this.props.firstVisibleTimestamp} 189 mayHaveSectionsCards={mayHaveSectionsCards} 190 corpus_item_id={rec.corpus_item_id} 191 scheduled_corpus_item_id={rec.scheduled_corpus_item_id} 192 recommended_at={rec.recommended_at} 193 received_rank={rec.received_rank} 194 format={rec.format} 195 alt_text={rec.alt_text} 196 isTimeSensitive={rec.isTimeSensitive} 197 tabIndex={currentCardIndex === this.state.focusedIndex ? 0 : -1} 198 onFocus={() => this.onCardFocus(currentCardIndex)} 199 attribution={rec.attribution} 200 /> 201 ); 202 } 203 } 204 205 if (widgets?.positions?.length && widgets?.data?.length) { 206 let positionIndex = 0; 207 const source = "CARDGRID_WIDGET"; 208 209 for (const widget of widgets.data) { 210 let widgetComponent = null; 211 const position = widgets.positions[positionIndex]; 212 213 // Stop if we run out of positions to place widgets. 214 if (!position) { 215 break; 216 } 217 218 switch (widget?.type) { 219 case "TopicsWidget": 220 widgetComponent = ( 221 <TopicsWidget 222 position={position.index} 223 dispatch={this.props.dispatch} 224 source={source} 225 id={WIDGET_IDS.TOPICS} 226 /> 227 ); 228 break; 229 } 230 231 if (widgetComponent) { 232 // We found a widget, so up the position for next try. 233 positionIndex++; 234 // We replace an existing card with the widget. 235 cards.splice(position.index, 1, widgetComponent); 236 } 237 } 238 } 239 240 // if a banner ad is enabled and we have any available, place them in the grid 241 const { spocs } = this.props.DiscoveryStream; 242 243 if ( 244 (billboardEnabled || leaderboardEnabled) && 245 spocs?.data?.newtab_spocs?.items 246 ) { 247 // Only render one AdBanner in the grid - 248 // Prioritize rendering a leaderboard if it exists, 249 // otherwise render a billboard 250 const spocToRender = 251 spocs.data.newtab_spocs.items.find( 252 ({ format }) => format === "leaderboard" && leaderboardEnabled 253 ) || 254 spocs.data.newtab_spocs.items.find( 255 ({ format }) => format === "billboard" && billboardEnabled 256 ); 257 258 if (spocToRender && !spocs.blocked.includes(spocToRender.url)) { 259 const row = 260 spocToRender.format === "leaderboard" 261 ? prefs[PREF_LEADERBOARD_POSITION] 262 : prefs[PREF_BILLBOARD_POSITION]; 263 264 function displayCardsPerRow() { 265 // Determines the number of cards per row based on the window width: 266 // width <= 1122px: 2 cards per row 267 // width 1123px to 1697px: 3 cards per row 268 // width >= 1698px: 4 cards per row 269 if (window.innerWidth <= 1122) { 270 return 2; 271 } else if (window.innerWidth > 1122 && window.innerWidth < 1698) { 272 return 3; 273 } 274 return 4; 275 } 276 277 const injectAdBanner = bannerIndex => { 278 // .splice() inserts the AdBanner at the desired index, ensuring correct DOM order for accessibility and keyboard navigation. 279 // .push() would place it at the end, which is visually incorrect even if adjusted with CSS. 280 cards.splice( 281 bannerIndex, 282 0, 283 <AdBanner 284 spoc={spocToRender} 285 key={`dscard-${spocToRender.id}`} 286 dispatch={this.props.dispatch} 287 type={this.props.type} 288 firstVisibleTimestamp={this.props.firstVisibleTimestamp} 289 row={row} 290 prefs={prefs} 291 /> 292 ); 293 }; 294 295 const getBannerIndex = () => { 296 // Calculate the index for where the AdBanner should be added, depending on number of cards per row on the grid 297 const cardsPerRow = displayCardsPerRow(); 298 let bannerIndex = (row - 1) * cardsPerRow; 299 return bannerIndex; 300 }; 301 302 injectAdBanner(getBannerIndex()); 303 } 304 } 305 306 const gridClassName = this.renderGridClassName(); 307 308 return ( 309 <> 310 {cards?.length > 0 && ( 311 <div className={gridClassName} onKeyDown={this.handleCardKeyDown}> 312 {cards} 313 </div> 314 )} 315 </> 316 ); 317 } 318 319 renderGridClassName() { 320 const { 321 hybridLayout, 322 hideCardBackground, 323 fourCardLayout, 324 compactGrid, 325 hideDescriptions, 326 } = this.props; 327 328 const hideCardBackgroundClass = hideCardBackground 329 ? `ds-card-grid-hide-background` 330 : ``; 331 const fourCardLayoutClass = fourCardLayout 332 ? `ds-card-grid-four-card-variant` 333 : ``; 334 const hideDescriptionsClassName = !hideDescriptions 335 ? `ds-card-grid-include-descriptions` 336 : ``; 337 const compactGridClassName = compactGrid ? `ds-card-grid-compact` : ``; 338 const hybridLayoutClassName = hybridLayout 339 ? `ds-card-grid-hybrid-layout` 340 : ``; 341 342 const gridClassName = `ds-card-grid ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`; 343 return gridClassName; 344 } 345 346 render() { 347 const { data } = this.props; 348 349 // Handle a render before feed has been fetched by displaying nothing 350 if (!data) { 351 return null; 352 } 353 354 // Handle the case where a user has dismissed all recommendations 355 const isEmpty = data.recommendations.length === 0; 356 357 return ( 358 <div> 359 {this.props.title && ( 360 <div className="ds-header"> 361 <div className="title">{this.props.title}</div> 362 {this.props.context && ( 363 <FluentOrText message={this.props.context}> 364 <div className="ds-context" /> 365 </FluentOrText> 366 )} 367 </div> 368 )} 369 {isEmpty ? ( 370 <div className="ds-card-grid empty"> 371 <DSEmptyState 372 status={data.status} 373 dispatch={this.props.dispatch} 374 feed={this.props.feed} 375 /> 376 </div> 377 ) : ( 378 this.renderCards() 379 )} 380 </div> 381 ); 382 } 383 } 384 385 _CardGrid.defaultProps = { 386 items: 4, // Number of stories to display 387 }; 388 389 export const CardGrid = connect(state => ({ 390 Prefs: state.Prefs, 391 App: state.App, 392 DiscoveryStream: state.DiscoveryStream, 393 }))(_CardGrid);