DiscoveryStreamBase.jsx (14506B)
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 { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; 6 import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; 7 import { connect } from "react-redux"; 8 import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage"; 9 import { ReportContent } from "../DiscoveryStreamComponents/ReportContent/ReportContent"; 10 import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights"; 11 import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; 12 import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; 13 import { PrivacyLink } from "content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink"; 14 import React from "react"; 15 import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle"; 16 import { selectLayoutRender } from "content-src/lib/selectLayoutRender"; 17 import { TopSites } from "content-src/components/TopSites/TopSites"; 18 import { CardSections } from "../DiscoveryStreamComponents/CardSections/CardSections"; 19 import { Widgets } from "content-src/components/Widgets/Widgets"; 20 21 const ALLOWED_CSS_URL_PREFIXES = [ 22 "chrome://", 23 "resource://", 24 "https://img-getpocket.cdn.mozilla.net/", 25 ]; 26 const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR"; 27 28 /** 29 * Validate a CSS declaration. The values are assumed to be normalized by CSSOM. 30 */ 31 export function isAllowedCSS(property, value) { 32 // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are 33 // exposed but their values aren't resulting in getting nothing. Fortunately, 34 // we don't care about validating the values of the current set of properties. 35 if (value === undefined) { 36 return true; 37 } 38 39 // Make sure all urls are of the allowed protocols/prefixes 40 const urls = value.match(/url\("[^"]+"\)/g); 41 return ( 42 !urls || 43 urls.every(url => 44 ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix)) 45 ) 46 ); 47 } 48 49 export class _DiscoveryStreamBase extends React.PureComponent { 50 constructor(props) { 51 super(props); 52 this.onStyleMount = this.onStyleMount.bind(this); 53 } 54 55 onStyleMount(style) { 56 // Unmounting style gets rid of old styles, so nothing else to do 57 if (!style) { 58 return; 59 } 60 61 const { sheet } = style; 62 const styles = JSON.parse(style.dataset.styles); 63 styles.forEach((row, rowIndex) => { 64 row.forEach((component, componentIndex) => { 65 // Nothing to do without optional styles overrides 66 if (!component) { 67 return; 68 } 69 70 Object.entries(component).forEach(([selectors, declarations]) => { 71 // Start with a dummy rule to validate declarations and selectors 72 sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`); 73 const [rule] = sheet.cssRules; 74 75 // Validate declarations and remove any offenders. CSSOM silently 76 // discards invalid entries, so here we apply extra restrictions. 77 rule.style = declarations; 78 [...rule.style].forEach(property => { 79 const value = rule.style[property]; 80 if (!isAllowedCSS(property, value)) { 81 console.error(`Bad CSS declaration ${property}: ${value}`); 82 rule.style.removeProperty(property); 83 } 84 }); 85 86 // Set the actual desired selectors scoped to the component 87 const prefix = `.ds-layout > .ds-column:nth-child(${ 88 rowIndex + 1 89 }) .ds-column-grid > :nth-child(${componentIndex + 1})`; 90 // NB: Splitting on "," doesn't work with strings with commas, but 91 // we're okay with not supporting those selectors 92 rule.selectorText = selectors 93 .split(",") 94 .map( 95 selector => 96 prefix + 97 // Assume :pseudo-classes are for component instead of descendant 98 (selector[0] === ":" ? "" : " ") + 99 selector 100 ) 101 .join(","); 102 103 // CSSOM silently ignores bad selectors, so we'll be noisy instead 104 if (rule.selectorText === DUMMY_CSS_SELECTOR) { 105 console.error(`Bad CSS selector ${selectors}`); 106 } 107 }); 108 }); 109 }); 110 } 111 112 renderComponent(component) { 113 switch (component.type) { 114 case "Highlights": 115 return <Highlights />; 116 case "TopSites": 117 return ( 118 <div className="ds-top-sites"> 119 <TopSites isFixed={true} title={component.header?.title} /> 120 </div> 121 ); 122 case "Message": 123 return ( 124 <DSMessage 125 title={component.header && component.header.title} 126 subtitle={component.header && component.header.subtitle} 127 link_text={component.header && component.header.link_text} 128 link_url={component.header && component.header.link_url} 129 icon={component.header && component.header.icon} 130 /> 131 ); 132 case "SectionTitle": 133 return <SectionTitle header={component.header} />; 134 case "Navigation": 135 return ( 136 <Navigation 137 dispatch={this.props.dispatch} 138 links={component.properties.links} 139 extraLinks={component.properties.extraLinks} 140 alignment={component.properties.alignment} 141 explore_topics={component.properties.explore_topics} 142 header={component.header} 143 locale={this.props.App.locale} 144 newFooterSection={component.newFooterSection} 145 privacyNoticeURL={component.properties.privacyNoticeURL} 146 /> 147 ); 148 case "CardGrid": { 149 const sectionsEnabled = 150 this.props.Prefs.values["discoverystream.sections.enabled"]; 151 if (sectionsEnabled) { 152 return ( 153 <CardSections 154 feed={component.feed} 155 data={component.data} 156 dispatch={this.props.dispatch} 157 type={component.type} 158 firstVisibleTimestamp={this.props.firstVisibleTimestamp} 159 ctaButtonSponsors={component.properties.ctaButtonSponsors} 160 ctaButtonVariant={component.properties.ctaButtonVariant} 161 placeholder={this.props.placeholder} 162 /> 163 ); 164 } 165 return ( 166 <CardGrid 167 title={component.header && component.header.title} 168 data={component.data} 169 feed={component.feed} 170 widgets={component.widgets} 171 type={component.type} 172 dispatch={this.props.dispatch} 173 items={component.properties.items} 174 hybridLayout={component.properties.hybridLayout} 175 hideCardBackground={component.properties.hideCardBackground} 176 fourCardLayout={component.properties.fourCardLayout} 177 compactGrid={component.properties.compactGrid} 178 ctaButtonSponsors={component.properties.ctaButtonSponsors} 179 ctaButtonVariant={component.properties.ctaButtonVariant} 180 hideDescriptions={this.props.DiscoveryStream.hideDescriptions} 181 firstVisibleTimestamp={this.props.firstVisibleTimestamp} 182 spocPositions={component.spocs?.positions} 183 placeholder={this.props.placeholder} 184 /> 185 ); 186 } 187 case "HorizontalRule": 188 return <HorizontalRule />; 189 case "PrivacyLink": 190 return <PrivacyLink properties={component.properties} />; 191 case "Widgets": 192 return <Widgets />; 193 default: 194 return <div>{component.type}</div>; 195 } 196 } 197 198 renderStyles(styles) { 199 // Use json string as both the key and styles to render so React knows when 200 // to unmount and mount a new instance for new styles. 201 const json = JSON.stringify(styles); 202 return <style key={json} data-styles={json} ref={this.onStyleMount} />; 203 } 204 205 render() { 206 const { locale } = this.props; 207 // Bug 1980459 - Note that selectLayoutRender acts as a selector that transforms layout data based on current 208 // preferences and experiment flags. It runs after Redux state is populated but before render. 209 // Components removed in selectLayoutRender (e.g., Widgets or TopSites) will not appear in the 210 // layoutRender result, and therefore will not be rendered here regardless of logic below. 211 212 // Select layout renders data by adding spocs and position to recommendations 213 const { layoutRender } = selectLayoutRender({ 214 state: this.props.DiscoveryStream, 215 prefs: this.props.Prefs.values, 216 locale, 217 }); 218 const sectionsEnabled = 219 this.props.Prefs.values["discoverystream.sections.enabled"]; 220 const { config } = this.props.DiscoveryStream; 221 const topicSelectionEnabled = 222 this.props.Prefs.values["discoverystream.topicSelection.enabled"]; 223 const reportAdsEnabled = 224 this.props.Prefs.values["discoverystream.reportAds.enabled"]; 225 const spocsEnabled = this.props.Prefs.values["unifiedAds.spocs.enabled"]; 226 227 // Allow rendering without extracting special components 228 if (!config.collapsible) { 229 return this.renderLayout(layoutRender); 230 } 231 232 // Find the first component of a type and remove it from layout 233 const extractComponent = type => { 234 for (const [rowIndex, row] of Object.entries(layoutRender)) { 235 for (const [index, component] of Object.entries(row.components)) { 236 if (component.type === type) { 237 // Remove the row if it was the only component or the single item 238 if (row.components.length === 1) { 239 layoutRender.splice(rowIndex, 1); 240 } else { 241 row.components.splice(index, 1); 242 } 243 return component; 244 } 245 } 246 } 247 return null; 248 }; 249 250 // Get "topstories" Section state for default values 251 const topStories = this.props.Sections.find(s => s.id === "topstories"); 252 253 if (!topStories) { 254 return null; 255 } 256 257 // Extract TopSites to render before the rest and Message to use for header 258 const topSites = extractComponent("TopSites"); 259 260 // There are two ways to enable widgets: 261 // Via `widgets.system.*` prefs or Nimbus experiment 262 const widgetsNimbusTrainhopEnabled = 263 this.props.Prefs.values.trainhopConfig?.widgets?.enabled; 264 const widgetsNimbusEnabled = this.props.Prefs.values.widgetsConfig?.enabled; 265 const widgetsSystemPrefsEnabled = 266 this.props.Prefs.values["widgets.system.enabled"]; 267 268 const widgets = 269 widgetsNimbusTrainhopEnabled || 270 widgetsNimbusEnabled || 271 widgetsSystemPrefsEnabled; 272 273 const message = extractComponent("Message") || { 274 header: { 275 link_text: topStories.learnMore.link.message, 276 link_url: topStories.learnMore.link.href, 277 title: topStories.title, 278 }, 279 }; 280 281 const privacyLinkComponent = extractComponent("PrivacyLink"); 282 let learnMore = { 283 link: { 284 href: message.header.link_url, 285 message: message.header.link_text, 286 }, 287 }; 288 let sectionTitle = message.header.title; 289 let subTitle = ""; 290 291 const { DiscoveryStream } = this.props; 292 293 return ( 294 <React.Fragment> 295 {/* Reporting stories/articles will only be available in sections, not the default card grid */} 296 {((reportAdsEnabled && spocsEnabled) || sectionsEnabled) && ( 297 <ReportContent spocs={DiscoveryStream.spocs} /> 298 )} 299 300 {topSites && 301 this.renderLayout([ 302 { 303 width: 12, 304 components: [topSites], 305 sectionType: "topsites", 306 }, 307 ])} 308 {widgets && 309 this.renderLayout([ 310 { 311 width: 12, 312 components: [{ type: "Widgets" }], 313 sectionType: "widgets", 314 }, 315 ])} 316 {!!layoutRender.length && ( 317 <CollapsibleSection 318 className="ds-layout" 319 collapsed={topStories.pref.collapsed} 320 dispatch={this.props.dispatch} 321 id={topStories.id} 322 isFixed={true} 323 learnMore={learnMore} 324 privacyNoticeURL={topStories.privacyNoticeURL} 325 showPrefName={topStories.pref.feed} 326 title={sectionTitle} 327 subTitle={subTitle} 328 mayHaveTopicsSelection={topicSelectionEnabled} 329 sectionsEnabled={sectionsEnabled} 330 eventSource="CARDGRID" 331 > 332 {this.renderLayout(layoutRender)} 333 </CollapsibleSection> 334 )} 335 {this.renderLayout([ 336 { 337 width: 12, 338 components: [{ type: "Highlights" }], 339 }, 340 ])} 341 {privacyLinkComponent && 342 this.renderLayout([ 343 { 344 width: 12, 345 components: [privacyLinkComponent], 346 }, 347 ])} 348 </React.Fragment> 349 ); 350 } 351 352 renderLayout(layoutRender) { 353 const styles = []; 354 let [data] = layoutRender; 355 // Add helper class for topsites 356 const sectionClass = data.sectionType 357 ? `ds-layout-${data.sectionType}` 358 : ""; 359 360 return ( 361 <div className={`discovery-stream ds-layout ${sectionClass}`}> 362 {layoutRender.map((row, rowIndex) => ( 363 <div 364 key={`row-${rowIndex}`} 365 className={`ds-column ds-column-${row.width}`} 366 > 367 <div className="ds-column-grid"> 368 {row.components.map((component, componentIndex) => { 369 if (!component) { 370 return null; 371 } 372 styles[rowIndex] = [ 373 ...(styles[rowIndex] || []), 374 component.styles, 375 ]; 376 return ( 377 <div key={`component-${componentIndex}`}> 378 {this.renderComponent(component, row.width)} 379 </div> 380 ); 381 })} 382 </div> 383 </div> 384 ))} 385 {this.renderStyles(styles)} 386 </div> 387 ); 388 } 389 } 390 391 export const DiscoveryStreamBase = connect(state => ({ 392 DiscoveryStream: state.DiscoveryStream, 393 Prefs: state.Prefs, 394 Sections: state.Sections, 395 document: globalThis.document, 396 App: state.App, 397 }))(_DiscoveryStreamBase);