diff options
Diffstat (limited to 'browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx')
-rw-r--r-- | browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx | 393 |
1 files changed, 393 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx new file mode 100644 index 0000000000..590223a981 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx @@ -0,0 +1,393 @@ +/* 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 http://mozilla.org/MPL/2.0/. */ + +import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { connect } from "react-redux"; +import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage"; +import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal"; +import { DSSignup } from "content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup"; +import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo"; +import { Hero } from "content-src/components/DiscoveryStreamComponents/Hero/Hero"; +import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights"; +import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; +import { List } from "content-src/components/DiscoveryStreamComponents/List/List"; +import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; +import React from "react"; +import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle"; +import { selectLayoutRender } from "content-src/lib/selectLayoutRender"; +import { TopSites } from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites"; + +const ALLOWED_CSS_URL_PREFIXES = [ + "chrome://", + "resource://", + "https://img-getpocket.cdn.mozilla.net/", +]; +const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR"; + +/** + * Validate a CSS declaration. The values are assumed to be normalized by CSSOM. + */ +export function isAllowedCSS(property, value) { + // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are + // exposed but their values aren't resulting in getting nothing. Fortunately, + // we don't care about validating the values of the current set of properties. + if (value === undefined) { + return true; + } + + // Make sure all urls are of the allowed protocols/prefixes + const urls = value.match(/url\("[^"]+"\)/g); + return ( + !urls || + urls.every(url => + ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix)) + ) + ); +} + +export class _DiscoveryStreamBase extends React.PureComponent { + constructor(props) { + super(props); + this.onStyleMount = this.onStyleMount.bind(this); + } + + onStyleMount(style) { + // Unmounting style gets rid of old styles, so nothing else to do + if (!style) { + return; + } + + const { sheet } = style; + const styles = JSON.parse(style.dataset.styles); + styles.forEach((row, rowIndex) => { + row.forEach((component, componentIndex) => { + // Nothing to do without optional styles overrides + if (!component) { + return; + } + + Object.entries(component).forEach(([selectors, declarations]) => { + // Start with a dummy rule to validate declarations and selectors + sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`); + const [rule] = sheet.cssRules; + + // Validate declarations and remove any offenders. CSSOM silently + // discards invalid entries, so here we apply extra restrictions. + rule.style = declarations; + [...rule.style].forEach(property => { + const value = rule.style[property]; + if (!isAllowedCSS(property, value)) { + console.error(`Bad CSS declaration ${property}: ${value}`); // eslint-disable-line no-console + rule.style.removeProperty(property); + } + }); + + // Set the actual desired selectors scoped to the component + const prefix = `.ds-layout > .ds-column:nth-child(${rowIndex + + 1}) .ds-column-grid > :nth-child(${componentIndex + 1})`; + // NB: Splitting on "," doesn't work with strings with commas, but + // we're okay with not supporting those selectors + rule.selectorText = selectors + .split(",") + .map( + selector => + prefix + + // Assume :pseudo-classes are for component instead of descendant + (selector[0] === ":" ? "" : " ") + + selector + ) + .join(","); + + // CSSOM silently ignores bad selectors, so we'll be noisy instead + if (rule.selectorText === DUMMY_CSS_SELECTOR) { + console.error(`Bad CSS selector ${selectors}`); // eslint-disable-line no-console + } + }); + }); + }); + } + + renderComponent(component, embedWidth) { + const ENGAGEMENT_LABEL_ENABLED = this.props.Prefs.values[ + `discoverystream.engagementLabelEnabled` + ]; + + switch (component.type) { + case "Highlights": + return <Highlights />; + case "TopSites": + let promoAlignment; + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { + promoAlignment = + component.spocs.positions[0].index === 0 ? "left" : "right"; + } + return ( + <TopSites + header={component.header} + data={component.data} + promoAlignment={promoAlignment} + /> + ); + case "TextPromo": + return ( + <DSTextPromo + dispatch={this.props.dispatch} + type={component.type} + data={component.data} + /> + ); + case "Signup": + return ( + <DSSignup + dispatch={this.props.dispatch} + type={component.type} + data={component.data} + /> + ); + case "Message": + return ( + <DSMessage + title={component.header && component.header.title} + subtitle={component.header && component.header.subtitle} + link_text={component.header && component.header.link_text} + link_url={component.header && component.header.link_url} + icon={component.header && component.header.icon} + /> + ); + case "SectionTitle": + return <SectionTitle header={component.header} />; + case "Navigation": + return ( + <Navigation + dispatch={this.props.dispatch} + links={component.properties.links} + alignment={component.properties.alignment} + display_variant={component.properties.display_variant} + explore_topics={component.properties.explore_topics} + header={component.header} + /> + ); + case "CollectionCardGrid": + const { DiscoveryStream } = this.props; + return ( + <CollectionCardGrid + data={component.data} + feed={component.feed} + spocs={DiscoveryStream.spocs} + placement={component.placement} + border={component.properties.border} + type={component.type} + items={component.properties.items} + cta_variant={component.cta_variant} + display_engagement_labels={ENGAGEMENT_LABEL_ENABLED} + dismissible={this.props.DiscoveryStream.isCollectionDismissible} + dispatch={this.props.dispatch} + /> + ); + case "CardGrid": + return ( + <CardGrid + enable_video_playheads={ + !!component.properties.enable_video_playheads + } + title={component.header && component.header.title} + display_variant={component.properties.display_variant} + data={component.data} + feed={component.feed} + border={component.properties.border} + type={component.type} + dispatch={this.props.dispatch} + items={component.properties.items} + cta_variant={component.cta_variant} + display_engagement_labels={ENGAGEMENT_LABEL_ENABLED} + /> + ); + case "Hero": + return ( + <Hero + subComponentType={embedWidth >= 9 ? `cards` : `list`} + feed={component.feed} + title={component.header && component.header.title} + data={component.data} + border={component.properties.border} + type={component.type} + dispatch={this.props.dispatch} + items={component.properties.items} + /> + ); + case "HorizontalRule": + return <HorizontalRule />; + case "List": + return ( + <List + data={component.data} + feed={component.feed} + fullWidth={component.properties.full_width} + hasBorders={component.properties.border === "border"} + hasImages={component.properties.has_images} + hasNumbers={component.properties.has_numbers} + items={component.properties.items} + type={component.type} + header={component.header} + /> + ); + default: + return <div>{component.type}</div>; + } + } + + renderStyles(styles) { + // Use json string as both the key and styles to render so React knows when + // to unmount and mount a new instance for new styles. + const json = JSON.stringify(styles); + return <style key={json} data-styles={json} ref={this.onStyleMount} />; + } + + render() { + // Select layout render data by adding spocs and position to recommendations + const { layoutRender } = selectLayoutRender({ + state: this.props.DiscoveryStream, + prefs: this.props.Prefs.values, + locale: this.props.locale, + }); + const { config } = this.props.DiscoveryStream; + + // Allow rendering without extracting special components + if (!config.collapsible) { + return this.renderLayout(layoutRender); + } + + // Find the first component of a type and remove it from layout + const extractComponent = type => { + for (const [rowIndex, row] of Object.entries(layoutRender)) { + for (const [index, component] of Object.entries(row.components)) { + if (component.type === type) { + // Remove the row if it was the only component or the single item + if (row.components.length === 1) { + layoutRender.splice(rowIndex, 1); + } else { + row.components.splice(index, 1); + } + return component; + } + } + } + return null; + }; + + // Get "topstories" Section state for default values + const topStories = this.props.Sections.find(s => s.id === "topstories"); + + if (!topStories) { + return null; + } + + // Extract TopSites to render before the rest and Message to use for header + const topSites = extractComponent("TopSites"); + const sponsoredCollection = extractComponent("CollectionCardGrid"); + const message = extractComponent("Message") || { + header: { + link_text: topStories.learnMore.link.message, + link_url: topStories.learnMore.link.href, + title: topStories.title, + }, + }; + + // Render a DS-style TopSites then the rest if any in a collapsible section + return ( + <React.Fragment> + {this.props.DiscoveryStream.isPrivacyInfoModalVisible && ( + <DSPrivacyModal dispatch={this.props.dispatch} /> + )} + {topSites && + this.renderLayout([ + { + width: 12, + components: [topSites], + }, + ])} + {sponsoredCollection && + this.renderLayout([ + { + width: 12, + components: [sponsoredCollection], + }, + ])} + {!!layoutRender.length && ( + <CollapsibleSection + className="ds-layout" + collapsed={topStories.pref.collapsed} + dispatch={this.props.dispatch} + icon={topStories.icon} + id={topStories.id} + isFixed={true} + learnMore={{ + link: { + href: message.header.link_url, + message: message.header.link_text, + }, + }} + privacyNoticeURL={topStories.privacyNoticeURL} + showPrefName={topStories.pref.feed} + title={message.header.title} + eventSource="CARDGRID" + > + {this.renderLayout(layoutRender)} + </CollapsibleSection> + )} + {this.renderLayout([ + { + width: 12, + components: [{ type: "Highlights" }], + }, + ])} + </React.Fragment> + ); + } + + renderLayout(layoutRender) { + const styles = []; + return ( + <div className="discovery-stream ds-layout"> + {layoutRender.map((row, rowIndex) => ( + <div + key={`row-${rowIndex}`} + className={`ds-column ds-column-${row.width}`} + > + <div className="ds-column-grid"> + {row.components.map((component, componentIndex) => { + if (!component) { + return null; + } + styles[rowIndex] = [ + ...(styles[rowIndex] || []), + component.styles, + ]; + return ( + <div key={`component-${componentIndex}`}> + {this.renderComponent(component, row.width)} + </div> + ); + })} + </div> + </div> + ))} + {this.renderStyles(styles)} + </div> + ); + } +} + +export const DiscoveryStreamBase = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, + Prefs: state.Prefs, + Sections: state.Sections, + document: global.document, +}))(_DiscoveryStreamBase); |