tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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);