/* 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 ;
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 (
);
case "SectionTitle":
return ;
case "Navigation":
return (
);
case "CardGrid": {
const sectionsEnabled =
this.props.Prefs.values["discoverystream.sections.enabled"];
if (sectionsEnabled) {
return (
);
}
return (
);
}
case "HorizontalRule":
return ;
case "PrivacyLink":
return ;
case "Widgets":
return ;
default:
return {component.type}
;
}
}
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 ;
}
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 (
{/* Reporting stories/articles will only be available in sections, not the default card grid */}
{((reportAdsEnabled && spocsEnabled) || sectionsEnabled) && (
)}
{/**
* 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
) && (
)}
{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
) && (
)}
{!!layoutRender.length && (
{this.renderLayout(layoutRender)}
)}
{this.renderLayout([
{
width: 12,
components: [{ type: "Highlights" }],
},
])}
{privacyLinkComponent &&
this.renderLayout([
{
width: 12,
components: [privacyLinkComponent],
},
])}
);
}
renderLayout(layoutRender) {
const styles = [];
let [data] = layoutRender;
// Add helper class for topsites
const sectionClass = data.sectionType
? `ds-layout-${data.sectionType}`
: "";
return (
{layoutRender.map((row, rowIndex) => (
{row.components.map((component, componentIndex) => {
if (!component) {
return null;
}
styles[rowIndex] = [
...(styles[rowIndex] || []),
component.styles,
];
return (
{this.renderComponent(component, row.width)}
);
})}
))}
{this.renderStyles(styles)}
);
}
}
export const DiscoveryStreamBase = connect(state => ({
DiscoveryStream: state.DiscoveryStream,
Messages: state.Messages,
Prefs: state.Prefs,
Sections: state.Sections,
document: globalThis.document,
App: state.App,
}))(_DiscoveryStreamBase);