By defining the version of the "globals" module at the top-level, we can ensure that we get the expected version of the modules in our ESLint configuration. This also means that we can pick up new globals without having to wait for dependencies to pick up the new version of the "globals" module. This also fixes the raised issues when moving across to the updated globals modules, as well as removing a couple of exclusions that are now no longer necessary. Differential Revision: https://phabricator.services.mozilla.com/D300082
448 lines
16 KiB
React
448 lines
16 KiB
React
/* 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 { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
|
|
import { connect } from "react-redux";
|
|
import { ReportContent } from "../DiscoveryStreamComponents/ReportContent/ReportContent";
|
|
import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights";
|
|
import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
|
|
// eslint-disable-next-line no-shadow
|
|
import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
|
|
import { PrivacyLink } from "content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink";
|
|
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/TopSites/TopSites";
|
|
import { CardSections } from "../DiscoveryStreamComponents/CardSections/CardSections";
|
|
import { Widgets } from "content-src/components/Widgets/Widgets";
|
|
import {
|
|
ASROUTER_NEWTAB_MESSAGE_POSITIONS,
|
|
shouldShowASRouterNewTabMessage,
|
|
} from "../../lib/asrouter-message-utils.mjs";
|
|
import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
|
|
import { MessageWrapper } from "content-src/components/MessageWrapper/MessageWrapper";
|
|
import { ExternalComponentWrapper } from "content-src/components/ExternalComponentWrapper/ExternalComponentWrapper";
|
|
|
|
// @nova-cleanup(remove-pref): Remove PREF_NOVA_ENABLED
|
|
const PREF_NOVA_ENABLED = "nova.enabled";
|
|
|
|
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}`);
|
|
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}`);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
renderComponent(component) {
|
|
switch (component.type) {
|
|
case "Highlights":
|
|
return <Highlights />;
|
|
case "TopSites":
|
|
// @nova-cleanup(remove-conditional): Remove this guard when DiscoveryStreamBase
|
|
// is no longer used in the Nova layout
|
|
if (this.props.Prefs.values[PREF_NOVA_ENABLED]) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="ds-top-sites">
|
|
<TopSites isFixed={true} title={component.header?.title} />
|
|
</div>
|
|
);
|
|
case "SectionTitle":
|
|
return <SectionTitle header={component.header} />;
|
|
case "Navigation":
|
|
return (
|
|
<Navigation
|
|
dispatch={this.props.dispatch}
|
|
links={component.properties.links}
|
|
extraLinks={component.properties.extraLinks}
|
|
alignment={component.properties.alignment}
|
|
explore_topics={component.properties.explore_topics}
|
|
header={component.header}
|
|
locale={this.props.App.locale}
|
|
newFooterSection={component.newFooterSection}
|
|
privacyNoticeURL={component.properties.privacyNoticeURL}
|
|
/>
|
|
);
|
|
case "CardGrid": {
|
|
const sectionsEnabled =
|
|
this.props.Prefs.values["discoverystream.sections.enabled"];
|
|
if (sectionsEnabled) {
|
|
return (
|
|
<CardSections
|
|
feed={component.feed}
|
|
data={component.data}
|
|
dispatch={this.props.dispatch}
|
|
type={component.type}
|
|
ctaButtonSponsors={component.properties.ctaButtonSponsors}
|
|
ctaButtonVariant={component.properties.ctaButtonVariant}
|
|
spocsLoading={this.props.spocsLoading}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<CardGrid
|
|
title={component.header && component.header.title}
|
|
data={component.data}
|
|
feed={component.feed}
|
|
widgets={component.widgets}
|
|
type={component.type}
|
|
dispatch={this.props.dispatch}
|
|
items={component.properties.items}
|
|
hybridLayout={component.properties.hybridLayout}
|
|
hideCardBackground={component.properties.hideCardBackground}
|
|
fourCardLayout={component.properties.fourCardLayout}
|
|
compactGrid={component.properties.compactGrid}
|
|
ctaButtonSponsors={component.properties.ctaButtonSponsors}
|
|
ctaButtonVariant={component.properties.ctaButtonVariant}
|
|
hideDescriptions={this.props.DiscoveryStream.hideDescriptions}
|
|
spocPositions={component.spocs?.positions}
|
|
placeholder={this.props.spocsLoading}
|
|
/>
|
|
);
|
|
}
|
|
case "HorizontalRule":
|
|
return <HorizontalRule />;
|
|
case "PrivacyLink":
|
|
return <PrivacyLink properties={component.properties} />;
|
|
case "Widgets":
|
|
return <Widgets />;
|
|
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() {
|
|
const { locale } = this.props;
|
|
// Bug 1980459 - Note that selectLayoutRender acts as a selector that transforms layout data based on current
|
|
// preferences and experiment flags. It runs after Redux state is populated but before render.
|
|
// Components removed in selectLayoutRender (e.g., Widgets or TopSites) will not appear in the
|
|
// layoutRender result, and therefore will not be rendered here regardless of logic below.
|
|
|
|
// Select layout renders data by adding spocs and position to recommendations
|
|
const { layoutRender } = selectLayoutRender({
|
|
state: this.props.DiscoveryStream,
|
|
prefs: this.props.Prefs.values,
|
|
locale,
|
|
});
|
|
// @nova-cleanup(remove-pref): Delete this line; remove all !novaEnabled guards on ASRouterNewTabMessage blocks below.
|
|
const novaEnabled = this.props.Prefs.values[PREF_NOVA_ENABLED];
|
|
const sectionsEnabled =
|
|
this.props.Prefs.values["discoverystream.sections.enabled"];
|
|
const topicSelectionEnabled =
|
|
this.props.Prefs.values["discoverystream.topicSelection.enabled"];
|
|
const reportAdsEnabled =
|
|
this.props.Prefs.values["discoverystream.reportAds.enabled"];
|
|
const spocsEnabled = this.props.Prefs.values["unifiedAds.spocs.enabled"];
|
|
|
|
// 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");
|
|
|
|
// There are two ways to enable widgets:
|
|
// Via `widgets.system.*` prefs or Nimbus experiment
|
|
const widgetsNimbusTrainhopEnabled =
|
|
this.props.Prefs.values.trainhopConfig?.widgets?.enabled;
|
|
const widgetsNimbusEnabled = this.props.Prefs.values.widgetsConfig?.enabled;
|
|
const widgetsSystemPrefsEnabled =
|
|
this.props.Prefs.values["widgets.system.enabled"];
|
|
|
|
const widgets =
|
|
widgetsNimbusTrainhopEnabled ||
|
|
widgetsNimbusEnabled ||
|
|
widgetsSystemPrefsEnabled;
|
|
|
|
const message = extractComponent("Message") || {
|
|
header: {
|
|
link_text: topStories.learnMore.link.message,
|
|
link_url: topStories.learnMore.link.href,
|
|
title: topStories.title,
|
|
},
|
|
};
|
|
|
|
const privacyLinkComponent = extractComponent("PrivacyLink");
|
|
let learnMore = {
|
|
link: {
|
|
href: message.header.link_url,
|
|
message: message.header.link_text,
|
|
},
|
|
};
|
|
let sectionTitle = message.header.title;
|
|
let subTitle = "";
|
|
|
|
const { DiscoveryStream } = this.props;
|
|
|
|
return (
|
|
<React.Fragment>
|
|
{/* Reporting stories/articles will only be available in sections, not the default card grid */}
|
|
{((reportAdsEnabled && spocsEnabled) || sectionsEnabled) && (
|
|
<ReportContent spocs={DiscoveryStream.spocs} />
|
|
)}
|
|
|
|
{/**
|
|
* The ABOVE_TOPSITES ASRouterNewTabMessage rendering actually occurs in Base.jsx
|
|
* for silly reasons. Essentially, it's easier for the browser_asrouter_newtab_message
|
|
* mochitest-browser test to render a test message if it doesn't have to rely on
|
|
* DiscoveryStreamBase being rendered. Thankfully, this can all be removed
|
|
* after Nova ships.
|
|
*/}
|
|
|
|
{topSites &&
|
|
this.renderLayout([
|
|
{
|
|
width: 12,
|
|
components: [topSites],
|
|
sectionType: "topsites",
|
|
},
|
|
])}
|
|
|
|
{
|
|
// @nova-cleanup(remove-conditional): Remove this entire block; Base.jsx handles ABOVE_WIDGETS in the Nova layout.
|
|
}
|
|
{!novaEnabled &&
|
|
shouldShowASRouterNewTabMessage(
|
|
this.props.Messages,
|
|
"ASRouterNewTabMessage",
|
|
ASROUTER_NEWTAB_MESSAGE_POSITIONS.ABOVE_WIDGETS
|
|
) && (
|
|
<ErrorBoundary>
|
|
<MessageWrapper dispatch={this.props.dispatch}>
|
|
<ExternalComponentWrapper
|
|
type="ASROUTER_NEWTAB_MESSAGE"
|
|
messageData={this.props.Messages.messageData}
|
|
className="asrouter-newtab-message-wrapper"
|
|
/>
|
|
</MessageWrapper>
|
|
</ErrorBoundary>
|
|
)}
|
|
|
|
{widgets &&
|
|
this.renderLayout([
|
|
{
|
|
width: 12,
|
|
components: [{ type: "Widgets" }],
|
|
sectionType: "widgets",
|
|
},
|
|
])}
|
|
|
|
{
|
|
//@nova-cleanup(remove-conditional): Remove this entire block; Base.jsx handles ABOVE_CONTENT_FEED in the Nova layout. */
|
|
}
|
|
{!novaEnabled &&
|
|
shouldShowASRouterNewTabMessage(
|
|
this.props.Messages,
|
|
"ASRouterNewTabMessage",
|
|
ASROUTER_NEWTAB_MESSAGE_POSITIONS.ABOVE_CONTENT_FEED
|
|
) && (
|
|
<ErrorBoundary>
|
|
<MessageWrapper dispatch={this.props.dispatch}>
|
|
<ExternalComponentWrapper
|
|
type="ASROUTER_NEWTAB_MESSAGE"
|
|
messageData={this.props.Messages.messageData}
|
|
className="asrouter-newtab-message-wrapper"
|
|
/>
|
|
</MessageWrapper>
|
|
</ErrorBoundary>
|
|
)}
|
|
|
|
{!!layoutRender.length && (
|
|
<CollapsibleSection
|
|
className="ds-layout"
|
|
collapsed={topStories.pref.collapsed}
|
|
dispatch={this.props.dispatch}
|
|
id={topStories.id}
|
|
isFixed={true}
|
|
learnMore={learnMore}
|
|
privacyNoticeURL={topStories.privacyNoticeURL}
|
|
showPrefName={topStories.pref.feed}
|
|
title={sectionTitle}
|
|
subTitle={subTitle}
|
|
mayHaveTopicsSelection={topicSelectionEnabled}
|
|
sectionsEnabled={sectionsEnabled}
|
|
eventSource="CARDGRID"
|
|
>
|
|
{this.renderLayout(layoutRender)}
|
|
</CollapsibleSection>
|
|
)}
|
|
{this.renderLayout([
|
|
{
|
|
width: 12,
|
|
components: [{ type: "Highlights" }],
|
|
},
|
|
])}
|
|
{privacyLinkComponent &&
|
|
this.renderLayout([
|
|
{
|
|
width: 12,
|
|
components: [privacyLinkComponent],
|
|
},
|
|
])}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
renderLayout(layoutRender) {
|
|
const styles = [];
|
|
let [data] = layoutRender;
|
|
// Add helper class for topsites
|
|
const sectionClass = data.sectionType
|
|
? `ds-layout-${data.sectionType}`
|
|
: "";
|
|
|
|
return (
|
|
<div className={`discovery-stream ds-layout ${sectionClass}`}>
|
|
{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,
|
|
Messages: state.Messages,
|
|
Prefs: state.Prefs,
|
|
Sections: state.Sections,
|
|
document: globalThis.document,
|
|
App: state.App,
|
|
}))(_DiscoveryStreamBase);
|