Sections.jsx (10481B)
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 { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; 6 import { Card, PlaceholderCard } from "content-src/components/Card/Card"; 7 import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; 8 import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; 9 import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; 10 import { connect } from "react-redux"; 11 import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations"; 12 import React from "react"; 13 import { TopSites } from "content-src/components/TopSites/TopSites"; 14 15 const VISIBLE = "visible"; 16 const VISIBILITY_CHANGE_EVENT = "visibilitychange"; 17 const CARDS_PER_ROW_DEFAULT = 3; 18 const CARDS_PER_ROW_COMPACT_WIDE = 4; 19 20 export class Section extends React.PureComponent { 21 get numRows() { 22 const { rowsPref, maxRows, Prefs } = this.props; 23 return rowsPref ? Prefs.values[rowsPref] : maxRows; 24 } 25 26 _dispatchImpressionStats() { 27 const { props } = this; 28 let cardsPerRow = CARDS_PER_ROW_DEFAULT; 29 if ( 30 props.compactCards && 31 globalThis.matchMedia(`(min-width: 1072px)`).matches 32 ) { 33 // If the section has compact cards and the viewport is wide enough, we show 34 // 4 columns instead of 3. 35 // $break-point-widest = 1072px (from _variables.scss) 36 cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE; 37 } 38 const maxCards = cardsPerRow * this.numRows; 39 const cards = props.rows.slice(0, maxCards); 40 41 if (this.needsImpressionStats(cards)) { 42 props.dispatch( 43 ac.ImpressionStats({ 44 source: props.eventSource, 45 tiles: cards.map(link => ({ id: link.guid })), 46 }) 47 ); 48 this.impressionCardGuids = cards.map(link => link.guid); 49 } 50 } 51 52 // This sends an event when a user sees a set of new content. If content 53 // changes while the page is hidden (i.e. preloaded or on a hidden tab), 54 // only send the event if the page becomes visible again. 55 sendImpressionStatsOrAddListener() { 56 const { props } = this; 57 58 if (!props.shouldSendImpressionStats || !props.dispatch) { 59 return; 60 } 61 62 if (props.document.visibilityState === VISIBLE) { 63 this._dispatchImpressionStats(); 64 } else { 65 // We should only ever send the latest impression stats ping, so remove any 66 // older listeners. 67 if (this._onVisibilityChange) { 68 props.document.removeEventListener( 69 VISIBILITY_CHANGE_EVENT, 70 this._onVisibilityChange 71 ); 72 } 73 74 // When the page becomes visible, send the impression stats ping if the section isn't collapsed. 75 this._onVisibilityChange = () => { 76 if (props.document.visibilityState === VISIBLE) { 77 if (!this.props.pref.collapsed) { 78 this._dispatchImpressionStats(); 79 } 80 props.document.removeEventListener( 81 VISIBILITY_CHANGE_EVENT, 82 this._onVisibilityChange 83 ); 84 } 85 }; 86 props.document.addEventListener( 87 VISIBILITY_CHANGE_EVENT, 88 this._onVisibilityChange 89 ); 90 } 91 } 92 93 componentWillMount() { 94 this.sendNewTabRehydrated(this.props.initialized); 95 } 96 97 componentDidMount() { 98 if (this.props.rows.length && !this.props.pref.collapsed) { 99 this.sendImpressionStatsOrAddListener(); 100 } 101 } 102 103 componentDidUpdate(prevProps) { 104 const { props } = this; 105 const isCollapsed = props.pref.collapsed; 106 const wasCollapsed = prevProps.pref.collapsed; 107 if ( 108 // Don't send impression stats for the empty state 109 props.rows.length && 110 // We only want to send impression stats if the content of the cards has changed 111 // and the section is not collapsed... 112 ((props.rows !== prevProps.rows && !isCollapsed) || 113 // or if we are expanding a section that was collapsed. 114 (wasCollapsed && !isCollapsed)) 115 ) { 116 this.sendImpressionStatsOrAddListener(); 117 } 118 } 119 120 componentWillUpdate(nextProps) { 121 this.sendNewTabRehydrated(nextProps.initialized); 122 } 123 124 componentWillUnmount() { 125 if (this._onVisibilityChange) { 126 this.props.document.removeEventListener( 127 VISIBILITY_CHANGE_EVENT, 128 this._onVisibilityChange 129 ); 130 } 131 } 132 133 needsImpressionStats(cards) { 134 if ( 135 !this.impressionCardGuids || 136 this.impressionCardGuids.length !== cards.length 137 ) { 138 return true; 139 } 140 141 for (let i = 0; i < cards.length; i++) { 142 if (cards[i].guid !== this.impressionCardGuids[i]) { 143 return true; 144 } 145 } 146 147 return false; 148 } 149 150 // The NEW_TAB_REHYDRATED event is used to inform feeds that their 151 // data has been consumed e.g. for counting the number of tabs that 152 // have rendered that data. 153 sendNewTabRehydrated(initialized) { 154 if (initialized && !this.renderNotified) { 155 this.props.dispatch( 156 ac.AlsoToMain({ type: at.NEW_TAB_REHYDRATED, data: {} }) 157 ); 158 this.renderNotified = true; 159 } 160 } 161 162 render() { 163 const { 164 id, 165 eventSource, 166 title, 167 rows, 168 emptyState, 169 dispatch, 170 compactCards, 171 read_more_endpoint, 172 contextMenuOptions, 173 initialized, 174 learnMore, 175 pref, 176 privacyNoticeURL, 177 isFirst, 178 isLast, 179 } = this.props; 180 181 const waitingForSpoc = 182 id === "topstories" && this.props.Pocket.waitingForSpoc; 183 const maxCardsPerRow = compactCards 184 ? CARDS_PER_ROW_COMPACT_WIDE 185 : CARDS_PER_ROW_DEFAULT; 186 const { numRows } = this; 187 const maxCards = maxCardsPerRow * numRows; 188 const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows; 189 const shouldShowReadMore = read_more_endpoint; 190 191 const realRows = rows.slice(0, maxCards); 192 193 // The empty state should only be shown after we have initialized and there is no content. 194 // Otherwise, we should show placeholders. 195 const shouldShowEmptyState = initialized && !rows.length; 196 197 const cards = []; 198 if (!shouldShowEmptyState) { 199 for (let i = 0; i < maxCards; i++) { 200 const link = realRows[i]; 201 // On narrow viewports, we only show 3 cards per row. We'll mark the rest as 202 // .hide-for-narrow to hide in CSS via @media query. 203 const className = i >= maxCardsOnNarrow ? "hide-for-narrow" : ""; 204 let usePlaceholder = !link; 205 // If we are in the third card and waiting for spoc, 206 // use the placeholder. 207 if (!usePlaceholder && i === 2 && waitingForSpoc) { 208 usePlaceholder = true; 209 } 210 cards.push( 211 !usePlaceholder ? ( 212 <Card 213 key={i} 214 index={i} 215 className={className} 216 dispatch={dispatch} 217 link={link} 218 contextMenuOptions={contextMenuOptions} 219 eventSource={eventSource} 220 shouldSendImpressionStats={this.props.shouldSendImpressionStats} 221 isWebExtension={this.props.isWebExtension} 222 /> 223 ) : ( 224 <PlaceholderCard key={i} className={className} /> 225 ) 226 ); 227 } 228 } 229 230 const sectionClassName = [ 231 "section", 232 compactCards ? "compact-cards" : "normal-cards", 233 ].join(" "); 234 235 // <Section> <-- React component 236 // <section> <-- HTML5 element 237 return ( 238 <ComponentPerfTimer {...this.props}> 239 <CollapsibleSection 240 className={sectionClassName} 241 title={title} 242 id={id} 243 eventSource={eventSource} 244 collapsed={this.props.pref.collapsed} 245 showPrefName={(pref && pref.feed) || id} 246 privacyNoticeURL={privacyNoticeURL} 247 Prefs={this.props.Prefs} 248 isFixed={this.props.isFixed} 249 isFirst={isFirst} 250 isLast={isLast} 251 learnMore={learnMore} 252 dispatch={this.props.dispatch} 253 isWebExtension={this.props.isWebExtension} 254 > 255 {!shouldShowEmptyState && ( 256 <ul className="section-list" style={{ padding: 0 }}> 257 {cards} 258 </ul> 259 )} 260 {shouldShowEmptyState && ( 261 <div className="section-empty-state"> 262 <div className="empty-state"> 263 <FluentOrText message={emptyState.message}> 264 <p className="empty-state-message" /> 265 </FluentOrText> 266 </div> 267 </div> 268 )} 269 {id === "topstories" && ( 270 <div className="top-stories-bottom-container"> 271 <div className="wrapper-more-recommendations"> 272 {shouldShowReadMore && ( 273 <MoreRecommendations 274 read_more_endpoint={read_more_endpoint} 275 /> 276 )} 277 </div> 278 </div> 279 )} 280 </CollapsibleSection> 281 </ComponentPerfTimer> 282 ); 283 } 284 } 285 286 Section.defaultProps = { 287 document: globalThis.document, 288 rows: [], 289 emptyState: {}, 290 pref: {}, 291 title: "", 292 }; 293 294 export const SectionIntl = connect(state => ({ 295 Prefs: state.Prefs, 296 Pocket: state.Pocket, 297 }))(Section); 298 299 export class _Sections extends React.PureComponent { 300 renderSections() { 301 const sections = []; 302 const enabledSections = this.props.Sections.filter( 303 section => section.enabled 304 ); 305 const { sectionOrder, "feeds.topsites": showTopSites } = 306 this.props.Prefs.values; 307 // Enabled sections doesn't include Top Sites, so we add it if enabled. 308 const expectedCount = enabledSections.length + ~~showTopSites; 309 310 for (const sectionId of sectionOrder.split(",")) { 311 const commonProps = { 312 key: sectionId, 313 isFirst: sections.length === 0, 314 isLast: sections.length === expectedCount - 1, 315 }; 316 if (sectionId === "topsites" && showTopSites) { 317 sections.push(<TopSites {...commonProps} />); 318 } else { 319 const section = enabledSections.find(s => s.id === sectionId); 320 if (section) { 321 sections.push(<SectionIntl {...section} {...commonProps} />); 322 } 323 } 324 } 325 return sections; 326 } 327 328 render() { 329 return <div className="sections-list">{this.renderSections()}</div>; 330 } 331 } 332 333 export const Sections = connect(state => ({ 334 Sections: state.Sections, 335 Prefs: state.Prefs, 336 }))(_Sections);