summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/content-src/components
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/content-src/components')
-rw-r--r--browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx18
-rw-r--r--browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss13
-rw-r--r--browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx1967
-rw-r--r--browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss280
-rw-r--r--browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx31
-rw-r--r--browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx35
-rw-r--r--browser/components/newtab/content-src/components/Base/Base.jsx273
-rw-r--r--browser/components/newtab/content-src/components/Base/_Base.scss143
-rw-r--r--browser/components/newtab/content-src/components/Card/Card.jsx362
-rw-r--r--browser/components/newtab/content-src/components/Card/_Card.scss331
-rw-r--r--browser/components/newtab/content-src/components/Card/types.js30
-rw-r--r--browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx116
-rw-r--r--browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss106
-rw-r--r--browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx177
-rw-r--r--browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx103
-rw-r--r--browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss68
-rw-r--r--browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx176
-rw-r--r--browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx72
-rw-r--r--browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss57
-rw-r--r--browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx11
-rw-r--r--browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx308
-rw-r--r--browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx87
-rw-r--r--browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss311
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx384
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss67
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx536
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss355
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx139
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss38
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx491
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss251
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx118
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss81
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx57
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss47
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx100
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss83
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx263
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss48
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx70
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss28
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx34
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss37
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx72
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss48
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx168
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss52
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx143
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss92
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx26
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss45
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx11
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss7
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx112
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss180
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx20
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss10
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx65
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx19
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss18
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss77
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx125
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss88
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx250
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss7
-rw-r--r--browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx68
-rw-r--r--browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss21
-rw-r--r--browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx36
-rw-r--r--browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx110
-rw-r--r--browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx21
-rw-r--r--browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss24
-rw-r--r--browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx42
-rw-r--r--browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss42
-rw-r--r--browser/components/newtab/content-src/components/Search/Search.jsx223
-rw-r--r--browser/components/newtab/content-src/components/Search/_Search.scss412
-rw-r--r--browser/components/newtab/content-src/components/Sections/Sections.jsx378
-rw-r--r--browser/components/newtab/content-src/components/Sections/_Sections.scss123
-rw-r--r--browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx192
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSite.jsx873
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx323
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx111
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx149
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSites.jsx213
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js39
-rw-r--r--browser/components/newtab/content-src/components/TopSites/_TopSites.scss628
-rw-r--r--browser/components/newtab/content-src/components/Topics/Topics.jsx33
-rw-r--r--browser/components/newtab/content-src/components/Topics/_Topics.scss24
87 files changed, 14022 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx
new file mode 100644
index 0000000000..3aab52cdff
--- /dev/null
+++ b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx
@@ -0,0 +1,18 @@
+/* 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 React from "react";
+
+export function A11yLinkButton(props) {
+ // function for merging classes, if necessary
+ let className = "a11y-link-button";
+ if (props.className) {
+ className += ` ${props.className}`;
+ }
+ return (
+ <button type="button" {...props} className={className}>
+ {props.children}
+ </button>
+ );
+}
diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss
new file mode 100644
index 0000000000..c87fc93b60
--- /dev/null
+++ b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss
@@ -0,0 +1,13 @@
+
+.a11y-link-button {
+ border: 0;
+ padding: 0;
+ cursor: pointer;
+ text-align: unset;
+ color: var(--newtab-primary-action-background);
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
new file mode 100644
index 0000000000..3762be9c99
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
@@ -0,0 +1,1967 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { ASRouterUtils } from "../../asrouter/asrouter-utils";
+import { connect } from "react-redux";
+import React from "react";
+import { SimpleHashRouter } from "./SimpleHashRouter";
+import { CopyButton } from "./CopyButton";
+
+const Row = props => (
+ <tr className="message-item" {...props}>
+ {props.children}
+ </tr>
+);
+
+function relativeTime(timestamp) {
+ if (!timestamp) {
+ return "";
+ }
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
+ const minutes = Math.floor((Date.now() - timestamp) / 60000);
+ if (seconds < 2) {
+ return "just now";
+ } else if (seconds < 60) {
+ return `${seconds} seconds ago`;
+ } else if (minutes === 1) {
+ return "1 minute ago";
+ } else if (minutes < 600) {
+ return `${minutes} minutes ago`;
+ }
+ return new Date(timestamp).toLocaleString();
+}
+
+const LAYOUT_VARIANTS = {
+ basic: "Basic default layout (on by default in nightly)",
+ staging_spocs: "A layout with all spocs shown",
+ "dev-test-all":
+ "A little bit of everything. Good layout for testing all components",
+ "dev-test-feeds": "Stress testing for slow feeds",
+};
+
+export class ToggleStoryButton extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick() {
+ this.props.onClick(this.props.story);
+ }
+
+ render() {
+ return <button onClick={this.handleClick}>collapse/open</button>;
+ }
+}
+
+export class ToggleMessageJSON extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick() {
+ this.props.toggleJSON(this.props.msgId);
+ }
+
+ render() {
+ let iconName = this.props.isCollapsed
+ ? "icon icon-arrowhead-forward-small"
+ : "icon icon-arrowhead-down-small";
+ return (
+ <button className="clearButton" onClick={this.handleClick}>
+ <span className={iconName} />
+ </button>
+ );
+ }
+}
+
+export class TogglePrefCheckbox extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onChange = this.onChange.bind(this);
+ }
+
+ onChange(event) {
+ this.props.onChange(this.props.pref, event.target.checked);
+ }
+
+ render() {
+ return (
+ <>
+ <input
+ type="checkbox"
+ checked={this.props.checked}
+ onChange={this.onChange}
+ disabled={this.props.disabled}
+ />{" "}
+ {this.props.pref}{" "}
+ </>
+ );
+ }
+}
+
+export class Personalization extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.togglePersonalization = this.togglePersonalization.bind(this);
+ }
+
+ togglePersonalization() {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE,
+ })
+ );
+ }
+
+ render() {
+ const { lastUpdated, initialized } = this.props.state.Personalization;
+ return (
+ <React.Fragment>
+ <table>
+ <tbody>
+ <Row>
+ <td colSpan="2">
+ <TogglePrefCheckbox
+ checked={this.props.personalized}
+ pref="personalized"
+ onChange={this.togglePersonalization}
+ />
+ </td>
+ </Row>
+ <Row>
+ <td className="min">Personalization Last Updated</td>
+ <td>{relativeTime(lastUpdated) || "(no data)"}</td>
+ </Row>
+ <Row>
+ <td className="min">Personalization Initialized</td>
+ <td>{initialized ? "true" : "false"}</td>
+ </Row>
+ </tbody>
+ </table>
+ </React.Fragment>
+ );
+ }
+}
+
+export class DiscoveryStreamAdmin extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.restorePrefDefaults = this.restorePrefDefaults.bind(this);
+ this.setConfigValue = this.setConfigValue.bind(this);
+ this.expireCache = this.expireCache.bind(this);
+ this.refreshCache = this.refreshCache.bind(this);
+ this.idleDaily = this.idleDaily.bind(this);
+ this.systemTick = this.systemTick.bind(this);
+ this.syncRemoteSettings = this.syncRemoteSettings.bind(this);
+ this.changeEndpointVariant = this.changeEndpointVariant.bind(this);
+ this.onStoryToggle = this.onStoryToggle.bind(this);
+ this.state = {
+ toggledStories: {},
+ };
+ }
+
+ setConfigValue(name, value) {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE,
+ data: { name, value },
+ })
+ );
+ }
+
+ restorePrefDefaults(event) {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS,
+ })
+ );
+ }
+
+ refreshCache() {
+ const { config } = this.props.state.DiscoveryStream;
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_CONFIG_CHANGE,
+ data: config,
+ })
+ );
+ }
+
+ dispatchSimpleAction(type) {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type,
+ })
+ );
+ }
+
+ systemTick() {
+ this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYSTEM_TICK);
+ }
+
+ expireCache() {
+ this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE);
+ }
+
+ idleDaily() {
+ this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_IDLE_DAILY);
+ }
+
+ syncRemoteSettings() {
+ this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYNC_RS);
+ }
+
+ changeEndpointVariant(event) {
+ const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint;
+ if (endpoint) {
+ this.setConfigValue(
+ "layout_endpoint",
+ endpoint.replace(
+ /layout_variant=.+/,
+ `layout_variant=${event.target.value}`
+ )
+ );
+ }
+ }
+
+ renderComponent(width, component) {
+ return (
+ <table>
+ <tbody>
+ <Row>
+ <td className="min">Type</td>
+ <td>{component.type}</td>
+ </Row>
+ <Row>
+ <td className="min">Width</td>
+ <td>{width}</td>
+ </Row>
+ {component.feed && this.renderFeed(component.feed)}
+ </tbody>
+ </table>
+ );
+ }
+
+ isCurrentVariant(id) {
+ const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint;
+ const isMatch = endpoint && !!endpoint.match(`layout_variant=${id}`);
+ return isMatch;
+ }
+
+ renderFeedData(url) {
+ const { feeds } = this.props.state.DiscoveryStream;
+ const feed = feeds.data[url].data;
+ return (
+ <React.Fragment>
+ <h4>Feed url: {url}</h4>
+ <table>
+ <tbody>
+ {feed.recommendations?.map(story => this.renderStoryData(story))}
+ </tbody>
+ </table>
+ </React.Fragment>
+ );
+ }
+
+ renderFeedsData() {
+ const { feeds } = this.props.state.DiscoveryStream;
+ return (
+ <React.Fragment>
+ {Object.keys(feeds.data).map(url => this.renderFeedData(url))}
+ </React.Fragment>
+ );
+ }
+
+ renderSpocs() {
+ const { spocs } = this.props.state.DiscoveryStream;
+ let spocsData = [];
+ if (spocs.data && spocs.data.spocs && spocs.data.spocs.items) {
+ spocsData = spocs.data.spocs.items || [];
+ }
+
+ return (
+ <React.Fragment>
+ <table>
+ <tbody>
+ <Row>
+ <td className="min">spocs_endpoint</td>
+ <td>{spocs.spocs_endpoint}</td>
+ </Row>
+ <Row>
+ <td className="min">Data last fetched</td>
+ <td>{relativeTime(spocs.lastUpdated)}</td>
+ </Row>
+ </tbody>
+ </table>
+ <h4>Spoc data</h4>
+ <table>
+ <tbody>{spocsData.map(spoc => this.renderStoryData(spoc))}</tbody>
+ </table>
+ <h4>Spoc frequency caps</h4>
+ <table>
+ <tbody>
+ {spocs.frequency_caps.map(spoc => this.renderStoryData(spoc))}
+ </tbody>
+ </table>
+ </React.Fragment>
+ );
+ }
+
+ onStoryToggle(story) {
+ const { toggledStories } = this.state;
+ this.setState({
+ toggledStories: {
+ ...toggledStories,
+ [story.id]: !toggledStories[story.id],
+ },
+ });
+ }
+
+ renderStoryData(story) {
+ let storyData = "";
+ if (this.state.toggledStories[story.id]) {
+ storyData = JSON.stringify(story, null, 2);
+ }
+ return (
+ <tr className="message-item" key={story.id}>
+ <td className="message-id">
+ <span>
+ {story.id} <br />
+ </span>
+ <ToggleStoryButton story={story} onClick={this.onStoryToggle} />
+ </td>
+ <td className="message-summary">
+ <pre>{storyData}</pre>
+ </td>
+ </tr>
+ );
+ }
+
+ renderFeed(feed) {
+ const { feeds } = this.props.state.DiscoveryStream;
+ if (!feed.url) {
+ return null;
+ }
+ return (
+ <React.Fragment>
+ <Row>
+ <td className="min">Feed url</td>
+ <td>{feed.url}</td>
+ </Row>
+ <Row>
+ <td className="min">Data last fetched</td>
+ <td>
+ {relativeTime(
+ feeds.data[feed.url] ? feeds.data[feed.url].lastUpdated : null
+ ) || "(no data)"}
+ </td>
+ </Row>
+ </React.Fragment>
+ );
+ }
+
+ render() {
+ const prefToggles = "enabled hardcoded_layout show_spocs collapsible".split(
+ " "
+ );
+ const { config, lastUpdated, layout } = this.props.state.DiscoveryStream;
+ const personalized =
+ this.props.otherPrefs["discoverystream.personalization.enabled"];
+ return (
+ <div>
+ <button className="button" onClick={this.restorePrefDefaults}>
+ Restore Pref Defaults
+ </button>{" "}
+ <button className="button" onClick={this.refreshCache}>
+ Refresh Cache
+ </button>
+ <br />
+ <button className="button" onClick={this.expireCache}>
+ Expire Cache
+ </button>{" "}
+ <button className="button" onClick={this.systemTick}>
+ Trigger System Tick
+ </button>{" "}
+ <button className="button" onClick={this.idleDaily}>
+ Trigger Idle Daily
+ </button>
+ <br />
+ <button className="button" onClick={this.syncRemoteSettings}>
+ Sync Remote Settings
+ </button>
+ <table>
+ <tbody>
+ {prefToggles.map(pref => (
+ <Row key={pref}>
+ <td>
+ <TogglePrefCheckbox
+ checked={config[pref]}
+ pref={pref}
+ onChange={this.setConfigValue}
+ />
+ </td>
+ </Row>
+ ))}
+ </tbody>
+ </table>
+ <h3>Endpoint variant</h3>
+ <p>
+ You can also change this manually by changing this pref:{" "}
+ <code>browser.newtabpage.activity-stream.discoverystream.config</code>
+ </p>
+ <table
+ style={
+ config.enabled && !config.hardcoded_layout ? null : { opacity: 0.5 }
+ }
+ >
+ <tbody>
+ {Object.keys(LAYOUT_VARIANTS).map(id => (
+ <Row key={id}>
+ <td className="min">
+ <input
+ type="radio"
+ value={id}
+ checked={this.isCurrentVariant(id)}
+ onChange={this.changeEndpointVariant}
+ />
+ </td>
+ <td className="min">{id}</td>
+ <td>{LAYOUT_VARIANTS[id]}</td>
+ </Row>
+ ))}
+ </tbody>
+ </table>
+ <h3>Caching info</h3>
+ <table style={config.enabled ? null : { opacity: 0.5 }}>
+ <tbody>
+ <Row>
+ <td className="min">Data last fetched</td>
+ <td>{relativeTime(lastUpdated) || "(no data)"}</td>
+ </Row>
+ </tbody>
+ </table>
+ <h3>Layout</h3>
+ {layout.map((row, rowIndex) => (
+ <div key={`row-${rowIndex}`}>
+ {row.components.map((component, componentIndex) => (
+ <div key={`component-${componentIndex}`} className="ds-component">
+ {this.renderComponent(row.width, component)}
+ </div>
+ ))}
+ </div>
+ ))}
+ <h3>Personalization</h3>
+ <Personalization
+ personalized={personalized}
+ dispatch={this.props.dispatch}
+ state={{
+ Personalization: this.props.state.Personalization,
+ }}
+ />
+ <h3>Spocs</h3>
+ {this.renderSpocs()}
+ <h3>Feeds Data</h3>
+ {this.renderFeedsData()}
+ </div>
+ );
+ }
+}
+
+export class ASRouterAdminInner extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleEnabledToggle = this.handleEnabledToggle.bind(this);
+ this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this);
+ this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this);
+ this.onChangeMessageGroupsFilter =
+ this.onChangeMessageGroupsFilter.bind(this);
+ this.unblockAll = this.unblockAll.bind(this);
+ this.handleClearAllImpressionsByProvider =
+ this.handleClearAllImpressionsByProvider.bind(this);
+ this.handleExpressionEval = this.handleExpressionEval.bind(this);
+ this.onChangeTargetingParameters =
+ this.onChangeTargetingParameters.bind(this);
+ this.onChangeAttributionParameters =
+ this.onChangeAttributionParameters.bind(this);
+ this.setAttribution = this.setAttribution.bind(this);
+ this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this);
+ this.onNewTargetingParams = this.onNewTargetingParams.bind(this);
+ this.handleOpenPB = this.handleOpenPB.bind(this);
+ this.selectPBMessage = this.selectPBMessage.bind(this);
+ this.resetPBJSON = this.resetPBJSON.bind(this);
+ this.resetPBMessageState = this.resetPBMessageState.bind(this);
+ this.toggleJSON = this.toggleJSON.bind(this);
+ this.toggleAllMessages = this.toggleAllMessages.bind(this);
+ this.resetGroups = this.resetGroups.bind(this);
+ this.onMessageFromParent = this.onMessageFromParent.bind(this);
+ this.setStateFromParent = this.setStateFromParent.bind(this);
+ this.setState = this.setState.bind(this);
+ this.state = {
+ messageFilter: "all",
+ messageGroupsFilter: "all",
+ collapsedMessages: [],
+ modifiedMessages: [],
+ selectedPBMessage: "",
+ evaluationStatus: {},
+ stringTargetingParameters: null,
+ newStringTargetingParameters: null,
+ copiedToClipboard: false,
+ attributionParameters: {
+ source: "addons.mozilla.org",
+ medium: "referral",
+ campaign: "non-fx-button",
+ content: `rta:${btoa("uBlock0@raymondhill.net")}`,
+ experiment: "ua-onboarding",
+ variation: "chrome",
+ ua: "Google Chrome 123",
+ dltoken: "00000000-0000-0000-0000-000000000000",
+ },
+ };
+ }
+
+ onMessageFromParent({ type, data }) {
+ // These only exists due to onPrefChange events in ASRouter
+ switch (type) {
+ case "UpdateAdminState": {
+ this.setStateFromParent(data);
+ break;
+ }
+ }
+ }
+
+ setStateFromParent(data) {
+ this.setState(data);
+ if (!this.state.stringTargetingParameters) {
+ const stringTargetingParameters = {};
+ for (const param of Object.keys(data.targetingParameters)) {
+ stringTargetingParameters[param] = JSON.stringify(
+ data.targetingParameters[param],
+ null,
+ 2
+ );
+ }
+ this.setState({ stringTargetingParameters });
+ }
+ }
+
+ componentWillMount() {
+ ASRouterUtils.addListener(this.onMessageFromParent);
+ const endpoint = ASRouterUtils.getPreviewEndpoint();
+ ASRouterUtils.sendMessage({
+ type: "ADMIN_CONNECT_STATE",
+ data: { endpoint },
+ }).then(this.setStateFromParent);
+ }
+
+ handleBlock(msg) {
+ return () => ASRouterUtils.blockById(msg.id);
+ }
+
+ handleUnblock(msg) {
+ return () => ASRouterUtils.unblockById(msg.id);
+ }
+
+ resetJSON(msg) {
+ // reset the displayed JSON for the given message
+ document.getElementById(`${msg.id}-textarea`).value = JSON.stringify(
+ msg,
+ null,
+ 2
+ );
+ // remove the message from the list of modified IDs
+ let index = this.state.modifiedMessages.indexOf(msg.id);
+ this.setState(prevState => ({
+ modifiedMessages: [
+ ...prevState.modifiedMessages.slice(0, index),
+ ...prevState.modifiedMessages.slice(index + 1),
+ ],
+ }));
+ }
+
+ handleOverride(id) {
+ return () =>
+ ASRouterUtils.overrideMessage(id).then(state => {
+ this.setStateFromParent(state);
+ this.props.notifyContent({
+ message: state.message,
+ });
+ });
+ }
+
+ resetPBMessageState() {
+ // Iterate over Private Browsing messages and block/unblock each one to clear impressions
+ const PBMessages = this.state.messages.filter(
+ message => message.template === "pb_newtab"
+ ); // messages from state go here
+
+ PBMessages.forEach(message => {
+ if (message?.id) {
+ ASRouterUtils.blockById(message.id);
+ ASRouterUtils.unblockById(message.id);
+ }
+ });
+ // Clear the selected messages & radio buttons
+ document.getElementById("clear radio").checked = true;
+ this.selectPBMessage("clear");
+ }
+
+ resetPBJSON(msg) {
+ // reset the displayed JSON for the given message
+ document.getElementById(`${msg.id}-textarea`).value = JSON.stringify(
+ msg,
+ null,
+ 2
+ );
+ }
+
+ handleOpenPB() {
+ ASRouterUtils.sendMessage({
+ type: "FORCE_PRIVATE_BROWSING_WINDOW",
+ data: { message: { content: this.state.selectedPBMessage } },
+ });
+ }
+
+ expireCache() {
+ ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" });
+ }
+
+ resetPref() {
+ ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" });
+ }
+
+ resetGroups(id, value) {
+ ASRouterUtils.sendMessage({
+ type: "RESET_GROUPS_STATE",
+ }).then(this.setStateFromParent);
+ }
+
+ handleExpressionEval() {
+ const context = {};
+ for (const param of Object.keys(this.state.stringTargetingParameters)) {
+ const value = this.state.stringTargetingParameters[param];
+ context[param] = value ? JSON.parse(value) : null;
+ }
+ ASRouterUtils.sendMessage({
+ type: "EVALUATE_JEXL_EXPRESSION",
+ data: {
+ expression: this.refs.expressionInput.value,
+ context,
+ },
+ }).then(this.setStateFromParent);
+ }
+
+ onChangeTargetingParameters(event) {
+ const { name } = event.target;
+ const { value } = event.target;
+
+ this.setState(({ stringTargetingParameters }) => {
+ let targetingParametersError = null;
+ const updatedParameters = { ...stringTargetingParameters };
+ updatedParameters[name] = value;
+ try {
+ JSON.parse(value);
+ } catch (e) {
+ console.error(`Error parsing value of parameter ${name}`);
+ targetingParametersError = { id: name };
+ }
+
+ return {
+ copiedToClipboard: false,
+ evaluationStatus: {},
+ stringTargetingParameters: updatedParameters,
+ targetingParametersError,
+ };
+ });
+ }
+
+ unblockAll() {
+ return ASRouterUtils.sendMessage({
+ type: "UNBLOCK_ALL",
+ }).then(this.setStateFromParent);
+ }
+
+ handleClearAllImpressionsByProvider() {
+ const providerId = this.state.messageFilter;
+ if (!providerId) {
+ return;
+ }
+ const userPrefInfo = this.state.userPrefs;
+
+ const isUserEnabled =
+ providerId in userPrefInfo ? userPrefInfo[providerId] : true;
+
+ ASRouterUtils.sendMessage({
+ type: "DISABLE_PROVIDER",
+ data: providerId,
+ });
+ if (!isUserEnabled) {
+ ASRouterUtils.sendMessage({
+ type: "SET_PROVIDER_USER_PREF",
+ data: { id: providerId, value: true },
+ });
+ }
+ ASRouterUtils.sendMessage({
+ type: "ENABLE_PROVIDER",
+ data: providerId,
+ });
+ }
+
+ handleEnabledToggle(event) {
+ const provider = this.state.providerPrefs.find(
+ p => p.id === event.target.dataset.provider
+ );
+ const userPrefInfo = this.state.userPrefs;
+
+ const isUserEnabled =
+ provider.id in userPrefInfo ? userPrefInfo[provider.id] : true;
+ const isSystemEnabled = provider.enabled;
+ const isEnabling = event.target.checked;
+
+ if (isEnabling) {
+ if (!isUserEnabled) {
+ ASRouterUtils.sendMessage({
+ type: "SET_PROVIDER_USER_PREF",
+ data: { id: provider.id, value: true },
+ });
+ }
+ if (!isSystemEnabled) {
+ ASRouterUtils.sendMessage({
+ type: "ENABLE_PROVIDER",
+ data: provider.id,
+ });
+ }
+ } else {
+ ASRouterUtils.sendMessage({
+ type: "DISABLE_PROVIDER",
+ data: provider.id,
+ });
+ }
+
+ this.setState({ messageFilter: "all" });
+ }
+
+ handleUserPrefToggle(event) {
+ const action = {
+ type: "SET_PROVIDER_USER_PREF",
+ data: { id: event.target.dataset.provider, value: event.target.checked },
+ };
+ ASRouterUtils.sendMessage(action);
+ this.setState({ messageFilter: "all" });
+ }
+
+ onChangeMessageFilter(event) {
+ this.setState({ messageFilter: event.target.value });
+ }
+
+ onChangeMessageGroupsFilter(event) {
+ this.setState({ messageGroupsFilter: event.target.value });
+ }
+
+ // Simulate a copy event that sets to clipboard all targeting paramters and values
+ onCopyTargetingParams(event) {
+ const stringTargetingParameters = {
+ ...this.state.stringTargetingParameters,
+ };
+ for (const key of Object.keys(stringTargetingParameters)) {
+ // If the value is not set the parameter will be lost when we stringify
+ if (stringTargetingParameters[key] === undefined) {
+ stringTargetingParameters[key] = null;
+ }
+ }
+ const setClipboardData = e => {
+ e.preventDefault();
+ e.clipboardData.setData(
+ "text",
+ JSON.stringify(stringTargetingParameters, null, 2)
+ );
+ document.removeEventListener("copy", setClipboardData);
+ this.setState({ copiedToClipboard: true });
+ };
+
+ document.addEventListener("copy", setClipboardData);
+
+ document.execCommand("copy");
+ }
+
+ onNewTargetingParams(event) {
+ this.setState({ newStringTargetingParameters: event.target.value });
+ event.target.classList.remove("errorState");
+ this.refs.targetingParamsEval.innerText = "";
+
+ try {
+ const stringTargetingParameters = JSON.parse(event.target.value);
+ this.setState({ stringTargetingParameters });
+ } catch (e) {
+ event.target.classList.add("errorState");
+ this.refs.targetingParamsEval.innerText = e.message;
+ }
+ }
+
+ toggleJSON(msgId) {
+ if (this.state.collapsedMessages.includes(msgId)) {
+ let index = this.state.collapsedMessages.indexOf(msgId);
+ this.setState(prevState => ({
+ collapsedMessages: [
+ ...prevState.collapsedMessages.slice(0, index),
+ ...prevState.collapsedMessages.slice(index + 1),
+ ],
+ }));
+ } else {
+ this.setState(prevState => ({
+ collapsedMessages: prevState.collapsedMessages.concat(msgId),
+ }));
+ }
+ }
+
+ handleChange(msgId) {
+ if (!this.state.modifiedMessages.includes(msgId)) {
+ this.setState(prevState => ({
+ modifiedMessages: prevState.modifiedMessages.concat(msgId),
+ }));
+ }
+ }
+
+ renderMessageItem(msg) {
+ const isBlockedByGroup = this.state.groups
+ .filter(group => msg.groups.includes(group.id))
+ .some(group => !group.enabled);
+ const msgProvider =
+ this.state.providers.find(provider => provider.id === msg.provider) || {};
+ const isProviderExcluded =
+ msgProvider.exclude && msgProvider.exclude.includes(msg.id);
+ const isMessageBlocked =
+ this.state.messageBlockList.includes(msg.id) ||
+ this.state.messageBlockList.includes(msg.campaign);
+ const isBlocked =
+ isMessageBlocked || isBlockedByGroup || isProviderExcluded;
+ const impressions = this.state.messageImpressions[msg.id]
+ ? this.state.messageImpressions[msg.id].length
+ : 0;
+ const isCollapsed = this.state.collapsedMessages.includes(msg.id);
+ const isModified = this.state.modifiedMessages.includes(msg.id);
+ const aboutMessagePreviewSupported = [
+ "infobar",
+ "spotlight",
+ "cfr_doorhanger",
+ ].includes(msg.template);
+
+ let itemClassName = "message-item";
+ if (isBlocked) {
+ itemClassName += " blocked";
+ }
+
+ return (
+ <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}>
+ <td className="message-id">
+ <span>
+ {msg.id} <br />
+ </span>
+ </td>
+ <td>
+ <ToggleMessageJSON
+ msgId={`${msg.id}`}
+ toggleJSON={this.toggleJSON}
+ isCollapsed={isCollapsed}
+ />
+ </td>
+ <td className="button-column">
+ <button
+ className={`button ${isBlocked ? "" : " primary"}`}
+ onClick={
+ isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg)
+ }
+ >
+ {isBlocked ? "Unblock" : "Block"}
+ </button>
+ {
+ // eslint-disable-next-line no-nested-ternary
+ isBlocked ? null : isModified ? (
+ <button
+ className="button restore"
+ // eslint-disable-next-line react/jsx-no-bind
+ onClick={e => this.resetJSON(msg)}
+ >
+ Reset
+ </button>
+ ) : (
+ <button
+ className="button show"
+ onClick={this.handleOverride(msg.id)}
+ >
+ Show
+ </button>
+ )
+ }
+ {isBlocked ? null : (
+ <button
+ className="button modify"
+ // eslint-disable-next-line react/jsx-no-bind
+ onClick={e => this.modifyJson(msg)}
+ >
+ Modify
+ </button>
+ )}
+ {aboutMessagePreviewSupported ? (
+ <CopyButton
+ transformer={text =>
+ `about:messagepreview?json=${encodeURIComponent(btoa(text))}`
+ }
+ label="Share"
+ copiedLabel="Copied!"
+ inputSelector={`#${msg.id}-textarea`}
+ className={"button share"}
+ />
+ ) : null}
+ <br />({impressions} impressions)
+ </td>
+ <td className="message-summary">
+ {isBlocked && (
+ <tr>
+ Block reason:
+ {isBlockedByGroup && " Blocked by group"}
+ {isProviderExcluded && " Excluded by provider"}
+ {isMessageBlocked && " Message blocked"}
+ </tr>
+ )}
+ <tr>
+ <pre className={isCollapsed ? "collapsed" : "expanded"}>
+ <textarea
+ id={`${msg.id}-textarea`}
+ name={msg.id}
+ className="general-textarea"
+ disabled={isBlocked}
+ // eslint-disable-next-line react/jsx-no-bind
+ onChange={e => this.handleChange(msg.id)}
+ >
+ {JSON.stringify(msg, null, 2)}
+ </textarea>
+ </pre>
+ </tr>
+ </td>
+ </tr>
+ );
+ }
+
+ selectPBMessage(msgId) {
+ if (msgId === "clear") {
+ this.setState({
+ selectedPBMessage: "",
+ });
+ } else {
+ let selected = document.getElementById(`${msgId} radio`);
+ let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value);
+
+ if (selected.checked) {
+ this.setState({
+ selectedPBMessage: msg?.content,
+ });
+ } else {
+ this.setState({
+ selectedPBMessage: "",
+ });
+ }
+ }
+ }
+
+ modifyJson(content) {
+ const message = JSON.parse(
+ document.getElementById(`${content.id}-textarea`).value
+ );
+ return ASRouterUtils.modifyMessageJson(message).then(state => {
+ this.setStateFromParent(state);
+ this.props.notifyContent({
+ message: state.message,
+ });
+ });
+ }
+
+ renderPBMessageItem(msg) {
+ const isBlocked =
+ this.state.messageBlockList.includes(msg.id) ||
+ this.state.messageBlockList.includes(msg.campaign);
+ const impressions = this.state.messageImpressions[msg.id]
+ ? this.state.messageImpressions[msg.id].length
+ : 0;
+
+ const isCollapsed = this.state.collapsedMessages.includes(msg.id);
+
+ let itemClassName = "message-item";
+ if (isBlocked) {
+ itemClassName += " blocked";
+ }
+
+ return (
+ <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}>
+ <td className="message-id">
+ <span>
+ {msg.id} <br />
+ <br />({impressions} impressions)
+ </span>
+ </td>
+ <td>
+ <ToggleMessageJSON
+ msgId={`${msg.id}`}
+ toggleJSON={this.toggleJSON}
+ isCollapsed={isCollapsed}
+ />
+ </td>
+ <td>
+ <input
+ type="radio"
+ id={`${msg.id} radio`}
+ name="PB_message_radio"
+ style={{ marginBottom: 20 }}
+ onClick={() => this.selectPBMessage(msg.id)}
+ disabled={isBlocked}
+ />
+ <button
+ className={`button ${isBlocked ? "" : " primary"}`}
+ onClick={
+ isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg)
+ }
+ >
+ {isBlocked ? "Unblock" : "Block"}
+ </button>
+ <button
+ className="ASRouterButton slim button"
+ onClick={e => this.resetPBJSON(msg)}
+ >
+ Reset JSON
+ </button>
+ </td>
+ <td className={`message-summary`}>
+ <pre className={isCollapsed ? "collapsed" : "expanded"}>
+ <textarea
+ id={`${msg.id}-textarea`}
+ className="wnp-textarea"
+ name={msg.id}
+ >
+ {JSON.stringify(msg, null, 2)}
+ </textarea>
+ </pre>
+ </td>
+ </tr>
+ );
+ }
+
+ toggleAllMessages(messagesToShow) {
+ if (this.state.collapsedMessages.length) {
+ this.setState({
+ collapsedMessages: [],
+ });
+ } else {
+ Array.prototype.forEach.call(messagesToShow, msg => {
+ this.setState(prevState => ({
+ collapsedMessages: prevState.collapsedMessages.concat(msg.id),
+ }));
+ });
+ }
+ }
+
+ renderMessages() {
+ if (!this.state.messages) {
+ return null;
+ }
+ const messagesToShow =
+ this.state.messageFilter === "all"
+ ? this.state.messages
+ : this.state.messages.filter(
+ message =>
+ message.provider === this.state.messageFilter &&
+ message.template !== "pb_newtab"
+ );
+
+ return (
+ <div>
+ <button
+ className="ASRouterButton slim"
+ // eslint-disable-next-line react/jsx-no-bind
+ onClick={e => this.toggleAllMessages(messagesToShow)}
+ >
+ Collapse/Expand All
+ </button>
+ <p className="helpLink">
+ <span className="icon icon-small-spacer icon-info" />{" "}
+ <span>
+ To modify a message, change the JSON and click 'Modify' to see your
+ changes. Click 'Reset' to restore the JSON to the original. Click
+ 'Share' to copy a link to the clipboard that can be used to preview
+ the message by opening the link in Nightly/local builds.
+ </span>
+ </p>
+ <table>
+ <tbody>
+ {messagesToShow.map(msg => this.renderMessageItem(msg))}
+ </tbody>
+ </table>
+ </div>
+ );
+ }
+
+ renderMessagesByGroup() {
+ if (!this.state.messages) {
+ return null;
+ }
+ const messagesToShow =
+ this.state.messageGroupsFilter === "all"
+ ? this.state.messages.filter(m => m.groups.length)
+ : this.state.messages.filter(message =>
+ message.groups.includes(this.state.messageGroupsFilter)
+ );
+
+ return (
+ <table>
+ <tbody>{messagesToShow.map(msg => this.renderMessageItem(msg))}</tbody>
+ </table>
+ );
+ }
+
+ renderPBMessages() {
+ if (!this.state.messages) {
+ return null;
+ }
+ const messagesToShow = this.state.messages.filter(
+ message => message.template === "pb_newtab"
+ );
+ return (
+ <table>
+ <tbody>
+ {messagesToShow.map(msg => this.renderPBMessageItem(msg))}
+ </tbody>
+ </table>
+ );
+ }
+
+ renderMessageFilter() {
+ if (!this.state.providers) {
+ return null;
+ }
+
+ return (
+ <p>
+ <button
+ className="unblock-all ASRouterButton test-only"
+ onClick={this.unblockAll}
+ >
+ Unblock All Snippets
+ </button>
+ Show messages from{" "}
+ <select
+ value={this.state.messageFilter}
+ onChange={this.onChangeMessageFilter}
+ >
+ <option value="all">all providers</option>
+ {this.state.providers.map(provider => (
+ <option key={provider.id} value={provider.id}>
+ {provider.id}
+ </option>
+ ))}
+ </select>
+ {this.state.messageFilter !== "all" &&
+ !this.state.messageFilter.includes("_local_testing") ? (
+ <button
+ className="button messages-reset"
+ onClick={this.handleClearAllImpressionsByProvider}
+ >
+ Reset All
+ </button>
+ ) : null}
+ </p>
+ );
+ }
+
+ renderMessageGroupsFilter() {
+ if (!this.state.groups) {
+ return null;
+ }
+
+ return (
+ <p>
+ Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+ <select
+ value={this.state.messageGroupsFilter}
+ onChange={this.onChangeMessageGroupsFilter}
+ >
+ <option value="all">all groups</option>
+ {this.state.groups.map(group => (
+ <option key={group.id} value={group.id}>
+ {group.id}
+ </option>
+ ))}
+ </select>
+ </p>
+ );
+ }
+
+ renderTableHead() {
+ return (
+ <thead>
+ <tr className="message-item">
+ <td className="min" />
+ <td className="min">Provider ID</td>
+ <td>Source</td>
+ <td className="min">Cohort</td>
+ <td className="min">Last Updated</td>
+ </tr>
+ </thead>
+ );
+ }
+
+ renderProviders() {
+ const providersConfig = this.state.providerPrefs;
+ const providerInfo = this.state.providers;
+ const userPrefInfo = this.state.userPrefs;
+
+ return (
+ <table>
+ {this.renderTableHead()}
+ <tbody>
+ {providersConfig.map((provider, i) => {
+ const isTestProvider = provider.id.includes("_local_testing");
+ const info = providerInfo.find(p => p.id === provider.id) || {};
+ const isUserEnabled =
+ provider.id in userPrefInfo ? userPrefInfo[provider.id] : true;
+ const isSystemEnabled = isTestProvider || provider.enabled;
+
+ let label = "local";
+ if (provider.type === "remote") {
+ label = (
+ <span>
+ endpoint (
+ <a
+ className="providerUrl"
+ target="_blank"
+ href={info.url}
+ rel="noopener noreferrer"
+ >
+ {info.url}
+ </a>
+ )
+ </span>
+ );
+ } else if (provider.type === "remote-settings") {
+ label = `remote settings (${provider.collection})`;
+ } else if (provider.type === "remote-experiments") {
+ label = (
+ <span>
+ remote settings (
+ <a
+ className="providerUrl"
+ target="_blank"
+ href="https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/nimbus-desktop-experiments/records"
+ rel="noopener noreferrer"
+ >
+ nimbus-desktop-experiments
+ </a>
+ )
+ </span>
+ );
+ }
+
+ let reasonsDisabled = [];
+ if (!isSystemEnabled) {
+ reasonsDisabled.push("system pref");
+ }
+ if (!isUserEnabled) {
+ reasonsDisabled.push("user pref");
+ }
+ if (reasonsDisabled.length) {
+ label = `disabled via ${reasonsDisabled.join(", ")}`;
+ }
+
+ return (
+ <tr className="message-item" key={i}>
+ <td>
+ {isTestProvider ? (
+ <input
+ type="checkbox"
+ disabled={true}
+ readOnly={true}
+ checked={true}
+ />
+ ) : (
+ <input
+ type="checkbox"
+ data-provider={provider.id}
+ checked={isUserEnabled && isSystemEnabled}
+ onChange={this.handleEnabledToggle}
+ />
+ )}
+ </td>
+ <td>{provider.id}</td>
+ <td>
+ <span
+ className={`sourceLabel${
+ isUserEnabled && isSystemEnabled ? "" : " isDisabled"
+ }`}
+ >
+ {label}
+ </span>
+ </td>
+ <td>{provider.cohort}</td>
+ <td style={{ whiteSpace: "nowrap" }}>
+ {info.lastUpdated
+ ? new Date(info.lastUpdated).toLocaleString()
+ : ""}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ );
+ }
+
+ renderTargetingParameters() {
+ // There was no error and the result is truthy
+ const success =
+ this.state.evaluationStatus.success &&
+ !!this.state.evaluationStatus.result;
+ const result =
+ JSON.stringify(this.state.evaluationStatus.result, null, 2) ||
+ "(Empty result)";
+
+ return (
+ <table>
+ <tbody>
+ <tr>
+ <td>
+ <h2>Evaluate JEXL expression</h2>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <p>
+ <textarea
+ ref="expressionInput"
+ rows="10"
+ cols="60"
+ placeholder="Evaluate JEXL expressions and mock parameters by changing their values below"
+ />
+ </p>
+ <p>
+ Status:{" "}
+ <span ref="evaluationStatus">
+ {success ? "✅" : "❌"}, Result: {result}
+ </span>
+ </p>
+ </td>
+ <td>
+ <button
+ className="ASRouterButton secondary"
+ onClick={this.handleExpressionEval}
+ >
+ Evaluate
+ </button>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <h2>Modify targeting parameters</h2>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <button
+ className="ASRouterButton secondary"
+ onClick={this.onCopyTargetingParams}
+ disabled={this.state.copiedToClipboard}
+ >
+ {this.state.copiedToClipboard
+ ? "Parameters copied!"
+ : "Copy parameters"}
+ </button>
+ </td>
+ </tr>
+ {this.state.stringTargetingParameters &&
+ Object.keys(this.state.stringTargetingParameters).map(
+ (param, i) => {
+ const value = this.state.stringTargetingParameters[param];
+ const errorState =
+ this.state.targetingParametersError &&
+ this.state.targetingParametersError.id === param;
+ const className = errorState ? "errorState" : "";
+ const inputComp =
+ (value && value.length) > 30 ? (
+ <textarea
+ name={param}
+ className={className}
+ value={value}
+ rows="10"
+ cols="60"
+ onChange={this.onChangeTargetingParameters}
+ />
+ ) : (
+ <input
+ name={param}
+ className={className}
+ value={value}
+ onChange={this.onChangeTargetingParameters}
+ />
+ );
+
+ return (
+ <tr key={i}>
+ <td>{param}</td>
+ <td>{inputComp}</td>
+ </tr>
+ );
+ }
+ )}
+ </tbody>
+ </table>
+ );
+ }
+
+ onChangeAttributionParameters(event) {
+ const { name, value } = event.target;
+
+ this.setState(({ attributionParameters }) => {
+ const updatedParameters = { ...attributionParameters };
+ updatedParameters[name] = value;
+
+ return { attributionParameters: updatedParameters };
+ });
+ }
+
+ setAttribution(e) {
+ ASRouterUtils.sendMessage({
+ type: "FORCE_ATTRIBUTION",
+ data: this.state.attributionParameters,
+ }).then(this.setStateFromParent);
+ }
+
+ _getGroupImpressionsCount(id, frequency) {
+ if (frequency) {
+ return this.state.groupImpressions[id]
+ ? this.state.groupImpressions[id].length
+ : 0;
+ }
+
+ return "n/a";
+ }
+
+ renderDiscoveryStream() {
+ const { config } = this.props.DiscoveryStream;
+
+ return (
+ <div>
+ <table>
+ <tbody>
+ <tr className="message-item">
+ <td className="min">Enabled</td>
+ <td>{config.enabled ? "yes" : "no"}</td>
+ </tr>
+ <tr className="message-item">
+ <td className="min">Endpoint</td>
+ <td>{config.endpoint || "(empty)"}</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ );
+ }
+
+ renderAttributionParamers() {
+ return (
+ <div>
+ <h2> Attribution Parameters </h2>
+ <p>
+ {" "}
+ This forces the browser to set some attribution parameters, useful for
+ testing the Return To AMO feature. Clicking on 'Force Attribution',
+ with the default values in each field, will demo the Return To AMO
+ flow with the addon called 'uBlock Origin'. If you wish to try
+ different attribution parameters, enter them in the text boxes. If you
+ wish to try a different addon with the Return To AMO flow, make sure
+ the 'content' text box has a string that is 'rta:base64(addonID)', the
+ base64 string of the addonID prefixed with 'rta:'. The addon must
+ currently be a recommended addon on AMO. Then click 'Force
+ Attribution'. Clicking on 'Force Attribution' with blank text boxes
+ reset attribution data.
+ </p>
+ <table>
+ <tr>
+ <td>
+ <b> Source </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="source"
+ placeholder="addons.mozilla.org"
+ value={this.state.attributionParameters.source}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Medium </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="medium"
+ placeholder="referral"
+ value={this.state.attributionParameters.medium}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Campaign </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="campaign"
+ placeholder="non-fx-button"
+ value={this.state.attributionParameters.campaign}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Content </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="content"
+ placeholder={`rta:${btoa("uBlock0@raymondhill.net")}`}
+ value={this.state.attributionParameters.content}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Experiment </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="experiment"
+ placeholder="ua-onboarding"
+ value={this.state.attributionParameters.experiment}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Variation </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="variation"
+ placeholder="chrome"
+ value={this.state.attributionParameters.variation}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> User Agent </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="ua"
+ placeholder="Google Chrome 123"
+ value={this.state.attributionParameters.ua}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <b> Download Token </b>
+ </td>
+ <td>
+ {" "}
+ <input
+ type="text"
+ name="dltoken"
+ placeholder="00000000-0000-0000-0000-000000000000"
+ value={this.state.attributionParameters.dltoken}
+ onChange={this.onChangeAttributionParameters}
+ />{" "}
+ </td>
+ </tr>
+ <tr>
+ <td>
+ {" "}
+ <button
+ className="ASRouterButton primary button"
+ onClick={this.setAttribution}
+ >
+ {" "}
+ Force Attribution{" "}
+ </button>{" "}
+ </td>
+ </tr>
+ </table>
+ </div>
+ );
+ }
+
+ renderErrorMessage({ id, errors }) {
+ const providerId = <td rowSpan={errors.length}>{id}</td>;
+ // .reverse() so that the last error (most recent) is first
+ return errors
+ .map(({ error, timestamp }, cellKey) => (
+ <tr key={cellKey}>
+ {cellKey === errors.length - 1 ? providerId : null}
+ <td>{error.message}</td>
+ <td>{relativeTime(timestamp)}</td>
+ </tr>
+ ))
+ .reverse();
+ }
+
+ renderErrors() {
+ const providersWithErrors =
+ this.state.providers &&
+ this.state.providers.filter(p => p.errors && p.errors.length);
+
+ if (providersWithErrors && providersWithErrors.length) {
+ return (
+ <table className="errorReporting">
+ <thead>
+ <tr>
+ <th>Provider ID</th>
+ <th>Message</th>
+ <th>Timestamp</th>
+ </tr>
+ </thead>
+ <tbody>{providersWithErrors.map(this.renderErrorMessage)}</tbody>
+ </table>
+ );
+ }
+
+ return <p>No errors</p>;
+ }
+
+ renderPBTab() {
+ if (!this.state.messages) {
+ return null;
+ }
+ let messagesToShow = this.state.messages.filter(
+ message => message.template === "pb_newtab"
+ );
+
+ return (
+ <div>
+ <p className="helpLink">
+ <span className="icon icon-small-spacer icon-info" />{" "}
+ <span>
+ To view an available message, select its radio button and click
+ "Open a Private Browsing Window".
+ <br />
+ To modify a message, make changes to the JSON first, then select the
+ radio button. (To make new changes, click "Reset Message State",
+ make your changes, and reselect the radio button.)
+ <br />
+ Click "Reset Message State" to clear all message impressions and
+ view messages in a clean state.
+ <br />
+ Note that ContentSearch functions do not work in debug mode.
+ </span>
+ </p>
+ <div>
+ <button
+ className="ASRouterButton primary button"
+ onClick={this.handleOpenPB}
+ >
+ Open a Private Browsing Window
+ </button>
+ <button
+ className="ASRouterButton primary button"
+ style={{ marginInlineStart: 12 }}
+ onClick={this.resetPBMessageState}
+ >
+ Reset Message State
+ </button>
+ <br />
+ <input
+ type="radio"
+ id={`clear radio`}
+ name="PB_message_radio"
+ value="clearPBMessage"
+ style={{ display: "none" }}
+ />
+ <h2>Messages</h2>
+ <button
+ className="ASRouterButton slim button"
+ // eslint-disable-next-line react/jsx-no-bind
+ onClick={e => this.toggleAllMessages(messagesToShow)}
+ >
+ Collapse/Expand All
+ </button>
+ {this.renderPBMessages()}
+ </div>
+ </div>
+ );
+ }
+
+ getSection() {
+ const [section] = this.props.location.routes;
+ switch (section) {
+ case "private":
+ return (
+ <React.Fragment>
+ <h2>Private Browsing Messages</h2>
+ {this.renderPBTab()}
+ </React.Fragment>
+ );
+ case "targeting":
+ return (
+ <React.Fragment>
+ <h2>Targeting Utilities</h2>
+ <button className="button" onClick={this.expireCache}>
+ Expire Cache
+ </button>{" "}
+ (This expires the cache in ASR Targeting for bookmarks and top
+ sites)
+ {this.renderTargetingParameters()}
+ {this.renderAttributionParamers()}
+ </React.Fragment>
+ );
+ case "groups":
+ return (
+ <React.Fragment>
+ <h2>Message Groups</h2>
+ <button className="button" onClick={this.resetGroups}>
+ Reset group impressions
+ </button>
+ <table>
+ <thead>
+ <tr className="message-item">
+ <td>Enabled</td>
+ <td>Impressions count</td>
+ <td>Custom frequency</td>
+ <td>User preferences</td>
+ </tr>
+ </thead>
+ <tbody>
+ {this.state.groups &&
+ this.state.groups.map(
+ (
+ { id, enabled, frequency, userPreferences = [] },
+ index
+ ) => (
+ <Row key={id}>
+ <td>
+ <TogglePrefCheckbox
+ checked={enabled}
+ pref={id}
+ disabled={true}
+ />
+ </td>
+ <td>{this._getGroupImpressionsCount(id, frequency)}</td>
+ <td>{JSON.stringify(frequency, null, 2)}</td>
+ <td>{userPreferences.join(", ")}</td>
+ </Row>
+ )
+ )}
+ </tbody>
+ </table>
+ {this.renderMessageGroupsFilter()}
+ {this.renderMessagesByGroup()}
+ </React.Fragment>
+ );
+ case "ds":
+ return (
+ <React.Fragment>
+ <h2>Discovery Stream</h2>
+ <DiscoveryStreamAdmin
+ state={{
+ DiscoveryStream: this.props.DiscoveryStream,
+ Personalization: this.props.Personalization,
+ }}
+ otherPrefs={this.props.Prefs.values}
+ dispatch={this.props.dispatch}
+ />
+ </React.Fragment>
+ );
+ case "errors":
+ return (
+ <React.Fragment>
+ <h2>ASRouter Errors</h2>
+ {this.renderErrors()}
+ </React.Fragment>
+ );
+ default:
+ return (
+ <React.Fragment>
+ <h2>
+ Message Providers{" "}
+ <button
+ title="Restore all provider settings that ship with Firefox"
+ className="button"
+ onClick={this.resetPref}
+ >
+ Restore default prefs
+ </button>
+ </h2>
+ {this.state.providers ? this.renderProviders() : null}
+ <h2>Messages</h2>
+ {this.renderMessageFilter()}
+ {this.renderMessages()}
+ </React.Fragment>
+ );
+ }
+ }
+
+ render() {
+ return (
+ <div
+ className={`asrouter-admin ${
+ this.props.collapsed ? "collapsed" : "expanded"
+ }`}
+ >
+ <aside className="sidebar">
+ <ul>
+ <li>
+ <a href="#devtools">General</a>
+ </li>
+ <li>
+ <a href="#devtools-private">Private Browsing</a>
+ </li>
+ <li>
+ <a href="#devtools-targeting">Targeting</a>
+ </li>
+ <li>
+ <a href="#devtools-groups">Message Groups</a>
+ </li>
+ <li>
+ <a href="#devtools-ds">Discovery Stream</a>
+ </li>
+ <li>
+ <a href="#devtools-errors">Errors</a>
+ </li>
+ </ul>
+ </aside>
+ <main className="main-panel">
+ <h1>AS Router Admin</h1>
+
+ <p className="helpLink">
+ <span className="icon icon-small-spacer icon-info" />{" "}
+ <span>
+ Need help using these tools? Check out our{" "}
+ <a
+ target="blank"
+ href="https://firefox-source-docs.mozilla.org/browser/components/newtab/content-src/asrouter/docs/debugging-docs.html"
+ >
+ documentation
+ </a>
+ </span>
+ </p>
+
+ {this.getSection()}
+ </main>
+ </div>
+ );
+ }
+}
+
+export class CollapseToggle extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onCollapseToggle = this.onCollapseToggle.bind(this);
+ this.state = { collapsed: false };
+ }
+
+ get renderAdmin() {
+ const { props } = this;
+ return (
+ props.location.hash &&
+ (props.location.hash.startsWith("#asrouter") ||
+ props.location.hash.startsWith("#devtools"))
+ );
+ }
+
+ onCollapseToggle(e) {
+ e.preventDefault();
+ this.setState(state => ({ collapsed: !state.collapsed }));
+ }
+
+ setBodyClass() {
+ if (this.renderAdmin && !this.state.collapsed) {
+ global.document.body.classList.add("no-scroll");
+ } else {
+ global.document.body.classList.remove("no-scroll");
+ }
+ }
+
+ componentDidMount() {
+ this.setBodyClass();
+ }
+
+ componentDidUpdate() {
+ this.setBodyClass();
+ }
+
+ componentWillUnmount() {
+ global.document.body.classList.remove("no-scroll");
+ ASRouterUtils.removeListener(this.onMessageFromParent);
+ }
+
+ render() {
+ const { props } = this;
+ const { renderAdmin } = this;
+ const isCollapsed = this.state.collapsed || !renderAdmin;
+ const label = `${isCollapsed ? "Expand" : "Collapse"} devtools`;
+ return (
+ <React.Fragment>
+ <a
+ href="#devtools"
+ title={label}
+ aria-label={label}
+ className={`asrouter-toggle ${
+ isCollapsed ? "collapsed" : "expanded"
+ }`}
+ onClick={this.renderAdmin ? this.onCollapseToggle : null}
+ >
+ <span className="icon icon-devtools" />
+ </a>
+ {renderAdmin ? (
+ <ASRouterAdminInner {...props} collapsed={this.state.collapsed} />
+ ) : null}
+ </React.Fragment>
+ );
+ }
+}
+
+const _ASRouterAdmin = props => (
+ <SimpleHashRouter>
+ <CollapseToggle {...props} />
+ </SimpleHashRouter>
+);
+
+export const ASRouterAdmin = connect(state => ({
+ Sections: state.Sections,
+ DiscoveryStream: state.DiscoveryStream,
+ Personalization: state.Personalization,
+ Prefs: state.Prefs,
+}))(_ASRouterAdmin);
diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss
new file mode 100644
index 0000000000..b095b94ef6
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss
@@ -0,0 +1,280 @@
+.asrouter-toggle {
+ position: fixed;
+ top: 50px;
+ inset-inline-end: 15px;
+ border: 0;
+ background: none;
+ z-index: 1;
+ border-radius: 2px;
+
+ .icon-devtools {
+ background-image: url('chrome://global/skin/icons/developer.svg');
+ padding: 15px;
+ }
+
+ &:dir(rtl) {
+ transform: scaleX(-1);
+ }
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ }
+
+ &.expanded {
+ background: $black-20;
+ }
+}
+
+.asrouter-admin {
+ $border-color: var(--newtab-border-color);
+ $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono',
+ 'Source Code Pro', monospace;
+ $sidebar-width: 240px;
+
+ position: fixed;
+ top: 0;
+ inset-inline-start: 0;
+ width: 100%;
+ background: var(--newtab-background-color);
+ height: 100%;
+ overflow-y: scroll;
+ margin: 0 auto;
+ font-size: 14px;
+ padding-inline-start: $sidebar-width;
+ color: var(--newtab-text-primary-color);
+
+ &.collapsed {
+ display: none;
+ }
+
+ .sidebar {
+ inset-inline-start: 0;
+ position: fixed;
+ width: $sidebar-width;
+ padding: 30px 20px;
+
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ li a {
+ padding: 10px 34px;
+ display: block;
+ color: var(--lwt-sidebar-text-color);
+
+ &:hover {
+ background: var(--newtab-background-color-secondary);
+ }
+ }
+ }
+
+ h1 {
+ font-weight: 200;
+ font-size: 32px;
+ }
+
+ h2 .button,
+ p .button {
+ font-size: 14px;
+ padding: 6px 12px;
+ margin-inline-start: 5px;
+ margin-bottom: 0;
+ }
+
+ .general-textarea {
+ direction: ltr;
+ width: 740px;
+ height: 500px;
+ overflow: auto;
+ resize: none;
+ border-radius: 4px;
+ display: flex;
+ }
+
+ .wnp-textarea {
+ direction: ltr;
+ width: 740px;
+ height: 500px;
+ overflow: auto;
+ resize: none;
+ border-radius: 4px;
+ display: flex;
+ }
+
+ .json-button {
+ display: inline-flex;
+ font-size: 10px;
+ padding: 4px 10px;
+ margin-bottom: 6px;
+ margin-inline-end: 4px;
+
+ &:hover {
+ background-color: var(--newtab-element-hover-color);
+ box-shadow: none;
+ }
+ }
+
+ table {
+ border-collapse: collapse;
+ width: 100%;
+
+ &.minimal-table {
+ border-collapse: collapse;
+ border: 1px solid $border-color;
+
+ td {
+ padding: 8px;
+ }
+
+ td:first-child {
+ width: 1%;
+ white-space: nowrap;
+ }
+
+ td:not(:first-child) {
+ font-family: $monospace;
+ }
+ }
+
+ &.errorReporting {
+ tr {
+ border: 1px solid var(--newtab-background-color-secondary);
+ }
+
+ td {
+ padding: 4px;
+
+ &[rowspan] {
+ border: 1px solid var(--newtab-background-color-secondary);
+ }
+ }
+ }
+ }
+
+ .sourceLabel {
+ background: var(--newtab-background-color-secondary);
+ padding: 2px 5px;
+ border-radius: 3px;
+
+ &.isDisabled {
+ background: $email-input-invalid;
+ color: var(--newtab-status-error);
+ }
+ }
+
+ .message-item {
+ &:first-child td {
+ border-top: 1px solid $border-color;
+ }
+
+ td {
+ vertical-align: top;
+ padding: 8px;
+ border-bottom: 1px solid $border-color;
+
+ &.min {
+ width: 1%;
+ white-space: nowrap;
+ }
+
+ &.message-summary {
+ width: 60%;
+ }
+
+ &.button-column {
+ width: 15%;
+ }
+
+ &:first-child {
+ border-inline-start: 1px solid $border-color;
+ }
+
+ &:last-child {
+ border-inline-end: 1px solid $border-color;
+ }
+ }
+
+ &.blocked {
+ .message-id,
+ .message-summary {
+ opacity: 0.5;
+ }
+
+ .message-id {
+ opacity: 0.5;
+ }
+ }
+
+ .message-id {
+ font-family: $monospace;
+ font-size: 12px;
+ }
+ }
+
+ .providerUrl {
+ font-size: 12px;
+ }
+
+ pre {
+ background: var(--newtab-background-color-secondary);
+ margin: 0;
+ padding: 8px;
+ font-size: 12px;
+ max-width: 750px;
+ overflow: auto;
+ font-family: $monospace;
+ }
+
+ .errorState {
+ border: $input-error-border;
+ }
+
+ .helpLink {
+ padding: 10px;
+ display: flex;
+ background: $black-10;
+ border-radius: 3px;
+ align-items: center;
+
+ a {
+ text-decoration: underline;
+ }
+
+ .icon {
+ min-width: 18px;
+ min-height: 18px;
+ }
+ }
+
+ .ds-component {
+ margin-bottom: 20px;
+ }
+
+ .modalOverlayInner {
+ height: 80%;
+ }
+
+ .clearButton {
+ border: 0;
+ padding: 4px;
+ border-radius: 4px;
+ display: flex;
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ }
+ }
+
+ .collapsed {
+ display: none;
+ }
+
+ .icon {
+ display: inline-table;
+ cursor: pointer;
+ width: 18px;
+ height: 18px;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx
new file mode 100644
index 0000000000..07ce12d7bf
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx
@@ -0,0 +1,31 @@
+/* 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 React, { useState, useRef, useCallback } from "react";
+
+export const CopyButton = ({
+ className,
+ label,
+ copiedLabel,
+ inputSelector,
+ transformer,
+ ...props
+}) => {
+ const [copied, setCopied] = useState(false);
+ const timeout = useRef(null);
+ const onClick = useCallback(() => {
+ let text = document.querySelector(inputSelector).value;
+ if (transformer) text = transformer(text);
+ navigator.clipboard.writeText(text);
+
+ clearTimeout(timeout.current);
+ setCopied(true);
+ timeout.current = setTimeout(() => setCopied(false), 1500);
+ }, [inputSelector, transformer]);
+ return (
+ <button className={className} onClick={e => onClick()} {...props}>
+ {(copied && copiedLabel) || label}
+ </button>
+ );
+};
diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx
new file mode 100644
index 0000000000..9c3fd8579c
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx
@@ -0,0 +1,35 @@
+/* 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 React from "react";
+
+export class SimpleHashRouter extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onHashChange = this.onHashChange.bind(this);
+ this.state = { hash: global.location.hash };
+ }
+
+ onHashChange() {
+ this.setState({ hash: global.location.hash });
+ }
+
+ componentWillMount() {
+ global.addEventListener("hashchange", this.onHashChange);
+ }
+
+ componentWillUnmount() {
+ global.removeEventListener("hashchange", this.onHashChange);
+ }
+
+ render() {
+ const [, ...routes] = this.state.hash.split("-");
+ return React.cloneElement(this.props.children, {
+ location: {
+ hash: this.state.hash,
+ routes,
+ },
+ });
+ }
+}
diff --git a/browser/components/newtab/content-src/components/Base/Base.jsx b/browser/components/newtab/content-src/components/Base/Base.jsx
new file mode 100644
index 0000000000..8845ad87cd
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Base/Base.jsx
@@ -0,0 +1,273 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { ASRouterAdmin } from "content-src/components/ASRouterAdmin/ASRouterAdmin";
+import { ASRouterUISurface } from "../../asrouter/asrouter-content";
+import { ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog";
+import { connect } from "react-redux";
+import { DiscoveryStreamBase } from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase";
+import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
+import { CustomizeMenu } from "content-src/components/CustomizeMenu/CustomizeMenu";
+import React from "react";
+import { Search } from "content-src/components/Search/Search";
+import { Sections } from "content-src/components/Sections/Sections";
+
+export const PrefsButton = ({ onClick, icon }) => (
+ <div className="prefs-button">
+ <button
+ className={`icon ${icon || "icon-settings"}`}
+ onClick={onClick}
+ data-l10n-id="newtab-settings-button"
+ />
+ </div>
+);
+
+// Returns a function will not be continuously triggered when called. The
+// function will be triggered if called again after `wait` milliseconds.
+function debounce(func, wait) {
+ let timer;
+ return (...args) => {
+ if (timer) {
+ return;
+ }
+
+ let wakeUp = () => {
+ timer = null;
+ };
+
+ timer = setTimeout(wakeUp, wait);
+ func.apply(this, args);
+ };
+}
+
+export class _Base extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ message: {},
+ };
+ this.notifyContent = this.notifyContent.bind(this);
+ }
+
+ notifyContent(state) {
+ this.setState(state);
+ }
+
+ componentWillUnmount() {
+ this.updateTheme();
+ }
+
+ componentWillUpdate() {
+ this.updateTheme();
+ }
+
+ updateTheme() {
+ const bodyClassName = [
+ "activity-stream",
+ // If we skipped the about:welcome overlay and removed the CSS classes
+ // we don't want to add them back to the Activity Stream view
+ document.body.classList.contains("inline-onboarding")
+ ? "inline-onboarding"
+ : "",
+ ]
+ .filter(v => v)
+ .join(" ");
+ global.document.body.className = bodyClassName;
+ }
+
+ render() {
+ const { props } = this;
+ const { App } = props;
+ const isDevtoolsEnabled = props.Prefs.values["asrouter.devtoolsEnabled"];
+
+ if (!App.initialized) {
+ return null;
+ }
+
+ return (
+ <ErrorBoundary className="base-content-fallback">
+ <React.Fragment>
+ <BaseContent {...this.props} adminContent={this.state} />
+ {isDevtoolsEnabled ? (
+ <ASRouterAdmin notifyContent={this.notifyContent} />
+ ) : null}
+ </React.Fragment>
+ </ErrorBoundary>
+ );
+ }
+}
+
+export class BaseContent extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.openPreferences = this.openPreferences.bind(this);
+ this.openCustomizationMenu = this.openCustomizationMenu.bind(this);
+ this.closeCustomizationMenu = this.closeCustomizationMenu.bind(this);
+ this.handleOnKeyDown = this.handleOnKeyDown.bind(this);
+ this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5);
+ this.setPref = this.setPref.bind(this);
+ this.state = { fixedSearch: false };
+ }
+
+ componentDidMount() {
+ global.addEventListener("scroll", this.onWindowScroll);
+ global.addEventListener("keydown", this.handleOnKeyDown);
+ }
+
+ componentWillUnmount() {
+ global.removeEventListener("scroll", this.onWindowScroll);
+ global.removeEventListener("keydown", this.handleOnKeyDown);
+ }
+
+ onWindowScroll() {
+ const prefs = this.props.Prefs.values;
+ const SCROLL_THRESHOLD = prefs["logowordmark.alwaysVisible"] ? 179 : 34;
+ if (global.scrollY > SCROLL_THRESHOLD && !this.state.fixedSearch) {
+ this.setState({ fixedSearch: true });
+ } else if (global.scrollY <= SCROLL_THRESHOLD && this.state.fixedSearch) {
+ this.setState({ fixedSearch: false });
+ }
+ }
+
+ openPreferences() {
+ this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN }));
+ this.props.dispatch(ac.UserEvent({ event: "OPEN_NEWTAB_PREFS" }));
+ }
+
+ openCustomizationMenu() {
+ this.props.dispatch({ type: at.SHOW_PERSONALIZE });
+ this.props.dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" }));
+ }
+
+ closeCustomizationMenu() {
+ if (this.props.App.customizeMenuVisible) {
+ this.props.dispatch({ type: at.HIDE_PERSONALIZE });
+ this.props.dispatch(ac.UserEvent({ event: "HIDE_PERSONALIZE" }));
+ }
+ }
+
+ handleOnKeyDown(e) {
+ if (e.key === "Escape") {
+ this.closeCustomizationMenu();
+ }
+ }
+
+ setPref(pref, value) {
+ this.props.dispatch(ac.SetPref(pref, value));
+ }
+
+ render() {
+ const { props } = this;
+ const { App } = props;
+ const { initialized, customizeMenuVisible } = App;
+ const prefs = props.Prefs.values;
+
+ const isDiscoveryStream =
+ props.DiscoveryStream.config && props.DiscoveryStream.config.enabled;
+ let filteredSections = props.Sections.filter(
+ section => section.id !== "topstories"
+ );
+
+ const pocketEnabled =
+ prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"];
+ const noSectionsEnabled =
+ !prefs["feeds.topsites"] &&
+ !pocketEnabled &&
+ filteredSections.filter(section => section.enabled).length === 0;
+ const searchHandoffEnabled = prefs["improvesearch.handoffToAwesomebar"];
+ const enabledSections = {
+ topSitesEnabled: prefs["feeds.topsites"],
+ pocketEnabled: prefs["feeds.section.topstories"],
+ highlightsEnabled: prefs["feeds.section.highlights"],
+ showSponsoredTopSitesEnabled: prefs.showSponsoredTopSites,
+ showSponsoredPocketEnabled: prefs.showSponsored,
+ showRecentSavesEnabled: prefs.showRecentSaves,
+ topSitesRowsCount: prefs.topSitesRows,
+ };
+
+ const pocketRegion = prefs["feeds.system.topstories"];
+ const { mayHaveSponsoredTopSites } = prefs;
+
+ const outerClassName = [
+ "outer-wrapper",
+ isDiscoveryStream && pocketEnabled && "ds-outer-wrapper-search-alignment",
+ isDiscoveryStream && "ds-outer-wrapper-breakpoint-override",
+ prefs.showSearch &&
+ this.state.fixedSearch &&
+ !noSectionsEnabled &&
+ "fixed-search",
+ prefs.showSearch && noSectionsEnabled && "only-search",
+ prefs["logowordmark.alwaysVisible"] && "visible-logo",
+ ]
+ .filter(v => v)
+ .join(" ");
+
+ const hasSnippet =
+ prefs["feeds.snippets"] &&
+ this.props.adminContent &&
+ this.props.adminContent.message &&
+ this.props.adminContent.message.id;
+
+ return (
+ <div>
+ <CustomizeMenu
+ onClose={this.closeCustomizationMenu}
+ onOpen={this.openCustomizationMenu}
+ openPreferences={this.openPreferences}
+ setPref={this.setPref}
+ enabledSections={enabledSections}
+ pocketRegion={pocketRegion}
+ mayHaveSponsoredTopSites={mayHaveSponsoredTopSites}
+ showing={customizeMenuVisible}
+ />
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/}
+ <div className={outerClassName} onClick={this.closeCustomizationMenu}>
+ <main className={hasSnippet ? "has-snippet" : ""}>
+ {prefs.showSearch && (
+ <div className="non-collapsible-section">
+ <ErrorBoundary>
+ <Search
+ showLogo={
+ noSectionsEnabled || prefs["logowordmark.alwaysVisible"]
+ }
+ handoffEnabled={searchHandoffEnabled}
+ {...props.Search}
+ />
+ </ErrorBoundary>
+ </div>
+ )}
+ <ASRouterUISurface
+ adminContent={this.props.adminContent}
+ appUpdateChannel={this.props.Prefs.values.appUpdateChannel}
+ fxaEndpoint={this.props.Prefs.values.fxa_endpoint}
+ dispatch={this.props.dispatch}
+ />
+ <div className={`body-wrapper${initialized ? " on" : ""}`}>
+ {isDiscoveryStream ? (
+ <ErrorBoundary className="borderless-error">
+ <DiscoveryStreamBase locale={props.App.locale} />
+ </ErrorBoundary>
+ ) : (
+ <Sections />
+ )}
+ </div>
+ <ConfirmDialog />
+ </main>
+ </div>
+ </div>
+ );
+ }
+}
+
+export const Base = connect(state => ({
+ App: state.App,
+ Prefs: state.Prefs,
+ Sections: state.Sections,
+ DiscoveryStream: state.DiscoveryStream,
+ Search: state.Search,
+}))(_Base);
diff --git a/browser/components/newtab/content-src/components/Base/_Base.scss b/browser/components/newtab/content-src/components/Base/_Base.scss
new file mode 100644
index 0000000000..7e13fe9a90
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Base/_Base.scss
@@ -0,0 +1,143 @@
+.outer-wrapper {
+ color: var(--newtab-text-primary-color);
+ display: flex;
+ flex-grow: 1;
+ min-height: 100vh;
+ padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter;
+
+ &.ds-outer-wrapper-breakpoint-override {
+ padding: 30px 0 32px;
+
+ @media(min-width: $break-point-medium) {
+ padding: 30px 32px 32px;
+ }
+ }
+
+ &.only-search {
+ display: block;
+ padding-top: 134px;
+ }
+
+ a {
+ color: var(--newtab-primary-action-background);
+ }
+}
+
+main {
+ margin: auto;
+ width: $wrapper-default-width;
+ padding: 0;
+
+ section {
+ margin-bottom: $section-spacing;
+ position: relative;
+ }
+
+ .hide-main & {
+ visibility: hidden;
+ }
+
+ @media (min-width: $break-point-medium) {
+ width: $wrapper-max-width-medium;
+ }
+
+ @media (min-width: $break-point-large) {
+ width: $wrapper-max-width-large;
+ }
+
+ @media (min-width: $break-point-widest) {
+ width: $wrapper-max-width-widest;
+ }
+
+ &.has-snippet {
+ // Offset the snippets container so things at the bottom of the page are still
+ // visible when snippets are visible. Adjust for other spacing.
+ padding-bottom: $snippets-container-height - $section-spacing - $base-gutter;
+ }
+}
+
+.below-search-snippet.withButton {
+ margin: auto;
+ width: 100%;
+}
+
+.ds-outer-wrapper-search-alignment {
+ main {
+ // This override is to ensure while Discovery Stream loads,
+ // the search bar does not jump around. (it sticks to the top)
+ margin: 0 auto;
+ }
+}
+
+.ds-outer-wrapper-breakpoint-override {
+ main {
+ width: 266px;
+ padding-bottom: 0;
+
+ @media (min-width: $break-point-medium) {
+ width: 510px;
+ }
+
+ @media (min-width: $break-point-large) {
+ width: 746px;
+ }
+
+ @media (min-width: $break-point-widest) {
+ width: 986px;
+ }
+
+ &.has-snippet {
+ // Offset the snippets container so things at the bottom of the page are still
+ // visible when snippets are visible. Adjust for other spacing.
+ padding-bottom: $snippets-container-height - $section-spacing - $base-gutter;
+ }
+ }
+}
+
+.base-content-fallback {
+ // Make the error message be centered against the viewport
+ height: 100vh;
+}
+
+.body-wrapper {
+ // Hide certain elements so the page structure is fixed, e.g., placeholders,
+ // while avoiding flashes of changing content, e.g., icons and text
+ $selectors-to-hide: '.section-title, .sections-list .section:last-of-type, .topics';
+
+ #{$selectors-to-hide} {
+ opacity: 0;
+ }
+
+ &.on {
+ #{$selectors-to-hide} {
+ opacity: 1;
+ }
+ }
+}
+
+.non-collapsible-section {
+ padding: 0 $section-horizontal-padding;
+}
+
+.prefs-button {
+ button {
+ background-color: transparent;
+ border: 0;
+ border-radius: 2px;
+ cursor: pointer;
+ inset-inline-end: 15px;
+ padding: 15px;
+ position: fixed;
+ top: 15px;
+ z-index: 1000;
+
+ &:hover,
+ &:focus {
+ background-color: var(--newtab-element-hover-color);
+ }
+
+ &:active {
+ background-color: var(--newtab-element-active-color);
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/Card/Card.jsx b/browser/components/newtab/content-src/components/Card/Card.jsx
new file mode 100644
index 0000000000..9d03377f1b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Card/Card.jsx
@@ -0,0 +1,362 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { cardContextTypes } from "./types";
+import { connect } from "react-redux";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import React from "react";
+import { ScreenshotUtils } from "content-src/lib/screenshot-utils";
+
+// Keep track of pending image loads to only request once
+const gImageLoading = new Map();
+
+/**
+ * Card component.
+ * Cards are found within a Section component and contain information about a link such
+ * as preview image, page title, page description, and some context about if the page
+ * was visited, bookmarked, trending etc...
+ * Each Section can make an unordered list of Cards which will create one instane of
+ * this class. Each card will then get a context menu which reflects the actions that
+ * can be done on this Card.
+ */
+export class _Card extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ activeCard: null,
+ imageLoaded: false,
+ cardImage: null,
+ };
+ this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this);
+ this.onLinkClick = this.onLinkClick.bind(this);
+ }
+
+ /**
+ * Helper to conditionally load an image and update state when it loads.
+ */
+ async maybeLoadImage() {
+ // No need to load if it's already loaded or no image
+ const { cardImage } = this.state;
+ if (!cardImage) {
+ return;
+ }
+
+ const imageUrl = cardImage.url;
+ if (!this.state.imageLoaded) {
+ // Initialize a promise to share a load across multiple card updates
+ if (!gImageLoading.has(imageUrl)) {
+ const loaderPromise = new Promise((resolve, reject) => {
+ const loader = new Image();
+ loader.addEventListener("load", resolve);
+ loader.addEventListener("error", reject);
+ loader.src = imageUrl;
+ });
+
+ // Save and remove the promise only while it's pending
+ gImageLoading.set(imageUrl, loaderPromise);
+ loaderPromise
+ .catch(ex => ex)
+ .then(() => gImageLoading.delete(imageUrl))
+ .catch();
+ }
+
+ // Wait for the image whether just started loading or reused promise
+ try {
+ await gImageLoading.get(imageUrl);
+ } catch (ex) {
+ // Ignore the failed image without changing state
+ return;
+ }
+
+ // Only update state if we're still waiting to load the original image
+ if (
+ ScreenshotUtils.isRemoteImageLocal(
+ this.state.cardImage,
+ this.props.link.image
+ ) &&
+ !this.state.imageLoaded
+ ) {
+ this.setState({ imageLoaded: true });
+ }
+ }
+ }
+
+ /**
+ * Helper to obtain the next state based on nextProps and prevState.
+ *
+ * NOTE: Rename this method to getDerivedStateFromProps when we update React
+ * to >= 16.3. We will need to update tests as well. We cannot rename this
+ * method to getDerivedStateFromProps now because there is a mismatch in
+ * the React version that we are using for both testing and production.
+ * (i.e. react-test-render => "16.3.2", react => "16.2.0").
+ *
+ * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
+ */
+ static getNextStateFromProps(nextProps, prevState) {
+ const { image } = nextProps.link;
+ const imageInState = ScreenshotUtils.isRemoteImageLocal(
+ prevState.cardImage,
+ image
+ );
+ let nextState = null;
+
+ // Image is updating.
+ if (!imageInState && nextProps.link) {
+ nextState = { imageLoaded: false };
+ }
+
+ if (imageInState) {
+ return nextState;
+ }
+
+ // Since image was updated, attempt to revoke old image blob URL, if it exists.
+ ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.cardImage);
+
+ nextState = nextState || {};
+ nextState.cardImage = ScreenshotUtils.createLocalImageObject(image);
+
+ return nextState;
+ }
+
+ onMenuButtonUpdate(isOpen) {
+ if (isOpen) {
+ this.setState({ activeCard: this.props.index });
+ } else {
+ this.setState({ activeCard: null });
+ }
+ }
+
+ /**
+ * Report to telemetry additional information about the item.
+ */
+ _getTelemetryInfo() {
+ // Filter out "history" type for being the default
+ if (this.props.link.type !== "history") {
+ return { value: { card_type: this.props.link.type } };
+ }
+
+ return null;
+ }
+
+ onLinkClick(event) {
+ event.preventDefault();
+ const { altKey, button, ctrlKey, metaKey, shiftKey } = event;
+ if (this.props.link.type === "download") {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.OPEN_DOWNLOAD_FILE,
+ data: Object.assign(this.props.link, {
+ event: { button, ctrlKey, metaKey, shiftKey },
+ }),
+ })
+ );
+ } else {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: Object.assign(this.props.link, {
+ event: { altKey, button, ctrlKey, metaKey, shiftKey },
+ }),
+ })
+ );
+ }
+ if (this.props.isWebExtension) {
+ this.props.dispatch(
+ ac.WebExtEvent(at.WEBEXT_CLICK, {
+ source: this.props.eventSource,
+ url: this.props.link.url,
+ action_position: this.props.index,
+ })
+ );
+ } else {
+ this.props.dispatch(
+ ac.UserEvent(
+ Object.assign(
+ {
+ event: "CLICK",
+ source: this.props.eventSource,
+ action_position: this.props.index,
+ },
+ this._getTelemetryInfo()
+ )
+ )
+ );
+
+ if (this.props.shouldSendImpressionStats) {
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source: this.props.eventSource,
+ click: 0,
+ tiles: [{ id: this.props.link.guid, pos: this.props.index }],
+ })
+ );
+ }
+ }
+ }
+
+ componentDidMount() {
+ this.maybeLoadImage();
+ }
+
+ componentDidUpdate() {
+ this.maybeLoadImage();
+ }
+
+ // NOTE: Remove this function when we update React to >= 16.3 since React will
+ // call getDerivedStateFromProps automatically. We will also need to
+ // rename getNextStateFromProps to getDerivedStateFromProps.
+ componentWillMount() {
+ const nextState = _Card.getNextStateFromProps(this.props, this.state);
+ if (nextState) {
+ this.setState(nextState);
+ }
+ }
+
+ // NOTE: Remove this function when we update React to >= 16.3 since React will
+ // call getDerivedStateFromProps automatically. We will also need to
+ // rename getNextStateFromProps to getDerivedStateFromProps.
+ componentWillReceiveProps(nextProps) {
+ const nextState = _Card.getNextStateFromProps(nextProps, this.state);
+ if (nextState) {
+ this.setState(nextState);
+ }
+ }
+
+ componentWillUnmount() {
+ ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.cardImage);
+ }
+
+ render() {
+ const {
+ index,
+ className,
+ link,
+ dispatch,
+ contextMenuOptions,
+ eventSource,
+ shouldSendImpressionStats,
+ } = this.props;
+ const { props } = this;
+ const title = link.title || link.hostname;
+ const isContextMenuOpen = this.state.activeCard === index;
+ // Display "now" as "trending" until we have new strings #3402
+ const { icon, fluentID } =
+ cardContextTypes[link.type === "now" ? "trending" : link.type] || {};
+ const hasImage = this.state.cardImage || link.hasImage;
+ const imageStyle = {
+ backgroundImage: this.state.cardImage
+ ? `url(${this.state.cardImage.url})`
+ : "none",
+ };
+ const outerClassName = [
+ "card-outer",
+ className,
+ isContextMenuOpen && "active",
+ props.placeholder && "placeholder",
+ ]
+ .filter(v => v)
+ .join(" ");
+
+ return (
+ <li className={outerClassName}>
+ <a
+ href={link.type === "pocket" ? link.open_url : link.url}
+ onClick={!props.placeholder ? this.onLinkClick : undefined}
+ >
+ <div className="card">
+ <div className="card-preview-image-outer">
+ {hasImage && (
+ <div
+ className={`card-preview-image${
+ this.state.imageLoaded ? " loaded" : ""
+ }`}
+ style={imageStyle}
+ />
+ )}
+ </div>
+ <div className="card-details">
+ {link.type === "download" && (
+ <div
+ className="card-host-name alternate"
+ data-l10n-id="newtab-menu-open-file"
+ />
+ )}
+ {link.hostname && (
+ <div className="card-host-name">
+ {link.hostname.slice(0, 100)}
+ {link.type === "download" && ` \u2014 ${link.description}`}
+ </div>
+ )}
+ <div
+ className={[
+ "card-text",
+ icon ? "" : "no-context",
+ link.description ? "" : "no-description",
+ link.hostname ? "" : "no-host-name",
+ ].join(" ")}
+ >
+ <h4 className="card-title" dir="auto">
+ {link.title}
+ </h4>
+ <p className="card-description" dir="auto">
+ {link.description}
+ </p>
+ </div>
+ <div className="card-context">
+ {icon && !link.context && (
+ <span
+ aria-haspopup="true"
+ className={`card-context-icon icon icon-${icon}`}
+ />
+ )}
+ {link.icon && link.context && (
+ <span
+ aria-haspopup="true"
+ className="card-context-icon icon"
+ style={{ backgroundImage: `url('${link.icon}')` }}
+ />
+ )}
+ {fluentID && !link.context && (
+ <div className="card-context-label" data-l10n-id={fluentID} />
+ )}
+ {link.context && (
+ <div className="card-context-label">{link.context}</div>
+ )}
+ </div>
+ </div>
+ </div>
+ </a>
+ {!props.placeholder && (
+ <ContextMenuButton
+ tooltip="newtab-menu-content-tooltip"
+ tooltipArgs={{ title }}
+ onUpdate={this.onMenuButtonUpdate}
+ >
+ <LinkMenu
+ dispatch={dispatch}
+ index={index}
+ source={eventSource}
+ options={link.contextMenuOptions || contextMenuOptions}
+ site={link}
+ siteInfo={this._getTelemetryInfo()}
+ shouldSendImpressionStats={shouldSendImpressionStats}
+ />
+ </ContextMenuButton>
+ )}
+ </li>
+ );
+ }
+}
+_Card.defaultProps = { link: {} };
+export const Card = connect(state => ({
+ platform: state.Prefs.values.platform,
+}))(_Card);
+export const PlaceholderCard = props => (
+ <Card placeholder={true} className={props.className} />
+);
diff --git a/browser/components/newtab/content-src/components/Card/_Card.scss b/browser/components/newtab/content-src/components/Card/_Card.scss
new file mode 100644
index 0000000000..0c363b52d8
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Card/_Card.scss
@@ -0,0 +1,331 @@
+@use 'sass:math';
+
+.card-outer {
+ @include context-menu-button;
+
+ background: var(--newtab-background-color-secondary);
+ border-radius: $border-radius-new;
+ display: inline-block;
+ height: $card-height;
+ margin-inline-end: $base-gutter;
+ position: relative;
+ width: 100%;
+
+ &:is(:focus):not(.placeholder) {
+ @include ds-focus;
+
+ transition: none;
+ }
+
+ &:hover {
+ box-shadow: none;
+ transition: none;
+ }
+
+ &.placeholder {
+ background: transparent;
+
+ .card-preview-image-outer,
+ .card-context {
+ display: none;
+ }
+ }
+
+ .card {
+ border-radius: $border-radius-new;
+ box-shadow: $shadow-card;
+ height: 100%;
+ }
+
+ > a {
+ color: inherit;
+ display: block;
+ height: 100%;
+ outline: none;
+ position: absolute;
+ width: 100%;
+
+ &:is(:focus) {
+ .card {
+ @include ds-focus;
+ }
+ }
+
+ &:is(.active, :focus) {
+ .card {
+ @include fade-in-card;
+ }
+
+ .card-title {
+ color: var(--newtab-primary-action-background);
+ }
+ }
+ }
+
+ &:is(:hover, :focus, .active):not(.placeholder) {
+ @include context-menu-button-hover;
+
+ outline: none;
+
+ .card-title {
+ color: var(--newtab-primary-action-background);
+ }
+
+ .alternate ~ .card-host-name {
+ display: none;
+ }
+
+ .card-host-name.alternate {
+ display: block;
+ }
+ }
+
+ .card-preview-image-outer {
+ background-color: var(--newtab-element-secondary-color);
+ border-radius: $border-radius-new $border-radius-new 0 0;
+ height: $card-preview-image-height;
+ overflow: hidden;
+ position: relative;
+
+ &::after {
+ border-bottom: 1px solid var(--newtab-card-hairline-color);
+ bottom: 0;
+ content: '';
+ position: absolute;
+ width: 100%;
+ }
+
+ .card-preview-image {
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ height: 100%;
+ opacity: 0;
+ transition: opacity 1s $photon-easing;
+ width: 100%;
+
+ &.loaded {
+ opacity: 1;
+ }
+ }
+ }
+
+ .card-details {
+ padding: 15px 16px 12px;
+ }
+
+ .card-text {
+ max-height: 4 * $card-text-line-height + $card-title-margin;
+ overflow: hidden;
+
+ &.no-host-name,
+ &.no-context {
+ max-height: 5 * $card-text-line-height + $card-title-margin;
+ }
+
+ &.no-host-name.no-context {
+ max-height: 6 * $card-text-line-height + $card-title-margin;
+ }
+
+ &:not(.no-description) .card-title {
+ max-height: 3 * $card-text-line-height;
+ overflow: hidden;
+ }
+ }
+
+ .card-host-name {
+ color: var(--newtab-text-secondary-color);
+ font-size: 10px;
+ overflow: hidden;
+ padding-bottom: 4px;
+ text-overflow: ellipsis;
+ text-transform: uppercase;
+ white-space: nowrap;
+ }
+
+ .card-host-name.alternate { display: none; }
+
+ .card-title {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: $card-text-line-height;
+ margin: 0 0 $card-title-margin;
+ word-wrap: break-word;
+ }
+
+ .card-description {
+ font-size: 12px;
+ line-height: $card-text-line-height;
+ margin: 0;
+ overflow: hidden;
+ word-wrap: break-word;
+ }
+
+ .card-context {
+ bottom: 0;
+ color: var(--newtab-text-secondary-color);
+ display: flex;
+ font-size: 11px;
+ inset-inline-start: 0;
+ padding: 9px 16px 9px 14px;
+ position: absolute;
+ }
+
+ .card-context-icon {
+ fill: var(--newtab-text-secondary-color);
+ height: 22px;
+ margin-inline-end: 6px;
+ }
+
+ .card-context-label {
+ flex-grow: 1;
+ line-height: 22px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.normal-cards {
+ .card-outer {
+ // Wide layout styles
+ @media (min-width: $break-point-widest) {
+ $line-height: 23px;
+
+ height: $card-height-large;
+
+ .card-preview-image-outer {
+ height: $card-preview-image-height-large;
+ }
+
+ .card-details {
+ padding: 13px 16px 12px;
+ }
+
+ .card-text {
+ max-height: 6 * $line-height + $card-title-margin;
+ }
+
+ .card-host-name {
+ font-size: 12px;
+ padding-bottom: 5px;
+ }
+
+ .card-title {
+ font-size: 17px;
+ line-height: $line-height;
+ margin-bottom: 0;
+ }
+
+ .card-text:not(.no-description) {
+ .card-title {
+ max-height: 3 * $line-height;
+ }
+ }
+
+ .card-description {
+ font-size: 15px;
+ line-height: $line-height;
+ }
+
+ .card-context {
+ bottom: 4px;
+ font-size: 14px;
+ }
+ }
+ }
+}
+
+.compact-cards {
+ $card-detail-vertical-spacing: 12px;
+ $card-title-font-size: 12px;
+
+ .card-outer {
+ height: $card-height-compact;
+
+ .card-preview-image-outer {
+ height: $card-preview-image-height-compact;
+ }
+
+ .card-details {
+ padding: $card-detail-vertical-spacing 16px;
+ }
+
+ .card-host-name {
+ line-height: 10px;
+ }
+
+ .card-text {
+ .card-title,
+ &:not(.no-description) .card-title {
+ font-size: $card-title-font-size;
+ line-height: $card-title-font-size + 1;
+ max-height: $card-title-font-size + 5;
+ overflow: hidden;
+ padding: 0 0 4px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ .card-description {
+ display: none;
+ }
+
+ .card-context {
+ $icon-size: 16px;
+ $container-size: 32px;
+
+ background-color: var(--newtab-background-color-secondary);
+ border-radius: math.div($container-size, 2);
+ clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing));
+ height: $container-size;
+ width: $container-size;
+ padding: math.div($container-size - $icon-size, 2);
+ // The -1 at the end is so both opacity borders don't overlap, which causes bug 1629483
+ top: $card-preview-image-height-compact - math.div($container-size, 2) - 1;
+ inset-inline-end: 12px;
+ inset-inline-start: auto;
+
+ &::after {
+ border: 1px solid var(--newtab-card-hairline-color);
+ border-bottom: 0;
+ border-radius: math.div($container-size, 2) + 1 math.div($container-size, 2) + 1 0 0;
+ content: '';
+ position: absolute;
+ height: math.div($container-size + 2, 2);
+ width: $container-size + 2;
+ top: -1px;
+ left: -1px;
+ }
+
+ .card-context-icon {
+ margin-inline-end: 0;
+ height: $icon-size;
+ width: $icon-size;
+
+ &.icon-bookmark-added {
+ fill: $bookmark-icon-fill;
+ }
+
+ &.icon-download {
+ fill: $download-icon-fill;
+ }
+
+ &.icon-pocket {
+ fill: $pocket-icon-fill;
+ }
+ }
+
+ .card-context-label {
+ display: none;
+ }
+ }
+ }
+
+ @media not all and (min-width: $break-point-widest) {
+ .hide-for-narrow {
+ display: none;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/Card/types.js b/browser/components/newtab/content-src/components/Card/types.js
new file mode 100644
index 0000000000..0b17eea408
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Card/types.js
@@ -0,0 +1,30 @@
+/* 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/. */
+
+export const cardContextTypes = {
+ history: {
+ fluentID: "newtab-label-visited",
+ icon: "history-item",
+ },
+ removedBookmark: {
+ fluentID: "newtab-label-removed-bookmark",
+ icon: "bookmark-removed",
+ },
+ bookmark: {
+ fluentID: "newtab-label-bookmarked",
+ icon: "bookmark-added",
+ },
+ trending: {
+ fluentID: "newtab-label-recommended",
+ icon: "trending",
+ },
+ pocket: {
+ fluentID: "newtab-label-saved",
+ icon: "pocket",
+ },
+ download: {
+ fluentID: "newtab-label-download",
+ icon: "download",
+ },
+};
diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx
new file mode 100644
index 0000000000..679e8e137f
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx
@@ -0,0 +1,116 @@
+/* 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 { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+import React from "react";
+import { connect } from "react-redux";
+
+/**
+ * A section that can collapse. As of bug 1710937, it can no longer collapse.
+ * See bug 1727365 for follow-up work to simplify this component.
+ */
+export class _CollapsibleSection extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onBodyMount = this.onBodyMount.bind(this);
+ this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this);
+ this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this);
+ this.onMenuUpdate = this.onMenuUpdate.bind(this);
+ this.state = {
+ menuButtonHover: false,
+ showContextMenu: false,
+ };
+ this.setContextMenuButtonRef = this.setContextMenuButtonRef.bind(this);
+ }
+
+ setContextMenuButtonRef(element) {
+ this.contextMenuButtonRef = element;
+ }
+
+ onBodyMount(node) {
+ this.sectionBody = node;
+ }
+
+ onMenuButtonMouseEnter() {
+ this.setState({ menuButtonHover: true });
+ }
+
+ onMenuButtonMouseLeave() {
+ this.setState({ menuButtonHover: false });
+ }
+
+ onMenuUpdate(showContextMenu) {
+ this.setState({ showContextMenu });
+ }
+
+ render() {
+ const { isAnimating, maxHeight, menuButtonHover, showContextMenu } =
+ this.state;
+ const { id, collapsed, learnMore, title, subTitle } = this.props;
+ const active = menuButtonHover || showContextMenu;
+ let bodyStyle;
+ if (isAnimating && !collapsed) {
+ bodyStyle = { maxHeight };
+ } else if (!isAnimating && collapsed) {
+ bodyStyle = { display: "none" };
+ }
+ let titleStyle;
+ if (this.props.hideTitle) {
+ titleStyle = { visibility: "hidden" };
+ }
+ const hasSubtitleClassName = subTitle ? `has-subtitle` : ``;
+ return (
+ <section
+ className={`collapsible-section ${this.props.className}${
+ active ? " active" : ""
+ }`}
+ // Note: data-section-id is used for web extension api tests in mozilla central
+ data-section-id={id}
+ >
+ <div className="section-top-bar">
+ <h3
+ className={`section-title-container ${hasSubtitleClassName}`}
+ style={titleStyle}
+ >
+ <span className="section-title">
+ <FluentOrText message={title} />
+ </span>
+ <span className="learn-more-link-wrapper">
+ {learnMore && (
+ <span className="learn-more-link">
+ <FluentOrText message={learnMore.link.message}>
+ <a href={learnMore.link.href} />
+ </FluentOrText>
+ </span>
+ )}
+ </span>
+ {subTitle && (
+ <span className="section-sub-title">
+ <FluentOrText message={subTitle} />
+ </span>
+ )}
+ </h3>
+ </div>
+ <ErrorBoundary className="section-body-fallback">
+ <div ref={this.onBodyMount} style={bodyStyle}>
+ {this.props.children}
+ </div>
+ </ErrorBoundary>
+ </section>
+ );
+ }
+}
+
+_CollapsibleSection.defaultProps = {
+ document: global.document || {
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ visibilityState: "hidden",
+ },
+};
+
+export const CollapsibleSection = connect(state => ({
+ Prefs: state.Prefs,
+}))(_CollapsibleSection);
diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss
new file mode 100644
index 0000000000..9811339b27
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss
@@ -0,0 +1,106 @@
+.collapsible-section {
+ padding: $section-vertical-padding $section-horizontal-padding;
+
+ .section-title-container {
+ margin: 0;
+
+ &.has-subtitle {
+ display: flex;
+ flex-direction: column;
+
+ @media (min-width: $break-point-large) {
+ flex-direction: row;
+ align-items: baseline;
+ justify-content: space-between;
+ }
+ }
+ }
+
+ .section-title {
+ font-size: $section-title-font-size;
+ font-weight: 600;
+ color: var(--newtab-text-primary-color);
+
+ &.grey-title {
+ color: var(--newtab-text-primary-color);
+ display: inline-block;
+ fill: var(--newtab-text-primary-color);
+ vertical-align: middle;
+ }
+
+ .section-title-contents {
+ // Center "What's Pocket?" for "mobile" viewport
+ @media (max-width: $break-point-medium - 1) {
+ display: block;
+
+ .learn-more-link-wrapper {
+ display: block;
+ text-align: center;
+
+ .learn-more-link {
+ margin-inline-start: 0;
+ }
+ }
+ }
+
+ vertical-align: top;
+ }
+ }
+
+ .section-sub-title {
+ font-size: 14px;
+ line-height: 16px;
+ color: var(--newtab-text-secondary-color);
+ opacity: 0.3;
+ }
+
+ .section-top-bar {
+ min-height: 19px;
+ margin-bottom: 13px;
+ position: relative;
+ }
+
+ &.active {
+ background: var(--newtab-element-hover-color);
+ border-radius: 4px;
+ }
+
+ .learn-more-link {
+ font-size: 13px;
+ margin-inline-start: 12px;
+
+ a {
+ color: var(--newtab-primary-action-background);
+ }
+ }
+
+ .section-body-fallback {
+ height: $card-height;
+ }
+
+ .section-body {
+ // This is so the top sites favicon and card dropshadows don't get clipped during animation:
+ $horizontal-padding: 7px;
+
+ margin: 0 (-$horizontal-padding);
+ padding: 0 $horizontal-padding;
+
+ &.animating {
+ overflow: hidden;
+ pointer-events: none;
+ }
+ }
+
+ &[data-section-id='topsites'] {
+ .section-top-bar {
+ display: none;
+ }
+ }
+
+ // Hide first story card for the medium breakpoint to prevent orphaned third story
+ &[data-section-id='topstories'] .card-outer:first-child {
+ @media (min-width: $break-point-medium) and (max-width: $break-point-large - 1) {
+ display: none;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx
new file mode 100644
index 0000000000..4efd8c712e
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx
@@ -0,0 +1,177 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { perfService as perfSvc } from "content-src/lib/perf-service";
+import React from "react";
+
+// Currently record only a fixed set of sections. This will prevent data
+// from custom sections from showing up or from topstories.
+const RECORDED_SECTIONS = ["highlights", "topsites"];
+
+export class ComponentPerfTimer extends React.Component {
+ constructor(props) {
+ super(props);
+ // Just for test dependency injection:
+ this.perfSvc = this.props.perfSvc || perfSvc;
+
+ this._sendBadStateEvent = this._sendBadStateEvent.bind(this);
+ this._sendPaintedEvent = this._sendPaintedEvent.bind(this);
+ this._reportMissingData = false;
+ this._timestampHandled = false;
+ this._recordedFirstRender = false;
+ }
+
+ componentDidMount() {
+ if (!RECORDED_SECTIONS.includes(this.props.id)) {
+ return;
+ }
+
+ this._maybeSendPaintedEvent();
+ }
+
+ componentDidUpdate() {
+ if (!RECORDED_SECTIONS.includes(this.props.id)) {
+ return;
+ }
+
+ this._maybeSendPaintedEvent();
+ }
+
+ /**
+ * Call the given callback after the upcoming frame paints.
+ *
+ * @note Both setTimeout and requestAnimationFrame are throttled when the page
+ * is hidden, so this callback may get called up to a second or so after the
+ * requestAnimationFrame "paint" for hidden tabs.
+ *
+ * Newtabs hidden while loading will presumably be fairly rare (other than
+ * preloaded tabs, which we will be filtering out on the server side), so such
+ * cases should get lost in the noise.
+ *
+ * If we decide that it's important to find out when something that's hidden
+ * has "painted", however, another option is to post a message to this window.
+ * That should happen even faster than setTimeout, and, at least as of this
+ * writing, it's not throttled in hidden windows in Firefox.
+ *
+ * @param {Function} callback
+ *
+ * @returns void
+ */
+ _afterFramePaint(callback) {
+ requestAnimationFrame(() => setTimeout(callback, 0));
+ }
+
+ _maybeSendBadStateEvent() {
+ // Follow up bugs:
+ // https://github.com/mozilla/activity-stream/issues/3691
+ if (!this.props.initialized) {
+ // Remember to report back when data is available.
+ this._reportMissingData = true;
+ } else if (this._reportMissingData) {
+ this._reportMissingData = false;
+ // Report how long it took for component to become initialized.
+ this._sendBadStateEvent();
+ }
+ }
+
+ _maybeSendPaintedEvent() {
+ // If we've already handled a timestamp, don't do it again.
+ if (this._timestampHandled || !this.props.initialized) {
+ return;
+ }
+
+ // And if we haven't, we're doing so now, so remember that. Even if
+ // something goes wrong in the callback, we can't try again, as we'd be
+ // sending back the wrong data, and we have to do it here, so that other
+ // calls to this method while waiting for the next frame won't also try to
+ // handle it.
+ this._timestampHandled = true;
+ this._afterFramePaint(this._sendPaintedEvent);
+ }
+
+ /**
+ * Triggered by call to render. Only first call goes through due to
+ * `_recordedFirstRender`.
+ */
+ _ensureFirstRenderTsRecorded() {
+ // Used as t0 for recording how long component took to initialize.
+ if (!this._recordedFirstRender) {
+ this._recordedFirstRender = true;
+ // topsites_first_render_ts, highlights_first_render_ts.
+ const key = `${this.props.id}_first_render_ts`;
+ this.perfSvc.mark(key);
+ }
+ }
+
+ /**
+ * Creates `SAVE_SESSION_PERF_DATA` with timestamp in ms
+ * of how much longer the data took to be ready for display than it would
+ * have been the ideal case.
+ * https://github.com/mozilla/ping-centre/issues/98
+ */
+ _sendBadStateEvent() {
+ // highlights_data_ready_ts, topsites_data_ready_ts.
+ const dataReadyKey = `${this.props.id}_data_ready_ts`;
+ this.perfSvc.mark(dataReadyKey);
+
+ try {
+ const firstRenderKey = `${this.props.id}_first_render_ts`;
+ // value has to be Int32.
+ const value = parseInt(
+ this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) -
+ this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey),
+ 10
+ );
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ // highlights_data_late_by_ms, topsites_data_late_by_ms.
+ data: { [`${this.props.id}_data_late_by_ms`]: value },
+ })
+ );
+ } catch (ex) {
+ // If this failed, it's likely because the `privacy.resistFingerprinting`
+ // pref is true.
+ }
+ }
+
+ _sendPaintedEvent() {
+ // Record first_painted event but only send if topsites.
+ if (this.props.id !== "topsites") {
+ return;
+ }
+
+ // topsites_first_painted_ts.
+ const key = `${this.props.id}_first_painted_ts`;
+ this.perfSvc.mark(key);
+
+ try {
+ const data = {};
+ data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key);
+
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data,
+ })
+ );
+ } catch (ex) {
+ // If this failed, it's likely because the `privacy.resistFingerprinting`
+ // pref is true. We should at least not blow up, and should continue
+ // to set this._timestampHandled to avoid going through this again.
+ }
+ }
+
+ render() {
+ if (RECORDED_SECTIONS.includes(this.props.id)) {
+ this._ensureFirstRenderTsRecorded();
+ this._maybeSendBadStateEvent();
+ }
+ return this.props.children;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx
new file mode 100644
index 0000000000..f69e540079
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx
@@ -0,0 +1,103 @@
+/* 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 { actionCreators as ac, actionTypes } from "common/Actions.sys.mjs";
+import { connect } from "react-redux";
+import React from "react";
+
+/**
+ * ConfirmDialog component.
+ * One primary action button, one cancel button.
+ *
+ * Content displayed is controlled by `data` prop the component receives.
+ * Example:
+ * data: {
+ * // Any sort of data needed to be passed around by actions.
+ * payload: site.url,
+ * // Primary button AlsoToMain action.
+ * action: "DELETE_HISTORY_URL",
+ * // Primary button USerEvent action.
+ * userEvent: "DELETE",
+ * // Array of locale ids to display.
+ * message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"],
+ * // Text for primary button.
+ * confirm_button_string_id: "menu_action_delete"
+ * },
+ */
+export class _ConfirmDialog extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this._handleCancelBtn = this._handleCancelBtn.bind(this);
+ this._handleConfirmBtn = this._handleConfirmBtn.bind(this);
+ }
+
+ _handleCancelBtn() {
+ this.props.dispatch({ type: actionTypes.DIALOG_CANCEL });
+ this.props.dispatch(
+ ac.UserEvent({
+ event: actionTypes.DIALOG_CANCEL,
+ source: this.props.data.eventSource,
+ })
+ );
+ }
+
+ _handleConfirmBtn() {
+ this.props.data.onConfirm.forEach(this.props.dispatch);
+ }
+
+ _renderModalMessage() {
+ const message_body = this.props.data.body_string_id;
+
+ if (!message_body) {
+ return null;
+ }
+
+ return (
+ <span>
+ {message_body.map(msg => (
+ <p key={msg} data-l10n-id={msg} />
+ ))}
+ </span>
+ );
+ }
+
+ render() {
+ if (!this.props.visible) {
+ return null;
+ }
+
+ return (
+ <div className="confirmation-dialog">
+ <div
+ className="modal-overlay"
+ onClick={this._handleCancelBtn}
+ role="presentation"
+ />
+ <div className="modal">
+ <section className="modal-message">
+ {this.props.data.icon && (
+ <span
+ className={`icon icon-spacer icon-${this.props.data.icon}`}
+ />
+ )}
+ {this._renderModalMessage()}
+ </section>
+ <section className="actions">
+ <button
+ onClick={this._handleCancelBtn}
+ data-l10n-id={this.props.data.cancel_button_string_id}
+ />
+ <button
+ className="done"
+ onClick={this._handleConfirmBtn}
+ data-l10n-id={this.props.data.confirm_button_string_id}
+ />
+ </section>
+ </div>
+ </div>
+ );
+ }
+}
+
+export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog);
diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss
new file mode 100644
index 0000000000..ca9940ffc5
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss
@@ -0,0 +1,68 @@
+.confirmation-dialog {
+ .modal {
+ box-shadow: $shadow-secondary;
+ left: 0;
+ margin: auto;
+ position: fixed;
+ right: 0;
+ top: 20%;
+ width: 400px;
+ }
+
+ section {
+ margin: 0;
+ }
+
+ .modal-message {
+ display: flex;
+ padding: 16px;
+ padding-bottom: 0;
+
+ p {
+ margin: 0;
+ margin-bottom: 16px;
+ }
+ }
+
+ .actions {
+ border: 0;
+ display: flex;
+ flex-wrap: nowrap;
+ padding: 0 16px;
+
+ button {
+ margin-inline-end: 16px;
+ padding-inline-end: 18px;
+ padding-inline-start: 18px;
+ white-space: normal;
+ width: 50%;
+
+ &.done {
+ margin-inline-end: 0;
+ margin-inline-start: 0;
+ }
+ }
+ }
+
+ .icon {
+ margin-inline-end: 16px;
+ }
+}
+
+.modal-overlay {
+ background: var(--newtab-overlay-color);
+ height: 100%;
+ left: 0;
+ position: fixed;
+ top: 0;
+ width: 100%;
+ z-index: 11001;
+}
+
+.modal {
+ background: var(--newtab-background-color-secondary);
+ border: $border-secondary;
+ border-radius: 5px;
+ font-size: 15px;
+ z-index: 11002;
+}
diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx
new file mode 100644
index 0000000000..5ea6a57f71
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx
@@ -0,0 +1,176 @@
+/* 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 React from "react";
+import { connect } from "react-redux";
+
+export class ContextMenu extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.hideContext = this.hideContext.bind(this);
+ this.onShow = this.onShow.bind(this);
+ this.onClick = this.onClick.bind(this);
+ }
+
+ hideContext() {
+ this.props.onUpdate(false);
+ }
+
+ onShow() {
+ if (this.props.onShow) {
+ this.props.onShow();
+ }
+ }
+
+ componentDidMount() {
+ this.onShow();
+ setTimeout(() => {
+ global.addEventListener("click", this.hideContext);
+ }, 0);
+ }
+
+ componentWillUnmount() {
+ global.removeEventListener("click", this.hideContext);
+ }
+
+ onClick(event) {
+ // Eat all clicks on the context menu so they don't bubble up to window.
+ // This prevents the context menu from closing when clicking disabled items
+ // or the separators.
+ event.stopPropagation();
+ }
+
+ render() {
+ // Disabling focus on the menu span allows the first tab to focus on the first menu item instead of the wrapper.
+ return (
+ // eslint-disable-next-line jsx-a11y/interactive-supports-focus
+ <span className="context-menu">
+ <ul
+ role="menu"
+ onClick={this.onClick}
+ onKeyDown={this.onClick}
+ className="context-menu-list"
+ >
+ {this.props.options.map((option, i) =>
+ option.type === "separator" ? (
+ <li key={i} className="separator" role="separator" />
+ ) : (
+ option.type !== "empty" && (
+ <ContextMenuItem
+ key={i}
+ option={option}
+ hideContext={this.hideContext}
+ keyboardAccess={this.props.keyboardAccess}
+ />
+ )
+ )
+ )}
+ </ul>
+ </span>
+ );
+ }
+}
+
+export class _ContextMenuItem extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onClick = this.onClick.bind(this);
+ this.onKeyDown = this.onKeyDown.bind(this);
+ this.onKeyUp = this.onKeyUp.bind(this);
+ this.focusFirst = this.focusFirst.bind(this);
+ }
+
+ onClick(event) {
+ this.props.hideContext();
+ this.props.option.onClick(event);
+ }
+
+ // Focus the first menu item if the menu was accessed via the keyboard.
+ focusFirst(button) {
+ if (this.props.keyboardAccess && button) {
+ button.focus();
+ }
+ }
+
+ // This selects the correct node based on the key pressed
+ focusSibling(target, key) {
+ const parent = target.parentNode;
+ const closestSiblingSelector =
+ key === "ArrowUp" ? "previousSibling" : "nextSibling";
+ if (!parent[closestSiblingSelector]) {
+ return;
+ }
+ if (parent[closestSiblingSelector].firstElementChild) {
+ parent[closestSiblingSelector].firstElementChild.focus();
+ } else {
+ parent[closestSiblingSelector][
+ closestSiblingSelector
+ ].firstElementChild.focus();
+ }
+ }
+
+ onKeyDown(event) {
+ const { option } = this.props;
+ switch (event.key) {
+ case "Tab":
+ // tab goes down in context menu, shift + tab goes up in context menu
+ // if we're on the last item, one more tab will close the context menu
+ // similarly, if we're on the first item, one more shift + tab will close it
+ if (
+ (event.shiftKey && option.first) ||
+ (!event.shiftKey && option.last)
+ ) {
+ this.props.hideContext();
+ }
+ break;
+ case "ArrowUp":
+ case "ArrowDown":
+ event.preventDefault();
+ this.focusSibling(event.target, event.key);
+ break;
+ case "Enter":
+ case " ":
+ event.preventDefault();
+ this.props.hideContext();
+ option.onClick();
+ break;
+ case "Escape":
+ this.props.hideContext();
+ break;
+ }
+ }
+
+ // Prevents the default behavior of spacebar
+ // scrolling the page & auto-triggering buttons.
+ onKeyUp(event) {
+ if (event.key === " ") {
+ event.preventDefault();
+ }
+ }
+
+ render() {
+ const { option } = this.props;
+ return (
+ <li role="presentation" className="context-menu-item">
+ <button
+ className={option.disabled ? "disabled" : ""}
+ role="menuitem"
+ onClick={this.onClick}
+ onKeyDown={this.onKeyDown}
+ onKeyUp={this.onKeyUp}
+ ref={option.first ? this.focusFirst : null}
+ aria-haspopup={
+ option.id === "newtab-menu-edit-topsites" ? "dialog" : null
+ }
+ >
+ <span data-l10n-id={option.string_id || option.id} />
+ </button>
+ </li>
+ );
+ }
+}
+
+export const ContextMenuItem = connect(state => ({
+ Prefs: state.Prefs,
+}))(_ContextMenuItem);
diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx
new file mode 100644
index 0000000000..0364f5386a
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx
@@ -0,0 +1,72 @@
+/* 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 React from "react";
+
+export class ContextMenuButton extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ showContextMenu: false,
+ contextMenuKeyboard: false,
+ };
+ this.onClick = this.onClick.bind(this);
+ this.onKeyDown = this.onKeyDown.bind(this);
+ this.onUpdate = this.onUpdate.bind(this);
+ }
+
+ openContextMenu(isKeyBoard, event) {
+ if (this.props.onUpdate) {
+ this.props.onUpdate(true);
+ }
+ this.setState({
+ showContextMenu: true,
+ contextMenuKeyboard: isKeyBoard,
+ });
+ }
+
+ onClick(event) {
+ event.preventDefault();
+ this.openContextMenu(false, event);
+ }
+
+ onKeyDown(event) {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ this.openContextMenu(true, event);
+ }
+ }
+
+ onUpdate(showContextMenu) {
+ if (this.props.onUpdate) {
+ this.props.onUpdate(showContextMenu);
+ }
+ this.setState({ showContextMenu });
+ }
+
+ render() {
+ const { tooltipArgs, tooltip, children, refFunction } = this.props;
+ const { showContextMenu, contextMenuKeyboard } = this.state;
+
+ return (
+ <React.Fragment>
+ <button
+ aria-haspopup="true"
+ data-l10n-id={tooltip}
+ data-l10n-args={tooltipArgs ? JSON.stringify(tooltipArgs) : null}
+ className="context-menu-button icon"
+ onKeyDown={this.onKeyDown}
+ onClick={this.onClick}
+ ref={refFunction}
+ />
+ {showContextMenu
+ ? React.cloneElement(children, {
+ keyboardAccess: contextMenuKeyboard,
+ onUpdate: this.onUpdate,
+ })
+ : null}
+ </React.Fragment>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss
new file mode 100644
index 0000000000..c0074128e6
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss
@@ -0,0 +1,57 @@
+@use 'sass:math';
+
+.context-menu {
+ background: var(--newtab-background-color-secondary);
+ border-radius: $context-menu-border-radius;
+ box-shadow: $context-menu-shadow;
+ display: block;
+ font-size: $context-menu-font-size;
+ margin-inline-start: 5px;
+ inset-inline-start: 100%;
+ position: absolute;
+ top: math.div($context-menu-button-size, 4);
+ z-index: 8;
+
+ > ul {
+ list-style: none;
+ margin: 0;
+ padding: $context-menu-outer-padding 0;
+
+ > li {
+ margin: 0;
+ width: 100%;
+
+ &.separator {
+ border-bottom: $border-secondary;
+ margin: $context-menu-outer-padding 0;
+ }
+
+ > a,
+ > button {
+ align-items: center;
+ color: inherit;
+ cursor: pointer;
+ display: flex;
+ width: 100%;
+ line-height: 16px;
+ outline: none;
+ border: 0;
+ padding: $context-menu-item-padding;
+ white-space: nowrap;
+
+ &:is(:focus, :hover) {
+ background: var(--newtab-element-secondary-hover-color);
+ }
+
+ &:active {
+ background: var(--newtab-element-secondary-active-color);
+ }
+
+ &.disabled {
+ opacity: 0.4;
+ pointer-events: none;
+ }
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx
new file mode 100644
index 0000000000..522ea6841f
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx
@@ -0,0 +1,11 @@
+/* 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 React from "react";
+
+export class BackgroundsSection extends React.PureComponent {
+ render() {
+ return <div />;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx
new file mode 100644
index 0000000000..423fd131e2
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx
@@ -0,0 +1,308 @@
+/* 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 React from "react";
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+
+export class ContentSection extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onPreferenceSelect = this.onPreferenceSelect.bind(this);
+
+ // Refs are necessary for dynamically measuring drawer heights for slide animations
+ this.topSitesDrawerRef = React.createRef();
+ this.pocketDrawerRef = React.createRef();
+ }
+
+ inputUserEvent(eventSource, status) {
+ this.props.dispatch(
+ ac.UserEvent({
+ event: "PREF_CHANGED",
+ source: eventSource,
+ value: { status, menu_source: "CUSTOMIZE_MENU" },
+ })
+ );
+ }
+
+ onPreferenceSelect(e) {
+ let prefName = e.target.getAttribute("preference");
+ const eventSource = e.target.getAttribute("eventSource"); // TOP_SITES, TOP_STORIES, HIGHLIGHTS
+ let value;
+ if (e.target.nodeName === "SELECT") {
+ value = parseInt(e.target.value, 10);
+ } else if (e.target.nodeName === "INPUT") {
+ value = e.target.checked;
+ if (eventSource) {
+ this.inputUserEvent(eventSource, value);
+ }
+ }
+ this.props.setPref(prefName, value);
+ }
+
+ componentDidMount() {
+ this.setDrawerMargins();
+ }
+
+ componentDidUpdate() {
+ this.setDrawerMargins();
+ }
+
+ setDrawerMargins() {
+ this.setDrawerMargin(
+ `TOP_SITES`,
+ this.props.enabledSections.topSitesEnabled
+ );
+ this.setDrawerMargin(
+ `TOP_STORIES`,
+ this.props.enabledSections.pocketEnabled
+ );
+ }
+
+ setDrawerMargin(drawerID, isOpen) {
+ let drawerRef;
+
+ if (drawerID === `TOP_SITES`) {
+ drawerRef = this.topSitesDrawerRef.current;
+ } else if (drawerID === `TOP_STORIES`) {
+ drawerRef = this.pocketDrawerRef.current;
+ } else {
+ return;
+ }
+
+ let drawerHeight;
+
+ if (drawerRef) {
+ drawerHeight = parseFloat(window.getComputedStyle(drawerRef)?.height);
+
+ if (isOpen) {
+ drawerRef.style.marginTop = `0`;
+ } else {
+ drawerRef.style.marginTop = `-${drawerHeight}px`;
+ }
+ }
+ }
+
+ render() {
+ const {
+ enabledSections,
+ mayHaveSponsoredTopSites,
+ pocketRegion,
+ mayHaveSponsoredStories,
+ mayHaveRecentSaves,
+ openPreferences,
+ } = this.props;
+ const {
+ topSitesEnabled,
+ pocketEnabled,
+ highlightsEnabled,
+ showSponsoredTopSitesEnabled,
+ showSponsoredPocketEnabled,
+ showRecentSavesEnabled,
+ topSitesRowsCount,
+ } = enabledSections;
+
+ return (
+ <div className="home-section">
+ <div id="shortcuts-section" className="section">
+ <label className="switch">
+ <input
+ id="shortcuts-toggle"
+ checked={topSitesEnabled}
+ type="checkbox"
+ onChange={this.onPreferenceSelect}
+ preference="feeds.topsites"
+ aria-labelledby="custom-shortcuts-title"
+ aria-describedby="custom-shortcuts-subtitle"
+ eventSource="TOP_SITES"
+ />
+ <span className="slider" role="presentation"></span>
+ </label>
+ <div>
+ <h2 id="custom-shortcuts-title" className="title">
+ <label
+ htmlFor="shortcuts-toggle"
+ data-l10n-id="newtab-custom-shortcuts-title"
+ ></label>
+ </h2>
+ <p
+ id="custom-shortcuts-subtitle"
+ className="subtitle"
+ data-l10n-id="newtab-custom-shortcuts-subtitle"
+ ></p>
+ <div className="more-info-top-wrapper">
+ <div className="more-information" ref={this.topSitesDrawerRef}>
+ <select
+ id="row-selector"
+ className="selector"
+ name="row-count"
+ preference="topSitesRows"
+ value={topSitesRowsCount}
+ onChange={this.onPreferenceSelect}
+ disabled={!topSitesEnabled}
+ aria-labelledby="custom-shortcuts-title"
+ >
+ <option
+ value="1"
+ data-l10n-id="newtab-custom-row-selector"
+ data-l10n-args='{"num": 1}'
+ />
+ <option
+ value="2"
+ data-l10n-id="newtab-custom-row-selector"
+ data-l10n-args='{"num": 2}'
+ />
+ <option
+ value="3"
+ data-l10n-id="newtab-custom-row-selector"
+ data-l10n-args='{"num": 3}'
+ />
+ <option
+ value="4"
+ data-l10n-id="newtab-custom-row-selector"
+ data-l10n-args='{"num": 4}'
+ />
+ </select>
+ {mayHaveSponsoredTopSites && (
+ <div className="check-wrapper" role="presentation">
+ <input
+ id="sponsored-shortcuts"
+ className="sponsored-checkbox"
+ disabled={!topSitesEnabled}
+ checked={showSponsoredTopSitesEnabled}
+ type="checkbox"
+ onChange={this.onPreferenceSelect}
+ preference="showSponsoredTopSites"
+ eventSource="SPONSORED_TOP_SITES"
+ />
+ <label
+ className="sponsored"
+ htmlFor="sponsored-shortcuts"
+ data-l10n-id="newtab-custom-sponsored-sites"
+ />
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {pocketRegion && (
+ <div id="pocket-section" className="section">
+ <label className="switch">
+ <input
+ id="pocket-toggle"
+ checked={pocketEnabled}
+ type="checkbox"
+ onChange={this.onPreferenceSelect}
+ preference="feeds.section.topstories"
+ aria-labelledby="custom-pocket-title"
+ aria-describedby="custom-pocket-subtitle"
+ eventSource="TOP_STORIES"
+ />
+ <span className="slider" role="presentation"></span>
+ </label>
+ <div>
+ <h2 id="custom-pocket-title" className="title">
+ <label
+ htmlFor="pocket-toggle"
+ data-l10n-id="newtab-custom-pocket-title"
+ ></label>
+ </h2>
+ <p
+ id="custom-pocket-subtitle"
+ className="subtitle"
+ data-l10n-id="newtab-custom-pocket-subtitle"
+ />
+ {(mayHaveSponsoredStories || mayHaveRecentSaves) && (
+ <div className="more-info-pocket-wrapper">
+ <div className="more-information" ref={this.pocketDrawerRef}>
+ {mayHaveSponsoredStories && (
+ <div className="check-wrapper" role="presentation">
+ <input
+ id="sponsored-pocket"
+ className="sponsored-checkbox"
+ disabled={!pocketEnabled}
+ checked={showSponsoredPocketEnabled}
+ type="checkbox"
+ onChange={this.onPreferenceSelect}
+ preference="showSponsored"
+ eventSource="POCKET_SPOCS"
+ />
+ <label
+ className="sponsored"
+ htmlFor="sponsored-pocket"
+ data-l10n-id="newtab-custom-pocket-sponsored"
+ />
+ </div>
+ )}
+ {mayHaveRecentSaves && (
+ <div className="check-wrapper" role="presentation">
+ <input
+ id="recent-saves-pocket"
+ className="sponsored-checkbox"
+ disabled={!pocketEnabled}
+ checked={showRecentSavesEnabled}
+ type="checkbox"
+ onChange={this.onPreferenceSelect}
+ preference="showRecentSaves"
+ eventSource="POCKET_RECENT_SAVES"
+ />
+ <label
+ className="sponsored"
+ htmlFor="recent-saves-pocket"
+ data-l10n-id="newtab-custom-pocket-show-recent-saves"
+ />
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ <div id="recent-section" className="section">
+ <label className="switch">
+ <input
+ id="highlights-toggle"
+ checked={highlightsEnabled}
+ type="checkbox"
+ onChange={this.onPreferenceSelect}
+ preference="feeds.section.highlights"
+ eventSource="HIGHLIGHTS"
+ aria-labelledby="custom-recent-title"
+ aria-describedby="custom-recent-subtitle"
+ />
+ <span className="slider" role="presentation"></span>
+ </label>
+ <div>
+ <h2 id="custom-recent-title" className="title">
+ <label
+ htmlFor="highlights-toggle"
+ data-l10n-id="newtab-custom-recent-title"
+ ></label>
+ </h2>
+
+ <p
+ id="custom-recent-subtitle"
+ className="subtitle"
+ data-l10n-id="newtab-custom-recent-subtitle"
+ />
+ </div>
+ </div>
+
+ <span className="divider" role="separator"></span>
+
+ <div>
+ <button
+ id="settings-link"
+ className="external-link"
+ onClick={openPreferences}
+ data-l10n-id="newtab-custom-settings"
+ />
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx
new file mode 100644
index 0000000000..2b16c7fd39
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx
@@ -0,0 +1,87 @@
+/* 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 { BackgroundsSection } from "content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection";
+import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection";
+import { connect } from "react-redux";
+import React from "react";
+import { CSSTransition } from "react-transition-group";
+
+export class _CustomizeMenu extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onEntered = this.onEntered.bind(this);
+ this.onExited = this.onExited.bind(this);
+ }
+
+ onEntered() {
+ if (this.closeButton) {
+ this.closeButton.focus();
+ }
+ }
+
+ onExited() {
+ if (this.openButton) {
+ this.openButton.focus();
+ }
+ }
+
+ render() {
+ return (
+ <span>
+ <CSSTransition
+ timeout={300}
+ classNames="personalize-animate"
+ in={!this.props.showing}
+ appear={true}
+ >
+ <button
+ className="icon icon-settings personalize-button"
+ onClick={() => this.props.onOpen()}
+ data-l10n-id="newtab-personalize-icon-label"
+ ref={c => (this.openButton = c)}
+ />
+ </CSSTransition>
+ <CSSTransition
+ timeout={250}
+ classNames="customize-animate"
+ in={this.props.showing}
+ onEntered={this.onEntered}
+ onExited={this.onExited}
+ appear={true}
+ >
+ <div
+ className="customize-menu"
+ role="dialog"
+ data-l10n-id="newtab-personalize-dialog-label"
+ >
+ <button
+ onClick={() => this.props.onClose()}
+ className="close-button"
+ data-l10n-id="newtab-custom-close-button"
+ ref={c => (this.closeButton = c)}
+ />
+ <BackgroundsSection />
+ <ContentSection
+ openPreferences={this.props.openPreferences}
+ setPref={this.props.setPref}
+ enabledSections={this.props.enabledSections}
+ pocketRegion={this.props.pocketRegion}
+ mayHaveSponsoredTopSites={this.props.mayHaveSponsoredTopSites}
+ mayHaveSponsoredStories={
+ this.props.DiscoveryStream.config.show_spocs
+ }
+ mayHaveRecentSaves={this.props.DiscoveryStream.recentSavesEnabled}
+ dispatch={this.props.dispatch}
+ />
+ </div>
+ </CSSTransition>
+ </span>
+ );
+ }
+}
+
+export const CustomizeMenu = connect(state => ({
+ DiscoveryStream: state.DiscoveryStream,
+}))(_CustomizeMenu);
diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss
new file mode 100644
index 0000000000..98046c2326
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss
@@ -0,0 +1,311 @@
+@media (max-height: 701px) {
+ .personalize-button {
+ position: absolute;
+ top: 16px;
+ inset-inline-end: 16px;
+ }
+}
+
+@media (min-height: 700px) {
+ .personalize-button {
+ position: fixed;
+ top: 16px;
+ inset-inline-end: 16px;
+ z-index: 1000;
+ }
+}
+
+.personalize-button {
+ border: 0;
+ border-radius: 4px;
+ background-color: transparent;
+ padding: 15px;
+
+ &:hover {
+ background-color: var(--newtab-element-hover-color);
+ }
+
+ &:active {
+ background-color: var(--newtab-element-active-color);
+ }
+
+ &:focus-visible {
+ @include ds-focus;
+ }
+
+ &.personalize-animate-exit-done {
+ visibility: hidden;
+ }
+}
+
+.customize-menu {
+ color: var(--newtab-text-primary-color);
+ background-color: var(--newtab-background-color-secondary);
+ width: 432px;
+ height: 100%;
+ position: fixed;
+ inset-block: 0;
+ inset-inline-end: 0;
+ z-index: 1001;
+ padding: 16px;
+ overflow: auto;
+ transform: translateX(435px);
+ visibility: hidden;
+ cursor: default;
+
+ @media (prefers-reduced-motion: no-preference) {
+ transition: transform 250ms $customize-menu-slide-bezier, visibility 250ms;
+ }
+
+ @media (forced-colors: active) {
+ border-inline-start: solid 1px;
+ }
+
+ &:dir(rtl) {
+ transform: translateX(-435px);
+ }
+
+ &.customize-animate-enter-done,
+ &.customize-animate-enter-active {
+ box-shadow: $shadow-large;
+ visibility: visible;
+ transform: translateX(0);
+ }
+
+ &.customize-animate-exit-active {
+ box-shadow: $shadow-large;
+ }
+
+ .close-button {
+ margin-inline-start: auto;
+ margin-bottom: 28px;
+ white-space: nowrap;
+ display: block;
+ background-color: var(--newtab-element-secondary-color);
+ padding: 8px 10px;
+ border: $customize-menu-border-tint;
+ border-radius: 4px;
+ color: var(--newtab-text-primary-color);
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .close-button:hover {
+ background-color: var(--newtab-element-secondary-hover-color);
+ }
+
+ .close-button:hover:active {
+ background-color: var(--newtab-element-secondary-active-color);
+ }
+}
+
+.grid-skip {
+ display: contents;
+}
+
+.home-section {
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: repeat(4, auto);
+ grid-row-gap: 32px;
+ padding: 0 16px;
+
+ .section {
+ display: grid;
+ grid-template-rows: auto;
+ grid-template-columns: auto 26px;
+
+ & > div {
+ grid-area: 1;
+ }
+
+ .title {
+ grid-column: 1 / -1;
+ margin: 0;
+ font-weight: 600;
+ font-size: 16px;
+ margin-bottom: 10px;
+ }
+
+ .subtitle {
+ margin: 0;
+ font-size: 14px;
+ }
+
+ .sponsored {
+ font-size: 14px;
+ margin-inline-start: 5px;
+ }
+
+ .check-wrapper {
+ position: relative;
+ }
+
+ .sponsored-checkbox {
+ margin-inline-start: 2px;
+ width: 16px;
+ height: 16px;
+ vertical-align: middle;
+ border: $customize-menu-border-tint;
+ box-sizing: border-box;
+ border-radius: 4px;
+ appearance: none;
+ background-color: var(--newtab-element-secondary-color);
+ }
+
+ .sponsored-checkbox:checked {
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-element-text-color);
+ background: url('chrome://global/skin/icons/check.svg') center no-repeat;
+ background-color: var(--newtab-primary-action-background);
+
+ @media (forced-colors: active) {
+ fill: $black;
+ }
+ }
+
+ .sponsored-checkbox:active + .checkmark {
+ fill: var(--newtab-element-secondary-color);
+ }
+
+ .selector {
+ color: var(--newtab-text-primary-color);
+ width: 118px;
+ display: block;
+ border: 1px solid var(--newtab-border-color);
+ border-radius: 4px;
+ appearance: none;
+ padding-block: 7px;
+ padding-inline: 10px 13px;
+ margin-inline-start: 2px;
+ margin-bottom: 2px;
+ -moz-context-properties: fill;
+ fill: var(--newtab-text-primary-color);
+ background: url('chrome://global/skin/icons/arrow-down-12.svg') right no-repeat;
+ background-size: 8px;
+ background-origin: content-box;
+ background-color: var(--newtab-background-color-secondary);
+
+ &:dir(rtl) {
+ background-position-x: left;
+ }
+ }
+
+ .switch {
+ position: relative;
+ display: inline-block;
+ width: 26px;
+ height: 16px;
+ grid-column: 2;
+ margin-top: 2px;
+ }
+
+ .switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ .slider {
+ position: absolute;
+ inset: 0;
+ transition: transform 250ms;
+ border-radius: 13px;
+ border: $customize-menu-border-tint;
+ background-color: var(--newtab-element-secondary-color);
+
+ &::before {
+ position: absolute;
+ content: '';
+ height: 8px;
+ width: 8px;
+ inset-inline-start: 3px;
+ bottom: 3px;
+ background-color: var(--newtab-primary-element-text-color);
+ transition: transform 250ms;
+ border-radius: 50%;
+ outline: $customize-menu-border-tint;
+ }
+ }
+
+ .switch input:focus-visible + .slider {
+ outline: 0;
+ box-shadow: $shadow-focus;
+ }
+
+ .switch input:not(:checked):focus-visible + .slider {
+ border: 1px solid var(--newtab-primary-action-background);
+ }
+
+ input:checked + .slider {
+ background-color: var(--newtab-primary-action-background);
+ }
+
+ input:checked + .slider::before {
+ transform: translateX(10px);
+ }
+
+ input:checked + .slider:dir(rtl)::before {
+ transform: translateX(-10px);
+ }
+
+ .more-info-top-wrapper,
+ .more-info-pocket-wrapper {
+ margin-inline-start: -2px;
+ overflow: hidden;
+
+ .more-information {
+ padding-top: 12px;
+ position: relative;
+ transition: margin-top 250ms $customize-menu-expand-bezier;
+ }
+ }
+
+ .more-info-top-wrapper {
+ .check-wrapper {
+ margin-top: 10px;
+ }
+ }
+ }
+
+ .divider {
+ border-top: 1px var(--newtab-border-color) solid;
+ margin: 0 -16px;
+ }
+
+ .external-link {
+ font-size: 14px;
+ cursor: pointer;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ -moz-context-properties: fill;
+ fill: var(--newtab-text-primary-color);
+ background: url('chrome://global/skin/icons/settings.svg') left no-repeat;
+ background-size: 16px;
+ padding-inline-start: 21px;
+ margin-bottom: 20px;
+ text-decoration: underline;
+
+ @media (forced-colors: active) {
+ padding: 8px 10px;
+ padding-inline-start: 21px;
+ }
+
+ &:dir(rtl) {
+ background-position-x: right;
+ }
+ }
+
+ .external-link:hover {
+ text-decoration: none;
+ }
+}
+
+.home-section .section .sponsored-checkbox:focus-visible,
+.selector:focus-visible,
+.external-link:focus-visible,
+.close-button:focus-visible {
+ border: 1px solid var(--newtab-primary-action-background);
+ outline: 0;
+ box-shadow: $shadow-focus;
+}
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..9acfef211b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
@@ -0,0 +1,384 @@
+/* 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 { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights";
+import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
+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";
+
+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, embedWidth) {
+ switch (component.type) {
+ case "Highlights":
+ return <Highlights />;
+ case "TopSites":
+ return (
+ <div className="ds-top-sites">
+ <TopSites isFixed={true} title={component.header?.title} />
+ </div>
+ );
+ 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}
+ essentialReadsHeader={component.essentialReadsHeader}
+ editorsPicksHeader={component.editorsPicksHeader}
+ />
+ );
+ 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 "CollectionCardGrid":
+ const { DiscoveryStream } = this.props;
+ return (
+ <CollectionCardGrid
+ data={component.data}
+ feed={component.feed}
+ spocs={DiscoveryStream.spocs}
+ placement={component.placement}
+ type={component.type}
+ items={component.properties.items}
+ dismissible={this.props.DiscoveryStream.isCollectionDismissible}
+ dispatch={this.props.dispatch}
+ />
+ );
+ case "CardGrid":
+ 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}
+ essentialReadsHeader={component.properties.essentialReadsHeader}
+ onboardingExperience={component.properties.onboardingExperience}
+ editorsPicksHeader={component.properties.editorsPicksHeader}
+ recentSavesEnabled={this.props.DiscoveryStream.recentSavesEnabled}
+ hideDescriptions={this.props.DiscoveryStream.hideDescriptions}
+ />
+ );
+ case "HorizontalRule":
+ return <HorizontalRule />;
+ case "PrivacyLink":
+ return <PrivacyLink properties={component.properties} />;
+ 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;
+ // Select layout render data by adding spocs and position to recommendations
+ const { layoutRender } = selectLayoutRender({
+ state: this.props.DiscoveryStream,
+ prefs: this.props.Prefs.values,
+ 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,
+ },
+ };
+
+ const privacyLinkComponent = extractComponent("PrivacyLink");
+ let learnMore = {
+ link: {
+ href: message.header.link_url,
+ message: message.header.link_text,
+ },
+ };
+ let sectionTitle = message.header.title;
+ let subTitle = "";
+
+ // If we're in one of these experiments, override the default message.
+ // For now this is English only.
+ if (message.essentialReadsHeader || message.editorsPicksHeader) {
+ learnMore = null;
+ subTitle = "Recommended By Pocket";
+ if (message.essentialReadsHeader) {
+ sectionTitle = "Today’s Essential Reads";
+ } else if (message.editorsPicksHeader) {
+ sectionTitle = "Editor’s Picks";
+ }
+ }
+
+ // 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}
+ id={topStories.id}
+ isFixed={true}
+ learnMore={learnMore}
+ privacyNoticeURL={topStories.privacyNoticeURL}
+ showPrefName={topStories.pref.feed}
+ title={sectionTitle}
+ subTitle={subTitle}
+ 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 = [];
+ 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,
+ App: state.App,
+}))(_DiscoveryStreamBase);
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
new file mode 100644
index 0000000000..015cb57a8b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
@@ -0,0 +1,67 @@
+$ds-width: 936px;
+
+.discovery-stream.ds-layout {
+ $columns: 12;
+ --gridColumnGap: 48px;
+ --gridRowGap: 24px;
+
+ grid-template-columns: repeat($columns, 1fr);
+ grid-column-gap: var(--gridColumnGap);
+ grid-row-gap: var(--gridRowGap);
+ margin: 0 auto;
+
+ @while $columns > 0 {
+ .ds-column-#{$columns} {
+ grid-column-start: auto;
+ grid-column-end: span $columns;
+ }
+
+ $columns: $columns - 1;
+ }
+
+ .ds-column-grid {
+ display: grid;
+ grid-row-gap: var(--gridRowGap);
+
+ // We want to completely hide components with no content,
+ // otherwise, it creates grid-row-gap gaps around nothing.
+ > div:empty {
+ display: none;
+ }
+ }
+}
+
+.ds-header {
+ margin: 8px 0;
+
+ .ds-context {
+ font-weight: 400;
+ }
+}
+
+.ds-header,
+.ds-layout .section-title span {
+ color: var(--newtab-text-primary-color);
+ font-size: $section-title-font-size;
+ font-weight: 600;
+ line-height: 20px;
+
+ .icon {
+ fill: var(--newtab-text-secondary-color);
+ }
+}
+
+.collapsible-section.ds-layout {
+ margin: auto;
+
+ .section-top-bar {
+ .learn-more-link a {
+ color: var(--newtab-primary-action-background);
+ font-weight: 500;
+
+ &:is(:focus, :hover) {
+ text-decoration: none;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
new file mode 100644
index 0000000000..09dc657cbb
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
@@ -0,0 +1,536 @@
+/* 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 { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx";
+import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
+import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
+import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import React, { useEffect, useState, useRef, useCallback } from "react";
+import { connect, useSelector } from "react-redux";
+const PREF_ONBOARDING_EXPERIENCE_DISMISSED =
+ "discoverystream.onboardingExperience.dismissed";
+const INTERSECTION_RATIO = 0.5;
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+const WIDGET_IDS = {
+ TOPICS: 1,
+};
+
+export function DSSubHeader({ children }) {
+ return (
+ <div className="section-top-bar ds-sub-header">
+ <h3 className="section-title-container">{children}</h3>
+ </div>
+ );
+}
+
+export function OnboardingExperience({
+ children,
+ dispatch,
+ windowObj = global,
+}) {
+ const [dismissed, setDismissed] = useState(false);
+ const [maxHeight, setMaxHeight] = useState(null);
+ const heightElement = useRef(null);
+
+ const onDismissClick = useCallback(() => {
+ // We update this as state and redux.
+ // The state update is for this newtab,
+ // and the redux update is for other tabs, offscreen tabs, and future tabs.
+ // We need the state update for this tab to support the transition.
+ setDismissed(true);
+ dispatch(ac.SetPref(PREF_ONBOARDING_EXPERIENCE_DISMISSED, true));
+ dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "BLOCK",
+ source: "POCKET_ONBOARDING",
+ })
+ );
+ }, [dispatch]);
+
+ useEffect(() => {
+ const resizeObserver = new windowObj.ResizeObserver(() => {
+ if (heightElement.current) {
+ setMaxHeight(heightElement.current.offsetHeight);
+ }
+ });
+
+ const options = { threshold: INTERSECTION_RATIO };
+ const intersectionObserver = new windowObj.IntersectionObserver(entries => {
+ if (
+ entries.some(
+ entry =>
+ entry.isIntersecting &&
+ entry.intersectionRatio >= INTERSECTION_RATIO
+ )
+ ) {
+ dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "IMPRESSION",
+ source: "POCKET_ONBOARDING",
+ })
+ );
+ // Once we have observed an impression, we can stop for this instance of newtab.
+ intersectionObserver.unobserve(heightElement.current);
+ }
+ }, options);
+
+ const onVisibilityChange = () => {
+ intersectionObserver.observe(heightElement.current);
+ windowObj.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ onVisibilityChange
+ );
+ };
+
+ if (heightElement.current) {
+ resizeObserver.observe(heightElement.current);
+ // Check visibility or setup a visibility event to make
+ // sure we don't fire this for off screen pre loaded tabs.
+ if (windowObj.document.visibilityState === VISIBLE) {
+ intersectionObserver.observe(heightElement.current);
+ } else {
+ windowObj.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ onVisibilityChange
+ );
+ }
+ setMaxHeight(heightElement.current.offsetHeight);
+ }
+
+ // Return unmount callback to clean up observers.
+ return () => {
+ resizeObserver?.disconnect();
+ intersectionObserver?.disconnect();
+ windowObj.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ onVisibilityChange
+ );
+ };
+ }, [dispatch, windowObj]);
+
+ const style = {};
+ if (dismissed) {
+ style.maxHeight = "0";
+ style.opacity = "0";
+ style.transition = "max-height 0.26s ease, opacity 0.26s ease";
+ } else if (maxHeight) {
+ style.maxHeight = `${maxHeight}px`;
+ }
+
+ return (
+ <div style={style}>
+ <div className="ds-onboarding-ref" ref={heightElement}>
+ <div className="ds-onboarding-container">
+ <DSDismiss
+ onDismissClick={onDismissClick}
+ extraClasses={`ds-onboarding`}
+ >
+ <div>
+ <header>
+ <span className="icon icon-pocket" />
+ <span data-l10n-id="newtab-pocket-onboarding-discover" />
+ </header>
+ <p data-l10n-id="newtab-pocket-onboarding-cta" />
+ </div>
+ <div className="ds-onboarding-graphic" />
+ </DSDismiss>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export function IntersectionObserver({
+ children,
+ windowObj = window,
+ onIntersecting,
+}) {
+ const intersectionElement = useRef(null);
+
+ useEffect(() => {
+ let observer;
+ if (!observer && onIntersecting && intersectionElement.current) {
+ observer = new windowObj.IntersectionObserver(entries => {
+ const entry = entries.find(e => e.isIntersecting);
+
+ if (entry) {
+ // Stop observing since element has been seen
+ if (observer && intersectionElement.current) {
+ observer.unobserve(intersectionElement.current);
+ }
+
+ onIntersecting();
+ }
+ });
+ observer.observe(intersectionElement.current);
+ }
+ // Cleanup
+ return () => observer?.disconnect();
+ }, [windowObj, onIntersecting]);
+
+ return <div ref={intersectionElement}>{children}</div>;
+}
+
+export function RecentSavesContainer({
+ gridClassName = "",
+ dispatch,
+ windowObj = window,
+ items = 3,
+ source = "CARDGRID_RECENT_SAVES",
+}) {
+ const {
+ recentSavesData,
+ isUserLoggedIn,
+ experimentData: { utmCampaign, utmContent, utmSource },
+ } = useSelector(state => state.DiscoveryStream);
+
+ const [visible, setVisible] = useState(false);
+ const onIntersecting = useCallback(() => setVisible(true), []);
+
+ useEffect(() => {
+ if (visible) {
+ dispatch(
+ ac.AlsoToMain({
+ type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
+ })
+ );
+ }
+ }, [visible, dispatch]);
+
+ // The user has not yet scrolled to this section,
+ // so wait before potentially requesting Pocket data.
+ if (!visible) {
+ return (
+ <IntersectionObserver
+ windowObj={windowObj}
+ onIntersecting={onIntersecting}
+ />
+ );
+ }
+
+ // Intersection observer has finished, but we're not yet logged in.
+ if (visible && !isUserLoggedIn) {
+ return null;
+ }
+
+ let queryParams = `?utm_source=${utmSource}`;
+ // We really only need to add these params to urls we own.
+ if (utmCampaign && utmContent) {
+ queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`;
+ }
+
+ function renderCard(rec, index) {
+ const url = new URL(rec.url);
+ const urlSearchParams = new URLSearchParams(queryParams);
+ if (rec?.id && !url.href.match(/getpocket\.com\/read/)) {
+ url.href = `https://getpocket.com/read/${rec.id}`;
+ }
+
+ for (let [key, val] of urlSearchParams.entries()) {
+ url.searchParams.set(key, val);
+ }
+
+ return (
+ <DSCard
+ key={`dscard-${rec?.id || index}`}
+ id={rec.id}
+ pos={index}
+ type={source}
+ image_src={rec.image_src}
+ raw_image_src={rec.raw_image_src}
+ word_count={rec.word_count}
+ time_to_read={rec.time_to_read}
+ title={rec.title}
+ excerpt={rec.excerpt}
+ url={url.href}
+ source={rec.domain}
+ isRecentSave={true}
+ dispatch={dispatch}
+ />
+ );
+ }
+
+ function onMyListClicked() {
+ dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: `${source}_VIEW_LIST`,
+ })
+ );
+ }
+
+ const recentSavesCards = [];
+ // We fill the cards with a for loop over an inline map because
+ // we want empty placeholders if there are not enough cards.
+ for (let index = 0; index < items; index++) {
+ const recentSave = recentSavesData[index];
+ if (!recentSave) {
+ recentSavesCards.push(<PlaceholderDSCard key={`dscard-${index}`} />);
+ } else {
+ recentSavesCards.push(
+ renderCard(
+ {
+ id: recentSave.id,
+ image_src: recentSave.top_image_url,
+ raw_image_src: recentSave.top_image_url,
+ word_count: recentSave.word_count,
+ time_to_read: recentSave.time_to_read,
+ title: recentSave.resolved_title || recentSave.given_title,
+ url: recentSave.resolved_url || recentSave.given_url,
+ domain: recentSave.domain_metadata?.name,
+ excerpt: recentSave.excerpt,
+ },
+ index
+ )
+ );
+ }
+ }
+
+ // We are visible and logged in.
+ return (
+ <>
+ <DSSubHeader>
+ <span className="section-title">
+ <FluentOrText message="Recently Saved to your List" />
+ </span>
+ <SafeAnchor
+ onLinkClick={onMyListClicked}
+ className="section-sub-link"
+ url={`https://getpocket.com/a${queryParams}`}
+ >
+ <FluentOrText message="View My List" />
+ </SafeAnchor>
+ </DSSubHeader>
+ <div className={`ds-card-grid-recent-saves ${gridClassName}`}>
+ {recentSavesCards}
+ </div>
+ </>
+ );
+}
+
+export class _CardGrid extends React.PureComponent {
+ renderCards() {
+ const prefs = this.props.Prefs.values;
+ const {
+ items,
+ hybridLayout,
+ hideCardBackground,
+ fourCardLayout,
+ compactGrid,
+ essentialReadsHeader,
+ editorsPicksHeader,
+ onboardingExperience,
+ widgets,
+ recentSavesEnabled,
+ hideDescriptions,
+ DiscoveryStream,
+ } = this.props;
+ const { saveToPocketCard } = DiscoveryStream;
+ const showRecentSaves = prefs.showRecentSaves && recentSavesEnabled;
+ const isOnboardingExperienceDismissed =
+ prefs[PREF_ONBOARDING_EXPERIENCE_DISMISSED];
+
+ const recs = this.props.data.recommendations.slice(0, items);
+ const cards = [];
+ let essentialReadsCards = [];
+ let editorsPicksCards = [];
+
+ for (let index = 0; index < items; index++) {
+ const rec = recs[index];
+ cards.push(
+ !rec || rec.placeholder ? (
+ <PlaceholderDSCard key={`dscard-${index}`} />
+ ) : (
+ <DSCard
+ key={`dscard-${rec.id}`}
+ pos={rec.pos}
+ flightId={rec.flight_id}
+ image_src={rec.image_src}
+ raw_image_src={rec.raw_image_src}
+ word_count={rec.word_count}
+ time_to_read={rec.time_to_read}
+ title={rec.title}
+ excerpt={rec.excerpt}
+ url={rec.url}
+ id={rec.id}
+ shim={rec.shim}
+ type={this.props.type}
+ context={rec.context}
+ sponsor={rec.sponsor}
+ sponsored_by_override={rec.sponsored_by_override}
+ dispatch={this.props.dispatch}
+ source={rec.domain}
+ pocket_id={rec.pocket_id}
+ context_type={rec.context_type}
+ bookmarkGuid={rec.bookmarkGuid}
+ is_collection={this.props.is_collection}
+ saveToPocketCard={saveToPocketCard}
+ />
+ )
+ );
+ }
+
+ if (widgets?.positions?.length && widgets?.data?.length) {
+ let positionIndex = 0;
+ const source = "CARDGRID_WIDGET";
+
+ for (const widget of widgets.data) {
+ let widgetComponent = null;
+ const position = widgets.positions[positionIndex];
+
+ // Stop if we run out of positions to place widgets.
+ if (!position) {
+ break;
+ }
+
+ switch (widget?.type) {
+ case "TopicsWidget":
+ widgetComponent = (
+ <TopicsWidget
+ position={position.index}
+ dispatch={this.props.dispatch}
+ source={source}
+ id={WIDGET_IDS.TOPICS}
+ />
+ );
+ break;
+ }
+
+ if (widgetComponent) {
+ // We found a widget, so up the position for next try.
+ positionIndex++;
+ // We replace an existing card with the widget.
+ cards.splice(position.index, 1, widgetComponent);
+ }
+ }
+ }
+
+ let moreRecsHeader = "";
+ // For now this is English only.
+ if (showRecentSaves || (essentialReadsHeader && editorsPicksHeader)) {
+ let spliceAt = 6;
+ // For 4 card row layouts, second row is 8 cards, and regular it is 6 cards.
+ if (fourCardLayout) {
+ spliceAt = 8;
+ }
+ // If we have a custom header, ensure the more recs section also has a header.
+ moreRecsHeader = "More Recommendations";
+ // Put the first 2 rows into essentialReadsCards.
+ essentialReadsCards = [...cards.splice(0, spliceAt)];
+ // Put the rest into editorsPicksCards.
+ if (essentialReadsHeader && editorsPicksHeader) {
+ editorsPicksCards = [...cards.splice(0, cards.length)];
+ }
+ }
+
+ const hideCardBackgroundClass = hideCardBackground
+ ? `ds-card-grid-hide-background`
+ : ``;
+ const fourCardLayoutClass = fourCardLayout
+ ? `ds-card-grid-four-card-variant`
+ : ``;
+ const hideDescriptionsClassName = !hideDescriptions
+ ? `ds-card-grid-include-descriptions`
+ : ``;
+ const compactGridClassName = compactGrid ? `ds-card-grid-compact` : ``;
+ const hybridLayoutClassName = hybridLayout
+ ? `ds-card-grid-hybrid-layout`
+ : ``;
+
+ const gridClassName = `ds-card-grid ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`;
+
+ return (
+ <>
+ {!isOnboardingExperienceDismissed && onboardingExperience && (
+ <OnboardingExperience dispatch={this.props.dispatch} />
+ )}
+ {essentialReadsCards?.length > 0 && (
+ <div className={gridClassName}>{essentialReadsCards}</div>
+ )}
+ {showRecentSaves && (
+ <RecentSavesContainer
+ gridClassName={gridClassName}
+ dispatch={this.props.dispatch}
+ />
+ )}
+ {editorsPicksCards?.length > 0 && (
+ <>
+ <DSSubHeader>
+ <span className="section-title">
+ <FluentOrText message="Editor’s Picks" />
+ </span>
+ </DSSubHeader>
+ <div className={gridClassName}>{editorsPicksCards}</div>
+ </>
+ )}
+ {cards?.length > 0 && (
+ <>
+ {moreRecsHeader && (
+ <DSSubHeader>
+ <span className="section-title">
+ <FluentOrText message={moreRecsHeader} />
+ </span>
+ </DSSubHeader>
+ )}
+ <div className={gridClassName}>{cards}</div>
+ </>
+ )}
+ </>
+ );
+ }
+
+ render() {
+ const { data } = this.props;
+
+ // Handle a render before feed has been fetched by displaying nothing
+ if (!data) {
+ return null;
+ }
+
+ // Handle the case where a user has dismissed all recommendations
+ const isEmpty = data.recommendations.length === 0;
+
+ return (
+ <div>
+ {this.props.title && (
+ <div className="ds-header">
+ <div className="title">{this.props.title}</div>
+ {this.props.context && (
+ <FluentOrText message={this.props.context}>
+ <div className="ds-context" />
+ </FluentOrText>
+ )}
+ </div>
+ )}
+ {isEmpty ? (
+ <div className="ds-card-grid empty">
+ <DSEmptyState
+ status={data.status}
+ dispatch={this.props.dispatch}
+ feed={this.props.feed}
+ />
+ </div>
+ ) : (
+ this.renderCards()
+ )}
+ </div>
+ );
+ }
+}
+
+_CardGrid.defaultProps = {
+ items: 4, // Number of stories to display
+};
+
+export const CardGrid = connect(state => ({
+ Prefs: state.Prefs,
+ DiscoveryStream: state.DiscoveryStream,
+}))(_CardGrid);
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
new file mode 100644
index 0000000000..fb838f4628
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
@@ -0,0 +1,355 @@
+$col4-header-line-height: 20;
+$col4-header-font-size: 14;
+
+.ds-onboarding-container,
+.ds-card-grid .ds-card {
+ @include dark-theme-only {
+ background: none;
+ }
+
+ background: $white;
+ border-radius: 4px;
+
+ &:not(.placeholder) {
+ @include dark-theme-only {
+ background: var(--newtab-background-color-secondary);
+ }
+
+ border-radius: $border-radius-new;
+ box-shadow: $shadow-card;
+
+ .img-wrapper .img {
+ img,
+ .placeholder-image {
+ border-radius: $border-radius-new $border-radius-new 0 0;
+ }
+ }
+ }
+}
+
+.ds-onboarding-container {
+ padding-inline-start: 16px;
+ padding-inline-end: 16px;
+
+ @media (min-width: $break-point-medium) {
+ padding-inline-end: 48px;
+ }
+
+ @media (min-width: $break-point-large) {
+ padding-inline-end: 56px;
+ }
+
+ margin-bottom: 24px;
+ // This is to position the dismiss button to the right most of this element.
+ position: relative;
+
+ .ds-onboarding {
+ position: static;
+ display: flex;
+
+ .ds-dismiss-button {
+ inset-inline-end: 8px;
+ top: 8px;
+ }
+ }
+
+ header {
+ @include dark-theme-only {
+ color: var(--newtab-background-color-primary);
+ }
+
+ display: flex;
+ margin: 32px 0 8px;
+
+ @media (min-width: $break-point-medium) {
+ margin: 16px 0 8px;
+ display: block;
+ height: 24px;
+ }
+
+ font-size: 17px;
+ line-height: 23.8px;
+ font-weight: 600;
+ color: $pocket-icon-fill;
+ }
+
+ p {
+ margin: 8px 0 16px;
+ font-size: 13px;
+ line-height: 19.5px;
+ }
+
+ .icon-pocket {
+ @include dark-theme-only {
+ @media (forced-colors: active) {
+ fill: CurrentColor;
+ }
+ fill: var(--newtab-text-primary-color);
+ }
+ @media (forced-colors: active) {
+ fill: CurrentColor;
+ }
+
+ fill: $pocket-icon-fill;
+ margin-top: 3px;
+ margin-inline-end: 8px;
+ height: 22px;
+ width: 22px;
+ background-image: url('chrome://global/skin/icons/pocket.svg');
+
+ @media (min-width: $break-point-medium) {
+ margin-top: -5px;
+ margin-inline-start: -2px;
+ margin-inline-end: 15px;
+ height: 30px;
+ width: 30px;
+ }
+
+ background-size: contain;
+ }
+
+ .ds-onboarding-graphic {
+ background-image: url('chrome://activity-stream/content/data/content/assets/pocket-onboarding.avif');
+
+ @media (min-resolution: 2x) {
+ background-image: url('chrome://activity-stream/content/data/content/assets/pocket-onboarding@2x.avif');
+ }
+
+ border-radius: 8px;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ height: 120px;
+ width: 200px;
+ margin-top: 16px;
+ margin-bottom: 16px;
+ margin-inline-start: 54px;
+ flex-shrink: 0;
+ display: none;
+
+ @media (min-width: $break-point-large) {
+ display: block;
+ }
+ }
+}
+
+.ds-card-grid {
+ display: grid;
+ grid-gap: 24px;
+
+ &.ds-card-grid-compact {
+ grid-gap: 20px;
+ }
+
+ &.ds-card-grid-recent-saves {
+ .ds-card {
+ // Hide the second row orphan on narrow screens.
+ @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+ &:last-child:nth-child(2n - 1) {
+ display: none;
+ }
+ }
+ }
+ }
+
+ .ds-card-link:focus {
+ @include ds-focus;
+
+ transition: none;
+ border-radius: $border-radius-new;
+ }
+
+ // "2/3 width layout"
+ .ds-column-5 &,
+ .ds-column-6 &,
+ .ds-column-7 &,
+ .ds-column-8 & {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ // "Full width layout"
+ .ds-column-9 &,
+ .ds-column-10 &,
+ .ds-column-11 &,
+ .ds-column-12 & {
+ grid-template-columns: repeat(1, 1fr);
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .title {
+ font-size: 17px;
+ line-height: 24px;
+ }
+
+ .excerpt {
+ @include limit-visible-lines(3, 24, 15);
+ }
+ }
+
+ &.empty {
+ grid-template-columns: auto;
+ }
+
+ @mixin small-cards {
+ .ds-card {
+ &.placeholder {
+ min-height: 247px;
+ }
+
+ .meta {
+ .story-footer {
+ margin-top: 8px;
+ }
+
+ .source,
+ .story-sponsored-label,
+ .status-message .story-context-label {
+ color: var(--newtab-text-secondary-color);
+ -webkit-line-clamp: 2;
+ }
+
+ .source,
+ .story-sponsored-label {
+ font-size: 13px;
+ }
+
+ .status-message .story-context-label {
+ font-size: 11.7px;
+ }
+
+ .story-badge-icon {
+ margin-inline-end: 2px;
+ margin-bottom: 2px;
+ height: 14px;
+ width: 14px;
+ background-size: 14px;
+ }
+
+ .title {
+ font-size: 14px;
+ line-height: 20px;
+ }
+
+ .info-wrap {
+ flex-grow: 0;
+ }
+ }
+ }
+ }
+
+ &.ds-card-grid-four-card-variant {
+ // "Full width layout"
+ .ds-column-9 &,
+ .ds-column-10 &,
+ .ds-column-11 &,
+ .ds-column-12 & {
+ grid-template-columns: repeat(1, 1fr);
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ @media (min-width: $break-point-widest) {
+ grid-template-columns: repeat(4, 1fr);
+ }
+ }
+
+ @include small-cards;
+ }
+
+ &.ds-card-grid-hybrid-layout {
+ .ds-column-9 &,
+ .ds-column-10 &,
+ .ds-column-11 &,
+ .ds-column-12 & {
+ grid-template-columns: repeat(1, 1fr);
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ @media (max-height: 1065px) {
+ .excerpt {
+ display: none;
+ }
+ }
+
+ @media (max-width: $break-point-widest) {
+ @include small-cards;
+ }
+
+ @media (min-width: $break-point-widest) and (max-height: 964px) {
+ @include small-cards;
+
+ grid-template-columns: repeat(4, 1fr);
+ }
+ }
+ }
+}
+
+.outer-wrapper .ds-card-grid.ds-card-grid-hide-background .ds-card,
+.outer-wrapper.newtab-experience .ds-card-grid.ds-card-grid-hide-background .ds-card {
+ &:not(.placeholder) {
+ box-shadow: none;
+ background: none;
+
+ .ds-card-link:focus {
+ box-shadow: none;
+
+ .img-wrapper .img img {
+ @include ds-focus;
+ }
+ }
+
+ .img-wrapper .img img {
+ border-radius: 8px;
+ box-shadow: $shadow-card;
+ }
+
+ .meta {
+ padding: 12px 0 0;
+ }
+ }
+}
+
+.ds-layout {
+ .ds-sub-header {
+ margin-top: 24px;
+
+ .section-title-container {
+ flex-direction: row;
+ align-items: baseline;
+ justify-content: space-between;
+ display: flex;
+ }
+
+ .section-sub-link {
+ color: var(--newtab-primary-action-background);
+ font-size: 14px;
+ line-height: 16px;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ color: var(--newtab-primary-element-active-color);
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx
new file mode 100644
index 0000000000..d089a5c8ab
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx
@@ -0,0 +1,139 @@
+/* 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 { actionCreators as ac } from "common/Actions.sys.mjs";
+import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
+import { LinkMenuOptions } from "content-src/lib/link-menu-options";
+import React from "react";
+
+export class CollectionCardGrid extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onDismissClick = this.onDismissClick.bind(this);
+ this.state = {
+ dismissed: false,
+ };
+ }
+
+ onDismissClick() {
+ const { data } = this.props;
+ if (this.props.dispatch && data && data.spocs && data.spocs.length) {
+ this.setState({
+ dismissed: true,
+ });
+ const pos = 0;
+ const source = this.props.type.toUpperCase();
+ // Grab the available items in the array to dismiss.
+ // This fires a ping for all items available, even if below the fold.
+ const spocsData = data.spocs.map(item => ({
+ url: item.url,
+ guid: item.id,
+ shim: item.shim,
+ flight_id: item.flightId,
+ }));
+
+ const blockUrlOption = LinkMenuOptions.BlockUrls(spocsData, pos, source);
+ const { action, impression, userEvent } = blockUrlOption;
+ this.props.dispatch(action);
+
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: userEvent,
+ source,
+ action_position: pos,
+ })
+ );
+ if (impression) {
+ this.props.dispatch(impression);
+ }
+ }
+ }
+
+ render() {
+ const { data, dismissible, pocket_button_enabled } = this.props;
+ if (
+ this.state.dismissed ||
+ !data ||
+ !data.spocs ||
+ !data.spocs[0] ||
+ // We only display complete collections.
+ data.spocs.length < 3
+ ) {
+ return null;
+ }
+ const { spocs, placement, feed } = this.props;
+ // spocs.data is spocs state data, and not an array of spocs.
+ const { title, context, sponsored_by_override, sponsor } =
+ spocs.data[placement.name] || {};
+ // Just in case of bad data, don't display a broken collection.
+ if (!title) {
+ return null;
+ }
+
+ let sponsoredByMessage = "";
+
+ // If override is not false or an empty string.
+ if (sponsored_by_override || sponsored_by_override === "") {
+ // We specifically want to display nothing if the server returns an empty string.
+ // So the server can turn off the label.
+ // This is to support the use cases where the sponsored context is displayed elsewhere.
+ sponsoredByMessage = sponsored_by_override;
+ } else if (sponsor) {
+ sponsoredByMessage = {
+ id: `newtab-label-sponsored-by`,
+ values: { sponsor },
+ };
+ } else if (context) {
+ sponsoredByMessage = context;
+ }
+
+ // Generally a card grid displays recs with spocs already injected.
+ // Normally it doesn't care which rec is a spoc and which isn't,
+ // it just displays content in a grid.
+ // For collections, we're only displaying a list of spocs.
+ // We don't need to tell the card grid that our list of cards are spocs,
+ // it shouldn't need to care. So we just pass our spocs along as recs.
+ // Think of it as injecting all rec positions with spocs.
+ // Consider maybe making recommendations in CardGrid use a more generic name.
+ const recsData = {
+ recommendations: data.spocs,
+ };
+
+ // All cards inside of a collection card grid have a slightly different type.
+ // For the case of interactions to the card grid, we use the type "COLLECTIONCARDGRID".
+ // Example, you dismiss the whole collection, we use the type "COLLECTIONCARDGRID".
+ // For interactions inside the card grid, example, you dismiss a single card in the collection,
+ // we use the type "COLLECTIONCARDGRID_CARD".
+ const type = `${this.props.type}_card`;
+
+ const collectionGrid = (
+ <div className="ds-collection-card-grid">
+ <CardGrid
+ pocket_button_enabled={pocket_button_enabled}
+ title={title}
+ context={sponsoredByMessage}
+ data={recsData}
+ feed={feed}
+ type={type}
+ is_collection={true}
+ dispatch={this.props.dispatch}
+ items={this.props.items}
+ />
+ </div>
+ );
+
+ if (dismissible) {
+ return (
+ <DSDismiss
+ onDismissClick={this.onDismissClick}
+ extraClasses={`ds-dismiss-ds-collection`}
+ >
+ {collectionGrid}
+ </DSDismiss>
+ );
+ }
+ return collectionGrid;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss
new file mode 100644
index 0000000000..f4778f3b95
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss
@@ -0,0 +1,38 @@
+.ds-dismiss.ds-dismiss-ds-collection {
+ .ds-dismiss-button {
+ margin: 15px 0 0;
+ inset-inline-end: 25px;
+ }
+
+ &.hovering {
+ background: var(--newtab-element-hover-color);
+ }
+}
+
+.ds-collection-card-grid {
+ padding: 10px 25px 25px;
+ margin: 0 0 20px;
+
+ .story-footer {
+ display: none;
+ }
+
+ .ds-header {
+ padding: 0 40px 0 0;
+ margin-bottom: 12px;
+
+ .title {
+ color: var(--newtab-text-primary-color);
+ font-weight: 600;
+ font-size: 17px;
+ line-height: 24px;
+ }
+
+ .ds-context {
+ color: var(--newtab-text-secondary-color);
+ font-weight: normal;
+ font-size: 13px;
+ line-height: 24px;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
new file mode 100644
index 0000000000..561da8e2fa
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
@@ -0,0 +1,491 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { DSImage } from "../DSImage/DSImage.jsx";
+import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import {
+ DSContextFooter,
+ SponsorLabel,
+ DSMessageFooter,
+} from "../DSContextFooter/DSContextFooter.jsx";
+import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
+import { connect } from "react-redux";
+
+const READING_WPM = 220;
+
+/**
+ * READ TIME FROM WORD COUNT
+ * @param {int} wordCount number of words in an article
+ * @returns {int} number of words per minute in minutes
+ */
+export function readTimeFromWordCount(wordCount) {
+ if (!wordCount) return false;
+ return Math.ceil(parseInt(wordCount, 10) / READING_WPM);
+}
+
+export const DSSource = ({
+ source,
+ timeToRead,
+ newSponsoredLabel,
+ context,
+ sponsor,
+ sponsored_by_override,
+}) => {
+ // First try to display sponsored label or time to read here.
+ if (newSponsoredLabel) {
+ // If we can display something for spocs, do so.
+ if (sponsored_by_override || sponsor || context) {
+ return (
+ <SponsorLabel
+ context={context}
+ sponsor={sponsor}
+ sponsored_by_override={sponsored_by_override}
+ newSponsoredLabel="new-sponsored-label"
+ />
+ );
+ }
+ }
+
+ // If we are not a spoc, and can display a time to read value.
+ if (source && timeToRead) {
+ return (
+ <p className="source clamp time-to-read">
+ <FluentOrText
+ message={{
+ id: `newtab-label-source-read-time`,
+ values: { source, timeToRead },
+ }}
+ />
+ </p>
+ );
+ }
+
+ // Otherwise display a default source.
+ return <p className="source clamp">{source}</p>;
+};
+
+export const DefaultMeta = ({
+ source,
+ title,
+ excerpt,
+ timeToRead,
+ newSponsoredLabel,
+ context,
+ context_type,
+ sponsor,
+ sponsored_by_override,
+ saveToPocketCard,
+ isRecentSave,
+}) => (
+ <div className="meta">
+ <div className="info-wrap">
+ <DSSource
+ source={source}
+ timeToRead={timeToRead}
+ newSponsoredLabel={newSponsoredLabel}
+ context={context}
+ sponsor={sponsor}
+ sponsored_by_override={sponsored_by_override}
+ />
+ <header title={title} className="title clamp">
+ {title}
+ </header>
+ {excerpt && <p className="excerpt clamp">{excerpt}</p>}
+ </div>
+ {!newSponsoredLabel && (
+ <DSContextFooter
+ context_type={context_type}
+ context={context}
+ sponsor={sponsor}
+ sponsored_by_override={sponsored_by_override}
+ />
+ )}
+ {/* Sponsored label is normally in the way of any message.
+ newSponsoredLabel cards sponsored label is moved to just under the thumbnail,
+ so we can display both, so we specifically don't pass in context. */}
+ {newSponsoredLabel && (
+ <DSMessageFooter
+ context_type={context_type}
+ context={null}
+ saveToPocketCard={saveToPocketCard}
+ />
+ )}
+ </div>
+);
+
+export class _DSCard extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onLinkClick = this.onLinkClick.bind(this);
+ this.onSaveClick = this.onSaveClick.bind(this);
+ this.onMenuUpdate = this.onMenuUpdate.bind(this);
+ this.onMenuShow = this.onMenuShow.bind(this);
+
+ this.setContextMenuButtonHostRef = element => {
+ this.contextMenuButtonHostElement = element;
+ };
+ this.setPlaceholderRef = element => {
+ this.placeholderElement = element;
+ };
+
+ this.state = {
+ isSeen: false,
+ };
+
+ // If this is for the about:home startup cache, then we always want
+ // to render the DSCard, regardless of whether or not its been seen.
+ if (props.App.isForStartupCache) {
+ this.state.isSeen = true;
+ }
+
+ // We want to choose the optimal thumbnail for the underlying DSImage, but
+ // want to do it in a performant way. The breakpoints used in the
+ // CSS of the page are, unfortuntely, not easy to retrieve without
+ // causing a style flush. To avoid that, we hardcode them here.
+ //
+ // The values chosen here were the dimensions of the card thumbnails as
+ // computed by getBoundingClientRect() for each type of viewport width
+ // across both high-density and normal-density displays.
+ this.dsImageSizes = [
+ {
+ mediaMatcher: "(min-width: 1122px)",
+ width: 296,
+ height: 148,
+ },
+
+ {
+ mediaMatcher: "(min-width: 866px)",
+ width: 218,
+ height: 109,
+ },
+
+ {
+ mediaMatcher: "(max-width: 610px)",
+ width: 202,
+ height: 101,
+ },
+ ];
+ }
+
+ onLinkClick(event) {
+ if (this.props.dispatch) {
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: this.props.type.toUpperCase(),
+ action_position: this.props.pos,
+ value: { card_type: this.props.flightId ? "spoc" : "organic" },
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source: this.props.type.toUpperCase(),
+ click: 0,
+ window_inner_width: this.props.windowObj.innerWidth,
+ window_inner_height: this.props.windowObj.innerHeight,
+ tiles: [
+ {
+ id: this.props.id,
+ pos: this.props.pos,
+ ...(this.props.shim && this.props.shim.click
+ ? { shim: this.props.shim.click }
+ : {}),
+ type: this.props.flightId ? "spoc" : "organic",
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ onSaveClick(event) {
+ if (this.props.dispatch) {
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.SAVE_TO_POCKET,
+ data: { site: { url: this.props.url, title: this.props.title } },
+ })
+ );
+
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ source: "CARDGRID_HOVER",
+ action_position: this.props.pos,
+ value: { card_type: this.props.flightId ? "spoc" : "organic" },
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source: "CARDGRID_HOVER",
+ pocket: 0,
+ tiles: [
+ {
+ id: this.props.id,
+ pos: this.props.pos,
+ ...(this.props.shim && this.props.shim.save
+ ? { shim: this.props.shim.save }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ onMenuUpdate(showContextMenu) {
+ if (!showContextMenu) {
+ const dsLinkMenuHostDiv = this.contextMenuButtonHostElement;
+ if (dsLinkMenuHostDiv) {
+ dsLinkMenuHostDiv.classList.remove("active", "last-item");
+ }
+ }
+ }
+
+ async onMenuShow() {
+ const dsLinkMenuHostDiv = this.contextMenuButtonHostElement;
+ if (dsLinkMenuHostDiv) {
+ // Force translation so we can be sure it's ready before measuring.
+ await this.props.windowObj.document.l10n.translateFragment(
+ dsLinkMenuHostDiv
+ );
+ if (this.props.windowObj.scrollMaxX > 0) {
+ dsLinkMenuHostDiv.classList.add("last-item");
+ }
+ dsLinkMenuHostDiv.classList.add("active");
+ }
+ }
+
+ onSeen(entries) {
+ if (this.state) {
+ const entry = entries.find(e => e.isIntersecting);
+
+ if (entry) {
+ if (this.placeholderElement) {
+ this.observer.unobserve(this.placeholderElement);
+ }
+
+ // Stop observing since element has been seen
+ this.setState({
+ isSeen: true,
+ });
+ }
+ }
+ }
+
+ onIdleCallback() {
+ if (!this.state.isSeen) {
+ if (this.observer && this.placeholderElement) {
+ this.observer.unobserve(this.placeholderElement);
+ }
+ this.setState({
+ isSeen: true,
+ });
+ }
+ }
+
+ componentDidMount() {
+ this.idleCallbackId = this.props.windowObj.requestIdleCallback(
+ this.onIdleCallback.bind(this)
+ );
+ if (this.placeholderElement) {
+ this.observer = new IntersectionObserver(this.onSeen.bind(this));
+ this.observer.observe(this.placeholderElement);
+ }
+ }
+
+ componentWillUnmount() {
+ // Remove observer on unmount
+ if (this.observer && this.placeholderElement) {
+ this.observer.unobserve(this.placeholderElement);
+ }
+ if (this.idleCallbackId) {
+ this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
+ }
+ }
+
+ render() {
+ if (this.props.placeholder || !this.state.isSeen) {
+ return (
+ <div className="ds-card placeholder" ref={this.setPlaceholderRef} />
+ );
+ }
+
+ const { isRecentSave, DiscoveryStream, saveToPocketCard } = this.props;
+ let { source } = this.props;
+ if (!source) {
+ try {
+ source = new URL(this.props.url).hostname;
+ } catch (e) {}
+ }
+
+ const {
+ pocketButtonEnabled,
+ hideDescriptions,
+ compactImages,
+ imageGradient,
+ newSponsoredLabel,
+ titleLines = 3,
+ descLines = 3,
+ readTime: displayReadTime,
+ } = DiscoveryStream;
+
+ const excerpt = !hideDescriptions ? this.props.excerpt : "";
+
+ let timeToRead;
+ if (displayReadTime) {
+ timeToRead =
+ this.props.time_to_read || readTimeFromWordCount(this.props.word_count);
+ }
+
+ const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``;
+ const imageGradientClassName = imageGradient
+ ? `ds-card-image-gradient`
+ : ``;
+ const titleLinesName = `ds-card-title-lines-${titleLines}`;
+ const descLinesClassName = `ds-card-desc-lines-${descLines}`;
+
+ let stpButton = () => {
+ return (
+ <button className="card-stp-button" onClick={this.onSaveClick}>
+ {this.props.context_type === "pocket" ? (
+ <>
+ <span className="story-badge-icon icon icon-pocket" />
+ <span data-l10n-id="newtab-pocket-saved" />
+ </>
+ ) : (
+ <>
+ <span className="story-badge-icon icon icon-pocket-save" />
+ <span data-l10n-id="newtab-pocket-save" />
+ </>
+ )}
+ </button>
+ );
+ };
+
+ return (
+ <div
+ className={`ds-card ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName}`}
+ ref={this.setContextMenuButtonHostRef}
+ >
+ <SafeAnchor
+ className="ds-card-link"
+ dispatch={this.props.dispatch}
+ onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined}
+ url={this.props.url}
+ >
+ <div className="img-wrapper">
+ <DSImage
+ extraClassNames="img"
+ source={this.props.image_src}
+ rawSource={this.props.raw_image_src}
+ sizes={this.dsImageSizes}
+ url={this.props.url}
+ title={this.props.title}
+ isRecentSave={isRecentSave}
+ />
+ </div>
+ <DefaultMeta
+ source={source}
+ title={this.props.title}
+ excerpt={excerpt}
+ newSponsoredLabel={newSponsoredLabel}
+ timeToRead={timeToRead}
+ context={this.props.context}
+ context_type={this.props.context_type}
+ sponsor={this.props.sponsor}
+ sponsored_by_override={this.props.sponsored_by_override}
+ saveToPocketCard={saveToPocketCard}
+ />
+ <ImpressionStats
+ flightId={this.props.flightId}
+ rows={[
+ {
+ id: this.props.id,
+ pos: this.props.pos,
+ ...(this.props.shim && this.props.shim.impression
+ ? { shim: this.props.shim.impression }
+ : {}),
+ },
+ ]}
+ dispatch={this.props.dispatch}
+ source={this.props.type}
+ />
+ </SafeAnchor>
+ {saveToPocketCard && (
+ <div className="card-stp-button-hover-background">
+ <div className="card-stp-button-position-wrapper">
+ {!this.props.flightId && stpButton()}
+ <DSLinkMenu
+ id={this.props.id}
+ index={this.props.pos}
+ dispatch={this.props.dispatch}
+ url={this.props.url}
+ title={this.props.title}
+ source={source}
+ type={this.props.type}
+ pocket_id={this.props.pocket_id}
+ shim={this.props.shim}
+ bookmarkGuid={this.props.bookmarkGuid}
+ flightId={
+ !this.props.is_collection ? this.props.flightId : undefined
+ }
+ showPrivacyInfo={!!this.props.flightId}
+ onMenuUpdate={this.onMenuUpdate}
+ onMenuShow={this.onMenuShow}
+ saveToPocketCard={saveToPocketCard}
+ pocket_button_enabled={pocketButtonEnabled}
+ isRecentSave={isRecentSave}
+ />
+ </div>
+ </div>
+ )}
+ {!saveToPocketCard && (
+ <DSLinkMenu
+ id={this.props.id}
+ index={this.props.pos}
+ dispatch={this.props.dispatch}
+ url={this.props.url}
+ title={this.props.title}
+ source={source}
+ type={this.props.type}
+ pocket_id={this.props.pocket_id}
+ shim={this.props.shim}
+ bookmarkGuid={this.props.bookmarkGuid}
+ flightId={
+ !this.props.is_collection ? this.props.flightId : undefined
+ }
+ showPrivacyInfo={!!this.props.flightId}
+ hostRef={this.contextMenuButtonHostRef}
+ onMenuUpdate={this.onMenuUpdate}
+ onMenuShow={this.onMenuShow}
+ pocket_button_enabled={pocketButtonEnabled}
+ isRecentSave={isRecentSave}
+ />
+ )}
+ </div>
+ );
+ }
+}
+
+_DSCard.defaultProps = {
+ windowObj: window, // Added to support unit tests
+};
+
+export const DSCard = connect(state => ({
+ App: state.App,
+ DiscoveryStream: state.DiscoveryStream,
+}))(_DSCard);
+
+export const PlaceholderDSCard = props => <DSCard placeholder={true} />;
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
new file mode 100644
index 0000000000..a29087a5df
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
@@ -0,0 +1,251 @@
+// Type sizes
+$header-font-size: 17;
+$header-line-height: 24;
+$excerpt-font-size: 14;
+$excerpt-line-height: 20;
+$ds-card-image-gradient-fade: rgba(0, 0, 0, 0%);
+$ds-card-image-gradient-solid: rgba(0, 0, 0, 100%);
+
+.ds-card {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+
+ &.placeholder {
+ background: transparent;
+ box-shadow: inset $inner-box-shadow;
+ border-radius: 4px;
+ min-height: 300px;
+ }
+
+ .img-wrapper {
+ width: 100%;
+ position: relative;
+ }
+
+ .card-stp-button-hover-background {
+ opacity: 0;
+ width: 100%;
+ position: absolute;
+ top: 0;
+ height: 0;
+ transition: opacity;
+ transition-duration: 0s;
+ padding-top: 50%;
+ pointer-events: none;
+ background: $black-40;
+ border-radius: 8px 8px 0 0;
+
+ .card-stp-button-position-wrapper {
+ position: absolute;
+ inset-inline-end: 10px;
+ top: 10px;
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ }
+
+ .icon-pocket-save,
+ .icon-pocket {
+ margin-inline-end: 4px;
+ height: 15px;
+ width: 15px;
+ background-size: 15px;
+ fill: $white;
+ }
+
+ .context-menu-button {
+ position: static;
+ transition: none;
+ border-radius: 3px;
+ }
+
+ .context-menu-position-container {
+ position: relative;
+ }
+
+ .context-menu {
+ margin-inline-start: 18.5px;
+ inset-inline-start: auto;
+ position: absolute;
+ top: 20.25px;
+ }
+
+ .card-stp-button {
+ display: flex;
+ margin-inline-end: 7px;
+ font-weight: 400;
+ font-size: 13px;
+ line-height: 16px;
+ background-color: $pocket-icon-fill;
+ border: 0;
+ border-radius: 4px;
+ padding: 6px;
+ white-space: nowrap;
+ color: $white;
+ }
+
+ button,
+ .context-menu {
+ pointer-events: auto;
+ }
+
+ button {
+ cursor: pointer;
+ }
+ }
+
+ &.last-item {
+ .card-stp-button-hover-background {
+ .context-menu {
+ margin-inline-start: auto;
+ margin-inline-end: 18.5px;
+ }
+ }
+ }
+
+ // The active class is added when the context menu is open.
+ &.active,
+ &:focus-within,
+ &:hover {
+ .card-stp-button-hover-background {
+ display: block;
+ opacity: 1;
+ transition-duration: 0.3s;
+
+ .context-menu-button {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+ }
+
+ .img {
+ height: 0;
+ padding-top: 50%; // 2:1 aspect ratio
+
+ img {
+ border-radius: 4px;
+ box-shadow: $shadow-image-inset;
+ }
+ }
+
+ .ds-card-link {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ text-decoration: none;
+
+ &:hover {
+ header {
+ color: var(--newtab-primary-action-background);
+ }
+ }
+
+ &:focus {
+ @include ds-focus;
+
+ transition: none;
+
+ header {
+ color: var(--newtab-primary-action-background);
+ }
+ }
+
+ &:active {
+ header {
+ color: var(--newtab-primary-element-active-color);
+ }
+ }
+ }
+
+ .meta {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 16px;
+ flex-grow: 1;
+
+ .info-wrap {
+ flex-grow: 1;
+ }
+
+ .title {
+ // show only 3 lines of copy
+ @include limit-visible-lines(3, $header-line-height, $header-font-size);
+
+ font-weight: 600;
+ }
+
+ .excerpt {
+ // show only 3 lines of copy
+ @include limit-visible-lines(
+ 3,
+ $excerpt-line-height,
+ $excerpt-font-size
+ );
+ }
+
+ .source {
+ -webkit-line-clamp: 1;
+ margin-bottom: 2px;
+ font-size: 13px;
+ color: var(--newtab-text-secondary-color);
+
+ span {
+ display: inline-block;
+ }
+ }
+
+ .new-sponsored-label {
+ font-size: 13px;
+ margin-bottom: 2px;
+ }
+ }
+
+ &.ds-card-title-lines-2 .meta .title {
+ // show only 2 lines of copy
+ @include limit-visible-lines(2, $header-line-height, $header-font-size);
+ }
+
+ &.ds-card-title-lines-1 .meta .title {
+ // show only 1 line of copy
+ @include limit-visible-lines(1, $header-line-height, $header-font-size);
+ }
+
+ &.ds-card-desc-lines-2 .meta .excerpt {
+ // show only 2 lines of copy
+ @include limit-visible-lines(2, $excerpt-line-height, $excerpt-font-size);
+ }
+
+ &.ds-card-desc-lines-1 .meta .excerpt {
+ // show only 1 line of copy
+ @include limit-visible-lines(1, $excerpt-line-height, $excerpt-font-size);
+ }
+
+ &.ds-card-compact-image .img {
+ padding-top: 47%;
+ }
+
+ &.ds-card-image-gradient {
+ img {
+ mask-image: linear-gradient(to top, $ds-card-image-gradient-fade, $ds-card-image-gradient-solid 40px);
+ }
+
+ .meta {
+ padding: 3px 15px 11px;
+ }
+ }
+
+ header {
+ line-height: $header-line-height * 1px;
+ font-size: $header-font-size * 1px;
+ color: var(--newtab-text-primary-color);
+ }
+
+ p {
+ font-size: $excerpt-font-size * 1px;
+ line-height: $excerpt-line-height * 1px;
+ color: var(--newtab-text-primary-color);
+ margin: 0;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx
new file mode 100644
index 0000000000..0d7d4f666e
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx
@@ -0,0 +1,118 @@
+/* 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 { cardContextTypes } from "../../Card/types.js";
+import { CSSTransition, TransitionGroup } from "react-transition-group";
+import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
+import React from "react";
+
+// Animation time is mirrored in DSContextFooter.scss
+const ANIMATION_DURATION = 3000;
+
+export const DSMessageLabel = props => {
+ const { context, context_type } = props;
+ const { icon, fluentID } = cardContextTypes[context_type] || {};
+
+ if (!context && context_type) {
+ return (
+ <TransitionGroup component={null}>
+ <CSSTransition
+ key={fluentID}
+ timeout={ANIMATION_DURATION}
+ classNames="story-animate"
+ >
+ <StatusMessage icon={icon} fluentID={fluentID} />
+ </CSSTransition>
+ </TransitionGroup>
+ );
+ }
+
+ return null;
+};
+
+export const StatusMessage = ({ icon, fluentID }) => (
+ <div className="status-message">
+ <span
+ aria-haspopup="true"
+ className={`story-badge-icon icon icon-${icon}`}
+ />
+ <div className="story-context-label" data-l10n-id={fluentID} />
+ </div>
+);
+
+export const SponsorLabel = ({
+ sponsored_by_override,
+ sponsor,
+ context,
+ newSponsoredLabel,
+}) => {
+ const classList = `story-sponsored-label ${newSponsoredLabel || ""} clamp`;
+ // If override is not false or an empty string.
+ if (sponsored_by_override) {
+ return <p className={classList}>{sponsored_by_override}</p>;
+ } else if (sponsored_by_override === "") {
+ // We specifically want to display nothing if the server returns an empty string.
+ // So the server can turn off the label.
+ // This is to support the use cases where the sponsored context is displayed elsewhere.
+ return null;
+ } else if (sponsor) {
+ return (
+ <p className={classList}>
+ <FluentOrText
+ message={{
+ id: `newtab-label-sponsored-by`,
+ values: { sponsor },
+ }}
+ />
+ </p>
+ );
+ } else if (context) {
+ return <p className={classList}>{context}</p>;
+ }
+ return null;
+};
+
+export class DSContextFooter extends React.PureComponent {
+ render() {
+ const { context, context_type, sponsor, sponsored_by_override } =
+ this.props;
+
+ const sponsorLabel = SponsorLabel({
+ sponsored_by_override,
+ sponsor,
+ context,
+ });
+ const dsMessageLabel = DSMessageLabel({
+ context,
+ context_type,
+ });
+
+ if (sponsorLabel || dsMessageLabel) {
+ return (
+ <div className="story-footer">
+ {sponsorLabel}
+ {dsMessageLabel}
+ </div>
+ );
+ }
+
+ return null;
+ }
+}
+
+export const DSMessageFooter = props => {
+ const { context, context_type, saveToPocketCard } = props;
+
+ const dsMessageLabel = DSMessageLabel({
+ context,
+ context_type,
+ });
+
+ // This case is specific and already displayed to the user elsewhere.
+ if (!dsMessageLabel || (saveToPocketCard && context_type === "pocket")) {
+ return null;
+ }
+
+ return <div className="story-footer">{dsMessageLabel}</div>;
+};
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss
new file mode 100644
index 0000000000..c23bb1c661
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss
@@ -0,0 +1,81 @@
+.story-footer {
+ color: var(--newtab-text-secondary-color);
+ inset-inline-start: 0;
+ margin-top: 12px;
+ position: relative;
+
+ .story-sponsored-label span {
+ display: inline-block;
+ }
+
+ .story-sponsored-label,
+ .status-message {
+ -webkit-line-clamp: 1;
+ font-size: 13px;
+ line-height: 24px;
+ color: var(--newtab-text-secondary-color);
+ }
+
+ .status-message {
+ display: flex;
+ align-items: center;
+ height: 24px;
+
+ .story-badge-icon {
+ fill: var(--newtab-text-secondary-color);
+ height: 16px;
+ margin-inline-end: 6px;
+
+ &.icon-bookmark-removed {
+ background-image: url('#{$image-path}icon-removed-bookmark.svg');
+ }
+ }
+
+ .story-context-label {
+ color: var(--newtab-text-secondary-color);
+ flex-grow: 1;
+ font-size: 13px;
+ line-height: 24px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+}
+
+.story-animate-enter {
+ opacity: 0;
+}
+
+.story-animate-enter-active {
+ opacity: 1;
+ transition: opacity 150ms ease-in 300ms;
+
+ .story-badge-icon,
+ .story-context-label {
+ animation: color 3s ease-out 0.3s;
+
+ @keyframes color {
+ 0% {
+ color: var(--newtab-status-success);
+ fill: var(--newtab-status-success);
+ }
+
+ 100% {
+ color: var(--newtab-text-secondary-color);
+ fill: var(--newtab-text-secondary-color);
+ }
+ }
+ }
+}
+
+.story-animate-exit {
+ position: absolute;
+ top: 0;
+ opacity: 1;
+}
+
+.story-animate-exit-active {
+ opacity: 0;
+ transition: opacity 250ms ease-in;
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx
new file mode 100644
index 0000000000..9090ebe582
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx
@@ -0,0 +1,57 @@
+/* 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 React from "react";
+
+export class DSDismiss extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onDismissClick = this.onDismissClick.bind(this);
+ this.onHover = this.onHover.bind(this);
+ this.offHover = this.offHover.bind(this);
+ this.state = {
+ hovering: false,
+ };
+ }
+
+ onDismissClick() {
+ if (this.props.onDismissClick) {
+ this.props.onDismissClick();
+ }
+ }
+
+ onHover() {
+ this.setState({
+ hovering: true,
+ });
+ }
+
+ offHover() {
+ this.setState({
+ hovering: false,
+ });
+ }
+
+ render() {
+ let className = `ds-dismiss
+ ${this.state.hovering ? ` hovering` : ``}
+ ${this.props.extraClasses ? ` ${this.props.extraClasses}` : ``}`;
+
+ return (
+ <div className={className}>
+ {this.props.children}
+ <button
+ className="ds-dismiss-button"
+ data-l10n-id="newtab-dismiss-button-tooltip"
+ onHover={this.onHover}
+ onClick={this.onDismissClick}
+ onMouseEnter={this.onHover}
+ onMouseLeave={this.offHover}
+ >
+ <span className="icon icon-dismiss" />
+ </button>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
new file mode 100644
index 0000000000..3c736a24ad
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
@@ -0,0 +1,47 @@
+.ds-dismiss {
+ position: relative;
+ border-radius: 8px;
+ transition-duration: 250ms;
+ transition-property: background;
+
+ &:hover {
+ .ds-dismiss-button {
+ opacity: 1;
+ }
+ }
+
+ .ds-dismiss-button {
+ border: 0;
+ cursor: pointer;
+ height: 32px;
+ width: 32px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ inset-inline-end: 0;
+ top: 0;
+ border-radius: 50%;
+ background-color: transparent;
+
+ .icon {
+ @media (forced-colors: active) {
+ fill: CurrentColor;
+ }
+ fill: var(--newtab-text-primary-color);
+ }
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ }
+
+ &:active {
+ background: var(--newtab-element-active-color);
+ }
+
+ &:focus {
+ box-shadow: $shadow-secondary;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx
new file mode 100644
index 0000000000..ff3886b407
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx
@@ -0,0 +1,100 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import React from "react";
+
+export class DSEmptyState extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onReset = this.onReset.bind(this);
+ this.state = {};
+ }
+
+ componentWillUnmount() {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ }
+
+ onReset() {
+ if (this.props.dispatch && this.props.feed) {
+ const { feed } = this.props;
+ const { url } = feed;
+ this.props.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: {
+ feed: {
+ ...feed,
+ data: {
+ ...feed.data,
+ status: "waiting",
+ },
+ },
+ url,
+ },
+ });
+
+ this.setState({ waiting: true });
+ this.timeout = setTimeout(() => {
+ this.timeout = null;
+ this.setState({
+ waiting: false,
+ });
+ }, 300);
+
+ this.props.dispatch(
+ ac.OnlyToMain({ type: at.DISCOVERY_STREAM_RETRY_FEED, data: { feed } })
+ );
+ }
+ }
+
+ renderButton() {
+ if (this.props.status === "waiting" || this.state.waiting) {
+ return (
+ <button
+ className="try-again-button waiting"
+ data-l10n-id="newtab-discovery-empty-section-topstories-loading"
+ />
+ );
+ }
+
+ return (
+ <button
+ className="try-again-button"
+ onClick={this.onReset}
+ data-l10n-id="newtab-discovery-empty-section-topstories-try-again-button"
+ />
+ );
+ }
+
+ renderState() {
+ if (this.props.status === "waiting" || this.props.status === "failed") {
+ return (
+ <React.Fragment>
+ <h2 data-l10n-id="newtab-discovery-empty-section-topstories-timed-out" />
+ {this.renderButton()}
+ </React.Fragment>
+ );
+ }
+
+ return (
+ <React.Fragment>
+ <h2 data-l10n-id="newtab-discovery-empty-section-topstories-header" />
+ <p data-l10n-id="newtab-discovery-empty-section-topstories-content" />
+ </React.Fragment>
+ );
+ }
+
+ render() {
+ return (
+ <div className="section-empty-state">
+ <div className="empty-state-message">{this.renderState()}</div>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss
new file mode 100644
index 0000000000..9f9accf71b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss
@@ -0,0 +1,83 @@
+.section-empty-state {
+ border: $border-secondary;
+ border-radius: 4px;
+ display: flex;
+ height: $card-height-compact;
+ width: 100%;
+
+ .empty-state-message {
+ color: var(--newtab-text-secondary-color);
+ font-size: 14px;
+ line-height: 20px;
+ text-align: center;
+ margin: auto;
+ max-width: 936px;
+ }
+
+ .try-again-button {
+ margin-top: 12px;
+ padding: 6px 32px;
+ border-radius: 2px;
+ border: 0;
+ background: var(--newtab-button-secondary-color);
+ color: var(--newtab-text-primary-color);
+ cursor: pointer;
+ position: relative;
+ transition: background 0.2s ease, color 0.2s ease;
+
+ &:not(.waiting) {
+ &:focus {
+ @include ds-fade-in;
+
+ @include dark-theme-only {
+ @include ds-fade-in($blue-40-40);
+ }
+ }
+
+ &:hover {
+ @include ds-fade-in(var(--newtab-element-secondary-color));
+ }
+ }
+
+ &::after {
+ content: '';
+ height: 20px;
+ width: 20px;
+ animation: spinner 1s linear infinite;
+ opacity: 0;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -10px 0 0 -10px;
+ mask-image: url('chrome://activity-stream/content/data/content/assets/spinner.svg');
+ mask-size: 20px;
+ background: var(--newtab-text-secondary-color);
+ }
+
+ &.waiting {
+ cursor: initial;
+ background: var(--newtab-element-secondary-color);
+ color: transparent;
+ transition: background 0.2s ease;
+
+ &::after {
+ transition: opacity 0.2s ease;
+ opacity: 1;
+ }
+ }
+ }
+
+ h2 {
+ font-size: 15px;
+ font-weight: 600;
+ margin: 0;
+ }
+
+ p {
+ margin: 0;
+ }
+}
+
+@keyframes spinner {
+ to { transform: rotate(360deg); }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
new file mode 100644
index 0000000000..8a6cefed3a
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
@@ -0,0 +1,263 @@
+/* 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 React from "react";
+
+const PLACEHOLDER_IMAGE_DATA_ARRAY = [
+ {
+ rotation: "0deg",
+ offsetx: "20px",
+ offsety: "8px",
+ scale: "45%",
+ },
+ {
+ rotation: "54deg",
+ offsetx: "-26px",
+ offsety: "62px",
+ scale: "55%",
+ },
+ {
+ rotation: "-30deg",
+ offsetx: "78px",
+ offsety: "30px",
+ scale: "68%",
+ },
+ {
+ rotation: "-22deg",
+ offsetx: "0",
+ offsety: "92px",
+ scale: "60%",
+ },
+ {
+ rotation: "-65deg",
+ offsetx: "66px",
+ offsety: "28px",
+ scale: "60%",
+ },
+ {
+ rotation: "22deg",
+ offsetx: "-35px",
+ offsety: "62px",
+ scale: "52%",
+ },
+ {
+ rotation: "-25deg",
+ offsetx: "86px",
+ offsety: "-15px",
+ scale: "68%",
+ },
+];
+
+const PLACEHOLDER_IMAGE_COLORS_ARRAY =
+ "#0090ED #FF4F5F #2AC3A2 #FF7139 #A172FF #FFA437 #FF2A8A".split(" ");
+
+function generateIndex({ keyCode, max }) {
+ if (!keyCode) {
+ // Just grab a random index if we cannot generate an index from a key.
+ return Math.floor(Math.random() * max);
+ }
+
+ const hashStr = str => {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ let charCode = str.charCodeAt(i);
+ hash += charCode;
+ }
+ return hash;
+ };
+
+ const hash = hashStr(keyCode);
+ return hash % max;
+}
+
+export function PlaceholderImage({ urlKey, titleKey }) {
+ const dataIndex = generateIndex({
+ keyCode: urlKey,
+ max: PLACEHOLDER_IMAGE_DATA_ARRAY.length,
+ });
+ const colorIndex = generateIndex({
+ keyCode: titleKey,
+ max: PLACEHOLDER_IMAGE_COLORS_ARRAY.length,
+ });
+ const { rotation, offsetx, offsety, scale } =
+ PLACEHOLDER_IMAGE_DATA_ARRAY[dataIndex];
+ const color = PLACEHOLDER_IMAGE_COLORS_ARRAY[colorIndex];
+ const style = {
+ "--placeholderBackgroundColor": color,
+ "--placeholderBackgroundRotation": rotation,
+ "--placeholderBackgroundOffsetx": offsetx,
+ "--placeholderBackgroundOffsety": offsety,
+ "--placeholderBackgroundScale": scale,
+ };
+
+ return <div style={style} className="placeholder-image" />;
+}
+
+export class DSImage extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onOptimizedImageError = this.onOptimizedImageError.bind(this);
+ this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this);
+ this.onLoad = this.onLoad.bind(this);
+
+ this.state = {
+ isLoaded: false,
+ optimizedImageFailed: false,
+ useTransition: false,
+ };
+ }
+
+ onIdleCallback() {
+ if (!this.state.isLoaded) {
+ this.setState({
+ useTransition: true,
+ });
+ }
+ }
+
+ reformatImageURL(url, width, height) {
+ // Change the image URL to request a size tailored for the parent container width
+ // Also: force JPEG, quality 60, no upscaling, no EXIF data
+ // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html
+ return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(
+ url
+ )}`;
+ }
+
+ componentDidMount() {
+ this.idleCallbackId = this.props.windowObj.requestIdleCallback(
+ this.onIdleCallback.bind(this)
+ );
+ }
+
+ componentWillUnmount() {
+ if (this.idleCallbackId) {
+ this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
+ }
+ }
+
+ render() {
+ let classNames = `ds-image
+ ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}
+ ${this.state && this.state.useTransition ? ` use-transition` : ``}
+ ${this.state && this.state.isLoaded ? ` loaded` : ``}
+ `;
+
+ let img;
+
+ if (this.state) {
+ if (
+ this.props.optimize &&
+ this.props.rawSource &&
+ !this.state.optimizedImageFailed
+ ) {
+ let baseSource = this.props.rawSource;
+
+ let sizeRules = [];
+ let srcSetRules = [];
+
+ for (let rule of this.props.sizes) {
+ let { mediaMatcher, width, height } = rule;
+ let sizeRule = `${mediaMatcher} ${width}px`;
+ sizeRules.push(sizeRule);
+ let srcSetRule = `${this.reformatImageURL(
+ baseSource,
+ width,
+ height
+ )} ${width}w`;
+ let srcSetRule2x = `${this.reformatImageURL(
+ baseSource,
+ width * 2,
+ height * 2
+ )} ${width * 2}w`;
+ srcSetRules.push(srcSetRule);
+ srcSetRules.push(srcSetRule2x);
+ }
+
+ if (this.props.sizes.length) {
+ // We have to supply a fallback in the very unlikely event that none of
+ // the media queries match. The smallest dimension was chosen arbitrarily.
+ sizeRules.push(
+ `${this.props.sizes[this.props.sizes.length - 1].width}px`
+ );
+ }
+
+ img = (
+ <img
+ loading="lazy"
+ alt={this.props.alt_text}
+ crossOrigin="anonymous"
+ onLoad={this.onLoad}
+ onError={this.onOptimizedImageError}
+ sizes={sizeRules.join(",")}
+ src={baseSource}
+ srcSet={srcSetRules.join(",")}
+ />
+ );
+ } else if (this.props.source && !this.state.nonOptimizedImageFailed) {
+ img = (
+ <img
+ loading="lazy"
+ alt={this.props.alt_text}
+ crossOrigin="anonymous"
+ onLoad={this.onLoad}
+ onError={this.onNonOptimizedImageError}
+ src={this.props.source}
+ />
+ );
+ } else {
+ // We consider a failed to load img or source without an image as loaded.
+ classNames = `${classNames} loaded`;
+ // Remove the img element if we have no source. Render a placeholder instead.
+ // This only happens for recent saves without a source.
+ if (
+ this.props.isRecentSave &&
+ !this.props.rawSource &&
+ !this.props.source
+ ) {
+ img = (
+ <PlaceholderImage
+ urlKey={this.props.url}
+ titleKey={this.props.title}
+ />
+ );
+ } else {
+ img = <div className="broken-image" />;
+ }
+ }
+ }
+
+ return <picture className={classNames}>{img}</picture>;
+ }
+
+ onOptimizedImageError() {
+ // This will trigger a re-render and the unoptimized 450px image will be used as a fallback
+ this.setState({
+ optimizedImageFailed: true,
+ });
+ }
+
+ onNonOptimizedImageError() {
+ this.setState({
+ nonOptimizedImageFailed: true,
+ });
+ }
+
+ onLoad() {
+ this.setState({
+ isLoaded: true,
+ });
+ }
+}
+
+DSImage.defaultProps = {
+ source: null, // The current source style from Pocket API (always 450px)
+ rawSource: null, // Unadulterated image URL to filter through Thumbor
+ extraClassNames: null, // Additional classnames to append to component
+ optimize: true, // Measure parent container to request exact sizes
+ alt_text: null,
+ windowObj: window, // Added to support unit tests
+ sizes: [],
+};
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
new file mode 100644
index 0000000000..b11bcdcf55
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
@@ -0,0 +1,48 @@
+.ds-image {
+ display: block;
+ position: relative;
+ opacity: 0;
+
+ &.use-transition {
+ transition: opacity 0.8s;
+ }
+
+ &.loaded {
+ opacity: 1;
+ }
+
+ img,
+ .placeholder-image,
+ .broken-image {
+ background-color: var(--newtab-element-secondary-color);
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .placeholder-image {
+ overflow: hidden;
+ background-color: var(--placeholderBackgroundColor);
+
+ &::before {
+ content: '';
+ background-image: url('chrome://activity-stream/content/data/content/assets/pocket-swoosh.svg');
+ background-repeat: no-repeat;
+ background-position: center;
+ transform: rotate(var(--placeholderBackgroundRotation));
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ height: 200%;
+ // We use margin-left over margin-inline-start on purpose.
+ // This is because we are using it to offset an image's content,
+ // and the image content is the same in ltr and rtl.
+ margin-left: var(--placeholderBackgroundOffsetx); // stylelint-disable-line property-disallowed-list
+ margin-top: var(--placeholderBackgroundOffsety);
+ background-size: var(--placeholderBackgroundScale);
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx
new file mode 100644
index 0000000000..b75063940c
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx
@@ -0,0 +1,70 @@
+/* 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 { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import React from "react";
+
+export class DSLinkMenu extends React.PureComponent {
+ render() {
+ const { index, dispatch } = this.props;
+ let pocketMenuOptions = [];
+ let TOP_STORIES_CONTEXT_MENU_OPTIONS = [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ ];
+ if (!this.props.isRecentSave) {
+ if (this.props.pocket_button_enabled) {
+ pocketMenuOptions = this.props.saveToPocketCard
+ ? ["CheckDeleteFromPocket"]
+ : ["CheckSavedToPocket"];
+ }
+ TOP_STORIES_CONTEXT_MENU_OPTIONS = [
+ "CheckBookmark",
+ "CheckArchiveFromPocket",
+ ...pocketMenuOptions,
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ ...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : []),
+ ];
+ }
+ const type = this.props.type || "DISCOVERY_STREAM";
+ const title = this.props.title || this.props.source;
+
+ return (
+ <div className="context-menu-position-container">
+ <ContextMenuButton
+ tooltip={"newtab-menu-content-tooltip"}
+ tooltipArgs={{ title }}
+ onUpdate={this.props.onMenuUpdate}
+ >
+ <LinkMenu
+ dispatch={dispatch}
+ index={index}
+ source={type.toUpperCase()}
+ onShow={this.props.onMenuShow}
+ options={TOP_STORIES_CONTEXT_MENU_OPTIONS}
+ shouldSendImpressionStats={true}
+ userEvent={ac.DiscoveryStreamUserEvent}
+ site={{
+ referrer: "https://getpocket.com/recommendations",
+ title: this.props.title,
+ type: this.props.type,
+ url: this.props.url,
+ guid: this.props.id,
+ pocket_id: this.props.pocket_id,
+ shim: this.props.shim,
+ bookmarkGuid: this.props.bookmarkGuid,
+ flight_id: this.props.flightId,
+ }}
+ />
+ </ContextMenuButton>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss
new file mode 100644
index 0000000000..e85eab11e7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss
@@ -0,0 +1,28 @@
+.ds-card,
+.ds-signup {
+ @include context-menu-button;
+
+ .context-menu {
+ opacity: 0;
+ }
+
+ &.active {
+ .context-menu {
+ opacity: 1;
+ }
+ }
+
+ &.last-item {
+ @include context-menu-open-left;
+
+ .context-menu {
+ opacity: 1;
+ }
+ }
+
+ &:is(:hover, :focus, .active) {
+ @include context-menu-button-hover;
+
+ outline: none;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx
new file mode 100644
index 0000000000..df9ad4f641
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx
@@ -0,0 +1,34 @@
+/* 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 React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+
+export class DSMessage extends React.PureComponent {
+ render() {
+ return (
+ <div className="ds-message">
+ <header className="title">
+ {this.props.icon && (
+ <div
+ className="glyph"
+ style={{ backgroundImage: `url(${this.props.icon})` }}
+ />
+ )}
+ {this.props.title && (
+ <span className="title-text">
+ <FluentOrText message={this.props.title} />
+ </span>
+ )}
+ {this.props.link_text && this.props.link_url && (
+ <SafeAnchor className="link" url={this.props.link_url}>
+ <FluentOrText message={this.props.link_text} />
+ </SafeAnchor>
+ )}
+ </header>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss
new file mode 100644
index 0000000000..bb9666ae38
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss
@@ -0,0 +1,37 @@
+.ds-message {
+ margin: 8px 0 0;
+
+ .title {
+ display: flex;
+ align-items: center;
+
+ .glyph {
+ width: 16px;
+ height: 16px;
+ margin: 0 6px 0 0;
+ -moz-context-properties: fill;
+ fill: var(--newtab-text-secondary-color);
+ background-position: center center;
+ background-size: 16px;
+ background-repeat: no-repeat;
+ }
+
+ .title-text {
+ line-height: 20px;
+ font-size: 13px;
+ color: var(--newtab-text-secondary-color);
+ font-weight: 600;
+ padding-right: 12px;
+ }
+
+ .link {
+ line-height: 20px;
+ font-size: 13px;
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
new file mode 100644
index 0000000000..06ec1f7e78
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
@@ -0,0 +1,72 @@
+/* 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 React from "react";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay";
+
+export class DSPrivacyModal extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.closeModal = this.closeModal.bind(this);
+ this.onLearnLinkClick = this.onLearnLinkClick.bind(this);
+ this.onManageLinkClick = this.onManageLinkClick.bind(this);
+ }
+
+ onLearnLinkClick(event) {
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK_PRIVACY_INFO",
+ source: "DS_PRIVACY_MODAL",
+ })
+ );
+ }
+
+ onManageLinkClick(event) {
+ this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN }));
+ }
+
+ closeModal() {
+ this.props.dispatch({
+ type: `HIDE_PRIVACY_INFO`,
+ data: {},
+ });
+ }
+
+ render() {
+ return (
+ <ModalOverlayWrapper
+ onClose={this.closeModal}
+ innerClassName="ds-privacy-modal"
+ >
+ <div className="privacy-notice">
+ <h3 data-l10n-id="newtab-privacy-modal-header" />
+ <p data-l10n-id="newtab-privacy-modal-paragraph-2" />
+ <a
+ className="modal-link modal-link-privacy"
+ data-l10n-id="newtab-privacy-modal-link"
+ onClick={this.onLearnLinkClick}
+ href="https://help.getpocket.com/article/1142-firefox-new-tab-recommendations-faq"
+ />
+ <button
+ className="modal-link modal-link-manage"
+ data-l10n-id="newtab-privacy-modal-button-manage"
+ onClick={this.onManageLinkClick}
+ />
+ </div>
+ <section className="actions">
+ <button
+ className="done"
+ type="submit"
+ onClick={this.closeModal}
+ data-l10n-id="newtab-privacy-modal-button-done"
+ />
+ </section>
+ </ModalOverlayWrapper>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss
new file mode 100644
index 0000000000..2077f35709
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss
@@ -0,0 +1,48 @@
+.ds-privacy-modal {
+ .modal-link {
+ display: flex;
+ align-items: center;
+ margin: 0 0 8px;
+ border: 0;
+ padding: 0;
+ color: var(--newtab-primary-action-background);
+ width: max-content;
+
+ &:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ }
+
+ &::before {
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-action-background);
+ content: '';
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ margin-inline-end: 8px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ }
+
+ &.modal-link-privacy::before {
+ background-image: url('chrome://global/skin/icons/info.svg');
+ }
+
+ &.modal-link-manage::before {
+ background-image: url('chrome://global/skin/icons/settings.svg');
+ }
+ }
+
+ p {
+ line-height: 24px;
+ }
+
+ .privacy-notice {
+ max-width: 572px;
+ padding: 40px;
+ margin: auto;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx
new file mode 100644
index 0000000000..b7e3205646
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx
@@ -0,0 +1,168 @@
+/* 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 { actionCreators as ac } from "common/Actions.sys.mjs";
+import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+
+export class DSSignup extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ active: false,
+ lastItem: false,
+ };
+ this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this);
+ this.onLinkClick = this.onLinkClick.bind(this);
+ this.onMenuShow = this.onMenuShow.bind(this);
+ }
+
+ onMenuButtonUpdate(showContextMenu) {
+ if (!showContextMenu) {
+ this.setState({
+ active: false,
+ lastItem: false,
+ });
+ }
+ }
+
+ nextAnimationFrame() {
+ return new Promise(resolve =>
+ this.props.windowObj.requestAnimationFrame(resolve)
+ );
+ }
+
+ async onMenuShow() {
+ let { lastItem } = this.state;
+ // Wait for next frame before computing scrollMaxX to allow fluent menu strings to be visible
+ await this.nextAnimationFrame();
+ if (this.props.windowObj.scrollMaxX > 0) {
+ lastItem = true;
+ }
+ this.setState({
+ active: true,
+ lastItem,
+ });
+ }
+
+ onLinkClick() {
+ const { data } = this.props;
+ if (this.props.dispatch && data && data.spocs && data.spocs.length) {
+ const source = this.props.type.toUpperCase();
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source,
+ action_position: 0,
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source,
+ click: 0,
+ tiles: [
+ {
+ id: spoc.id,
+ pos: 0,
+ ...(spoc.shim && spoc.shim.click
+ ? { shim: spoc.shim.click }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ render() {
+ const { data, dispatch, type } = this.props;
+ if (!data || !data.spocs || !data.spocs[0]) {
+ return null;
+ }
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ const { title, url, excerpt, flight_id, id, shim } = spoc;
+
+ const SIGNUP_CONTEXT_MENU_OPTIONS = [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ ...(flight_id ? ["ShowPrivacyInfo"] : []),
+ ];
+
+ const outerClassName = [
+ "ds-signup",
+ this.state.active && "active",
+ this.state.lastItem && "last-item",
+ ]
+ .filter(v => v)
+ .join(" ");
+
+ return (
+ <div className={outerClassName}>
+ <div className="ds-signup-content">
+ <span className="icon icon-small-spacer icon-mail"></span>
+ <span>
+ {title}{" "}
+ <SafeAnchor
+ className="ds-chevron-link"
+ dispatch={dispatch}
+ onLinkClick={this.onLinkClick}
+ url={url}
+ >
+ {excerpt}
+ </SafeAnchor>
+ </span>
+ <ImpressionStats
+ flightId={flight_id}
+ rows={[
+ {
+ id,
+ pos: 0,
+ shim: shim && shim.impression,
+ },
+ ]}
+ dispatch={dispatch}
+ source={type}
+ />
+ </div>
+ <ContextMenuButton
+ tooltip={"newtab-menu-content-tooltip"}
+ tooltipArgs={{ title }}
+ onUpdate={this.onMenuButtonUpdate}
+ >
+ <LinkMenu
+ dispatch={dispatch}
+ index={0}
+ source={type.toUpperCase()}
+ onShow={this.onMenuShow}
+ options={SIGNUP_CONTEXT_MENU_OPTIONS}
+ shouldSendImpressionStats={true}
+ userEvent={ac.DiscoveryStreamUserEvent}
+ site={{
+ referrer: "https://getpocket.com/recommendations",
+ title,
+ type,
+ url,
+ guid: id,
+ shim,
+ flight_id,
+ }}
+ />
+ </ContextMenuButton>
+ </div>
+ );
+ }
+}
+
+DSSignup.defaultProps = {
+ windowObj: window, // Added to support unit tests
+};
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss
new file mode 100644
index 0000000000..dcaf0e804a
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss
@@ -0,0 +1,52 @@
+.ds-signup {
+ max-width: 300px;
+ margin: 0 auto;
+ padding: 8px;
+ position: relative;
+ text-align: center;
+ font-size: 17px;
+ font-weight: 600;
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ border-radius: 4px;
+ }
+
+ .icon-mail {
+ height: 40px;
+ width: 40px;
+ margin-inline-end: 8px;
+ fill: var(--newtab-text-secondary-color);
+ background-size: 30px;
+ flex-shrink: 0;
+ }
+
+ .ds-signup-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+
+ .ds-chevron-link {
+ margin-top: 4px;
+ box-shadow: none;
+ display: block;
+ white-space: nowrap;
+ }
+ }
+
+ @media (min-width: $break-point-large) {
+ min-width: 756px;
+ width: max-content;
+ text-align: start;
+
+ .ds-signup-content {
+ flex-direction: row;
+
+ .ds-chevron-link {
+ margin-top: 0;
+ display: inline;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
new file mode 100644
index 0000000000..02a3326eb7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
@@ -0,0 +1,143 @@
+/* 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 { actionCreators as ac } from "common/Actions.sys.mjs";
+import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
+import { DSImage } from "../DSImage/DSImage.jsx";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import { LinkMenuOptions } from "content-src/lib/link-menu-options";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+
+export class DSTextPromo extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onLinkClick = this.onLinkClick.bind(this);
+ this.onDismissClick = this.onDismissClick.bind(this);
+ }
+
+ onLinkClick() {
+ const { data } = this.props;
+ if (this.props.dispatch && data && data.spocs && data.spocs.length) {
+ const source = this.props.type.toUpperCase();
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source,
+ action_position: 0,
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source,
+ click: 0,
+ tiles: [
+ {
+ id: spoc.id,
+ pos: 0,
+ ...(spoc.shim && spoc.shim.click
+ ? { shim: spoc.shim.click }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ onDismissClick() {
+ const { data } = this.props;
+ if (this.props.dispatch && data && data.spocs && data.spocs.length) {
+ const index = 0;
+ const source = this.props.type.toUpperCase();
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ const spocData = {
+ url: spoc.url,
+ guid: spoc.id,
+ shim: spoc.shim,
+ };
+ const blockUrlOption = LinkMenuOptions.BlockUrl(spocData, index, source);
+
+ const { action, impression, userEvent } = blockUrlOption;
+
+ this.props.dispatch(action);
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: userEvent,
+ source,
+ action_position: index,
+ })
+ );
+ if (impression) {
+ this.props.dispatch(impression);
+ }
+ }
+ }
+
+ render() {
+ const { data } = this.props;
+ if (!data || !data.spocs || !data.spocs[0]) {
+ return null;
+ }
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ const {
+ image_src,
+ raw_image_src,
+ alt_text,
+ title,
+ url,
+ context,
+ cta,
+ flight_id,
+ id,
+ shim,
+ } = spoc;
+
+ return (
+ <DSDismiss
+ onDismissClick={this.onDismissClick}
+ extraClasses={`ds-dismiss-ds-text-promo`}
+ >
+ <div className="ds-text-promo">
+ <DSImage
+ alt_text={alt_text}
+ source={image_src}
+ rawSource={raw_image_src}
+ />
+ <div className="text">
+ <h3>
+ {`${title}\u2003`}
+ <SafeAnchor
+ className="ds-chevron-link"
+ dispatch={this.props.dispatch}
+ onLinkClick={this.onLinkClick}
+ url={url}
+ >
+ {cta}
+ </SafeAnchor>
+ </h3>
+ <p className="subtitle">{context}</p>
+ </div>
+ <ImpressionStats
+ flightId={flight_id}
+ rows={[
+ {
+ id,
+ pos: 0,
+ shim: shim && shim.impression,
+ },
+ ]}
+ dispatch={this.props.dispatch}
+ source={this.props.type}
+ />
+ </div>
+ </DSDismiss>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
new file mode 100644
index 0000000000..b0abea1213
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
@@ -0,0 +1,92 @@
+.ds-dismiss-ds-text-promo {
+ max-width: 744px;
+ margin: auto;
+ overflow: hidden;
+
+ &.hovering {
+ background: var(--newtab-element-hover-color);
+ }
+
+ .ds-dismiss-button {
+ margin-inline: 0 18px;
+ margin-block: 18px 0;
+ }
+}
+
+.ds-text-promo {
+ max-width: 640px;
+ margin: 0;
+ padding: 18px;
+
+ @media(min-width: $break-point-medium) {
+ display: flex;
+ margin: 18px 24px;
+ padding: 0 32px 0 0;
+ }
+
+ .ds-image {
+ width: 40px;
+ height: 40px;
+ flex-shrink: 0;
+ margin: 0 0 18px;
+
+ @media(min-width: $break-point-medium) {
+ margin: 4px 12px 0 0;
+ }
+
+ img {
+ border-radius: 4px;
+ }
+ }
+
+ .text {
+ line-height: 24px;
+ }
+
+ h3 {
+ color: var(--newtab-text-primary-color);
+ margin: 0;
+ font-weight: 600;
+ font-size: 15px;
+ }
+
+ .subtitle {
+ font-size: 13px;
+ margin: 0;
+ color: var(--newtab-text-primary-color);
+ }
+}
+
+.ds-chevron-link {
+ color: var(--newtab-primary-action-background);
+ display: inline-block;
+ outline: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ color: var(--newtab-primary-element-active-color);
+
+ &::after {
+ background-color: var(--newtab-primary-element-active-color);
+ }
+ }
+
+ &:focus {
+ box-shadow: $shadow-secondary;
+ border-radius: 2px;
+ }
+
+ &::after {
+ background-color: var(--newtab-primary-action-background);
+ content: ' ';
+ mask: url('chrome://global/skin/icons/arrow-right-12.svg') 0 -8px no-repeat;
+ margin: 0 0 0 4px;
+ width: 5px;
+ height: 8px;
+ text-decoration: none;
+ display: inline-block;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx
new file mode 100644
index 0000000000..d0cc87cce3
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx
@@ -0,0 +1,26 @@
+/* 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 { connect } from "react-redux";
+import React from "react";
+import { SectionIntl } from "content-src/components/Sections/Sections";
+
+export class _Highlights extends React.PureComponent {
+ render() {
+ const section = this.props.Sections.find(s => s.id === "highlights");
+ if (!section || !section.enabled) {
+ return null;
+ }
+
+ return (
+ <div className="ds-highlights sections-list">
+ <SectionIntl {...section} isFixed={true} />
+ </div>
+ );
+ }
+}
+
+export const Highlights = connect(state => ({ Sections: state.Sections }))(
+ _Highlights
+);
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss
new file mode 100644
index 0000000000..3c5b60e946
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss
@@ -0,0 +1,45 @@
+.ds-highlights {
+ .section {
+ .section-list {
+ grid-gap: var(--gridRowGap);
+ grid-template-columns: repeat(1, 1fr);
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .card-outer {
+ $line-height: 20px;
+
+ height: 175px;
+
+ .card-host-name {
+ font-size: 13px;
+ line-height: $line-height;
+ margin-bottom: 2px;
+ padding-bottom: 0;
+ text-transform: unset;
+ }
+
+ .card-title {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: $line-height;
+ max-height: $line-height;
+ }
+
+ a {
+ text-decoration: none;
+ }
+ }
+ }
+ }
+
+ .hide-for-narrow {
+ display: block;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx
new file mode 100644
index 0000000000..4cdfc7594f
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx
@@ -0,0 +1,11 @@
+/* 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 React from "react";
+
+export class HorizontalRule extends React.PureComponent {
+ render() {
+ return <hr className="ds-hr" />;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss
new file mode 100644
index 0000000000..aa5d6ff9f3
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss
@@ -0,0 +1,7 @@
+.ds-hr {
+ @include ds-border-top {
+ border: 0;
+ };
+
+ height: 0;
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx
new file mode 100644
index 0000000000..1062c3cade
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx
@@ -0,0 +1,112 @@
+/* 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 { actionCreators as ac } from "common/Actions.sys.mjs";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+
+export class Topic extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onLinkClick = this.onLinkClick.bind(this);
+ }
+
+ onLinkClick(event) {
+ if (this.props.dispatch) {
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "POPULAR_TOPICS",
+ action_position: 0,
+ value: {
+ topic: event.target.text.toLowerCase().replace(` `, `-`),
+ },
+ })
+ );
+ }
+ }
+
+ render() {
+ const { url, name } = this.props;
+ return (
+ <SafeAnchor
+ onLinkClick={this.onLinkClick}
+ className={this.props.className}
+ url={url}
+ >
+ {name}
+ </SafeAnchor>
+ );
+ }
+}
+
+export class Navigation extends React.PureComponent {
+ render() {
+ let links = this.props.links || [];
+ const alignment = this.props.alignment || "centered";
+ const header = this.props.header || {};
+ const english = this.props.locale.startsWith("en-");
+ const privacyNotice = this.props.privacyNoticeURL || {};
+ const { newFooterSection } = this.props;
+ const className = `ds-navigation ds-navigation-${alignment} ${
+ newFooterSection ? `ds-navigation-new-topics` : ``
+ }`;
+ let { title } = header;
+ if (newFooterSection) {
+ title = { id: "newtab-pocket-new-topics-title" };
+ if (this.props.extraLinks) {
+ links = [
+ ...links.slice(0, links.length - 1),
+ ...this.props.extraLinks,
+ links[links.length - 1],
+ ];
+ }
+ }
+
+ return (
+ <div className={className}>
+ {title && english ? (
+ <FluentOrText message={title}>
+ <span className="ds-navigation-header" />
+ </FluentOrText>
+ ) : null}
+
+ {english ? (
+ <ul>
+ {links &&
+ links.map(t => (
+ <li key={t.name}>
+ <Topic
+ url={t.url}
+ name={t.name}
+ dispatch={this.props.dispatch}
+ />
+ </li>
+ ))}
+ </ul>
+ ) : null}
+
+ {!newFooterSection ? (
+ <SafeAnchor className="ds-navigation-privacy" url={privacyNotice.url}>
+ <FluentOrText message={privacyNotice.title} />
+ </SafeAnchor>
+ ) : null}
+
+ {newFooterSection ? (
+ <div className="ds-navigation-family">
+ <span className="icon firefox-logo" />
+ <span>|</span>
+ <span className="icon pocket-logo" />
+ <span
+ className="ds-navigation-family-message"
+ data-l10n-id="newtab-pocket-pocket-firefox-family"
+ />
+ </div>
+ ) : null}
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss
new file mode 100644
index 0000000000..f9b5e5c704
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss
@@ -0,0 +1,180 @@
+.ds-navigation {
+ color: var(--newtab-text-primary-color);
+ font-size: 11.5px;
+ font-weight: 500;
+ line-height: 22px;
+ padding: 4px 0;
+
+ @media (min-width: $break-point-widest) {
+ line-height: 32px;
+ font-size: 14px;
+ }
+
+ &.ds-navigation-centered {
+ text-align: center;
+ }
+
+ &.ds-navigation-right-aligned {
+ text-align: end;
+ }
+
+ ul {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ }
+
+ ul li {
+ display: inline-block;
+
+ &::after {
+ content: '·';
+ padding: 6px;
+ }
+
+ &:last-child::after {
+ content: none;
+ }
+
+ a {
+ &:hover,
+ &:active {
+ text-decoration: none;
+ }
+
+ &:active {
+ color: var(--newtab-primary-element-active-color);
+ }
+ }
+ }
+
+ .ds-navigation-header {
+ padding-inline-end: 6px;
+ }
+
+ .ds-navigation-privacy {
+ padding-inline-start: 6px;
+ float: inline-end;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ &.ds-navigation-new-topics {
+ display: block;
+ padding-top: 32px;
+
+ .ds-navigation-header {
+ font-size: 14px;
+ line-height: 20px;
+ font-weight: 700;
+ display: inline-block;
+ margin-bottom: 8px;
+ }
+
+ .ds-navigation-family {
+ text-align: center;
+ font-size: 14px;
+ line-height: 20px;
+ margin: 16px auto 28px;
+
+ span {
+ margin: 0 6px;
+ }
+
+ .firefox-logo,
+ .pocket-logo {
+ height: 20px;
+ width: 20px;
+ background-size: cover;
+ }
+
+ .firefox-logo {
+ background-image: url('chrome://activity-stream/content/data/content/assets/firefox.svg');
+ }
+
+ .pocket-logo {
+ background-image: url('chrome://global/skin/icons/pocket.svg');
+ fill: $pocket-icon-fill;
+ }
+
+ .ds-navigation-family-message {
+ font-weight: 400;
+ display: block;
+
+ @media (min-width: $break-point-medium) {
+ display: inline;
+ }
+ }
+
+ @media (min-width: $break-point-medium) {
+ margin-top: 43px;
+ }
+ }
+
+ ul {
+ display: grid;
+ grid-gap: 0 24px;
+ grid-auto-flow: column;
+ grid-template: repeat(8, 1fr) / repeat(1, 1fr);
+
+ li {
+ border-top: $border-primary;
+ line-height: 24px;
+ font-size: 13px;
+ font-weight: 500;
+
+ &::after {
+ content: '';
+ padding: 0;
+ }
+
+ &:nth-last-child(2),
+ &:nth-last-child(3) {
+ display: none;
+ }
+
+ &:nth-last-child(1) {
+ border-bottom: $border-primary;
+ }
+ }
+
+ @media (min-width: $break-point-medium) {
+ grid-template: repeat(3, 1fr) / repeat(2, 1fr);
+
+ li {
+ &:nth-child(3) {
+ border-bottom: $border-primary;
+ }
+ }
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template: repeat(2, 1fr) / repeat(3, 1fr);
+
+
+ li {
+ &:nth-child(odd) {
+ border-bottom: 0;
+ }
+
+ &:nth-child(even) {
+ border-bottom: $border-primary;
+ }
+ }
+ }
+
+ @media (min-width: $break-point-widest) {
+ grid-template: repeat(2, 1fr) / repeat(4, 1fr);
+
+ li {
+ &:nth-last-child(2),
+ &:nth-last-child(3) {
+ display: block;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx
new file mode 100644
index 0000000000..8f7d88be85
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx
@@ -0,0 +1,20 @@
+/* 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 React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+
+export class PrivacyLink extends React.PureComponent {
+ render() {
+ const { properties } = this.props;
+ return (
+ <div className="ds-privacy-link">
+ <SafeAnchor url={properties.url}>
+ <FluentOrText message={properties.title} />
+ </SafeAnchor>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss
new file mode 100644
index 0000000000..08ce093c27
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss
@@ -0,0 +1,10 @@
+.ds-privacy-link {
+ text-align: center;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 24px;
+
+ a:hover {
+ text-decoration: none;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx
new file mode 100644
index 0000000000..cfbc6fe6cb
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx
@@ -0,0 +1,65 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import React from "react";
+
+export class SafeAnchor extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick(event) {
+ // Use dispatch instead of normal link click behavior to include referrer
+ if (this.props.dispatch) {
+ event.preventDefault();
+ const { altKey, button, ctrlKey, metaKey, shiftKey } = event;
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: {
+ event: { altKey, button, ctrlKey, metaKey, shiftKey },
+ referrer: "https://getpocket.com/recommendations",
+ // Use the anchor's url, which could have been cleaned up
+ url: event.currentTarget.href,
+ },
+ })
+ );
+ }
+
+ // Propagate event if there's a handler
+ if (this.props.onLinkClick) {
+ this.props.onLinkClick(event);
+ }
+ }
+
+ safeURI(url) {
+ let protocol = null;
+ try {
+ protocol = new URL(url).protocol;
+ } catch (e) {
+ return "";
+ }
+
+ const isAllowed = ["http:", "https:"].includes(protocol);
+ if (!isAllowed) {
+ console.warn(`${url} is not allowed for anchor targets.`); // eslint-disable-line no-console
+ return "";
+ }
+ return url;
+ }
+
+ render() {
+ const { url, className } = this.props;
+ return (
+ <a href={this.safeURI(url)} className={className} onClick={this.onClick}>
+ {this.props.children}
+ </a>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx
new file mode 100644
index 0000000000..646dc2263e
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx
@@ -0,0 +1,19 @@
+/* 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 React from "react";
+
+export class SectionTitle extends React.PureComponent {
+ render() {
+ const {
+ header: { title, subtitle },
+ } = this.props;
+ return (
+ <div className="ds-section-title">
+ <div className="title">{title}</div>
+ {subtitle ? <div className="subtitle">{subtitle}</div> : null}
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss
new file mode 100644
index 0000000000..453001b1b7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss
@@ -0,0 +1,18 @@
+.ds-section-title {
+ text-align: center;
+ margin-top: 24px;
+
+ .title {
+ color: var(--newtab-text-primary-color);
+ line-height: 48px;
+ font-size: 36px;
+ font-weight: 300;
+ }
+
+ .subtitle {
+ line-height: 24px;
+ font-size: 14px;
+ color: var(--newtab-text-secondary-color);
+ margin-top: 4px;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss
new file mode 100644
index 0000000000..e0c7c1a8eb
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss
@@ -0,0 +1,77 @@
+.outer-wrapper {
+ .ds-top-sites {
+ .top-sites {
+ .top-site-outer {
+ .top-site-inner > a:is(.active, :focus) .tile {
+ @include ds-focus;
+ }
+
+ .top-site-inner > a:is(:hover) .top-site-inner {
+ @include ds-fade-in(var(--newtab-background-color-secondary));
+ }
+ }
+
+ .top-sites-list {
+ margin: 0 -12px;
+ }
+ }
+ }
+}
+
+// Size overrides for topsites in the 2/3 view.
+.ds-column-5,
+.ds-column-6,
+.ds-column-7,
+.ds-column-8 {
+ .ds-top-sites {
+ .top-site-outer {
+ padding: 0 10px;
+ }
+
+ .top-sites-list {
+ margin: 0 -10px;
+ }
+
+ .top-site-inner {
+ --leftPanelIconWidth: 84.67px;
+
+ .tile {
+ width: var(--leftPanelIconWidth);
+ height: var(--leftPanelIconWidth);
+ }
+
+ .title {
+ width: var(--leftPanelIconWidth);
+ }
+ }
+ }
+}
+
+// Size overrides for topsites in the 1/3 view.
+.ds-column-1,
+.ds-column-2,
+.ds-column-3,
+.ds-column-4 {
+ .ds-top-sites {
+ .top-site-outer {
+ padding: 0 8px;
+ }
+
+ .top-sites-list {
+ margin: 0 -8px;
+ }
+
+ .top-site-inner {
+ --rightPanelIconWidth: 82.67px;
+
+ .tile {
+ width: var(--rightPanelIconWidth);
+ height: var(--rightPanelIconWidth);
+ }
+
+ .title {
+ width: var(--rightPanelIconWidth);
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx
new file mode 100644
index 0000000000..1fe2343b94
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx
@@ -0,0 +1,125 @@
+/* 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 React from "react";
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import { connect } from "react-redux";
+
+export function _TopicsWidget(props) {
+ const { id, source, position, DiscoveryStream, dispatch } = props;
+
+ const { utmCampaign, utmContent, utmSource } = DiscoveryStream.experimentData;
+
+ let queryParams = `?utm_source=${utmSource}`;
+ if (utmCampaign && utmContent) {
+ queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`;
+ }
+
+ const topics = [
+ { label: "Technology", name: "technology" },
+ { label: "Science", name: "science" },
+ { label: "Self-Improvement", name: "self-improvement" },
+ { label: "Travel", name: "travel" },
+ { label: "Career", name: "career" },
+ { label: "Entertainment", name: "entertainment" },
+ { label: "Food", name: "food" },
+ { label: "Health", name: "health" },
+ {
+ label: "Must-Reads",
+ name: "must-reads",
+ url: `https://getpocket.com/collections${queryParams}`,
+ },
+ ];
+
+ function onLinkClick(topic, positionInCard) {
+ if (dispatch) {
+ dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source,
+ action_position: position,
+ value: {
+ card_type: "topics_widget",
+ topic,
+ ...(positionInCard || positionInCard === 0
+ ? { position_in_card: positionInCard }
+ : {}),
+ },
+ })
+ );
+ dispatch(
+ ac.ImpressionStats({
+ source,
+ click: 0,
+ window_inner_width: props.windowObj.innerWidth,
+ window_inner_height: props.windowObj.innerHeight,
+ tiles: [
+ {
+ id,
+ pos: position,
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ function mapTopicItem(topic, index) {
+ return (
+ <li
+ key={topic.name}
+ className={topic.overflow ? "ds-topics-widget-list-overflow-item" : ""}
+ >
+ <SafeAnchor
+ url={
+ topic.url ||
+ `https://getpocket.com/explore/${topic.name}${queryParams}`
+ }
+ dispatch={dispatch}
+ onLinkClick={() => onLinkClick(topic.name, index)}
+ >
+ {topic.label}
+ </SafeAnchor>
+ </li>
+ );
+ }
+
+ return (
+ <div className="ds-topics-widget">
+ <header className="ds-topics-widget-header">Popular Topics</header>
+ <hr />
+ <div className="ds-topics-widget-list-container">
+ <ul>{topics.map(mapTopicItem)}</ul>
+ </div>
+ <SafeAnchor
+ className="ds-topics-widget-button button primary"
+ url={`https://getpocket.com/${queryParams}`}
+ dispatch={dispatch}
+ onLinkClick={() => onLinkClick("more-topics")}
+ >
+ More Topics
+ </SafeAnchor>
+ <ImpressionStats
+ dispatch={dispatch}
+ rows={[
+ {
+ id,
+ pos: position,
+ },
+ ]}
+ source={source}
+ />
+ </div>
+ );
+}
+
+_TopicsWidget.defaultProps = {
+ windowObj: window, // Added to support unit tests
+};
+
+export const TopicsWidget = connect(state => ({
+ DiscoveryStream: state.DiscoveryStream,
+}))(_TopicsWidget);
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss
new file mode 100644
index 0000000000..d05d46cd07
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss
@@ -0,0 +1,88 @@
+.ds-topics-widget {
+ display: flex;
+ position: relative;
+ flex-direction: column;
+
+ .ds-topics-widget-header {
+ font-size: 18px;
+ line-height: 20px;
+ }
+
+ hr {
+ background-color: color-mix(in srgb, var(--newtab-border-color) 52%, transparent);
+ height: 1px;
+ border: 0;
+ margin: 10px 0 0;
+ }
+
+ .ds-topics-widget-list-container {
+ flex-grow: 1;
+
+ ul {
+ margin: 14px 0 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ grid-gap: 10px;
+ flex-wrap: wrap;
+
+ li {
+ display: flex;
+
+ a {
+ font-size: 14px;
+ line-height: 16px;
+ text-decoration: none;
+ padding: 8px 15px;
+ background: var(--newtab-background-color-secondary);
+ border: 1px solid color-mix(in srgb, var(--newtab-border-color) 52%, transparent);
+ color: var(--newtab-text-primary-color);
+ border-radius: 8px;
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ }
+
+ &:focus {
+ outline: 0;
+ box-shadow: 0 0 0 3px var(--newtab-primary-action-background-dimmed), 0 0 0 1px var(--newtab-primary-action-background);
+ transition: box-shadow 150ms;
+ }
+ }
+ }
+
+ .ds-topics-widget-list-overflow-item {
+ display: flex;
+
+ @media (min-width: $break-point-medium) {
+ display: none;
+ }
+
+ @media (min-width: $break-point-widest) {
+ display: flex;
+ }
+ }
+ }
+ }
+
+ .ds-topics-widget-button {
+ margin: 14px 0 0;
+ font-size: 16px;
+ line-height: 24px;
+ text-align: center;
+ padding: 8px;
+ border-radius: 4px;
+ background-color: var(--newtab-primary-action-background-pocket);
+ border: 0;
+
+ &:hover {
+ background: var(--newtab-primary-element-hover-pocket-color);
+ }
+
+ &:focus {
+ outline: 0;
+ box-shadow: 0 0 0 3px var(--newtab-primary-action-background-pocket-dimmed), 0 0 0 1px var(--newtab-primary-action-background-pocket);
+ transition: box-shadow 150ms;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
new file mode 100644
index 0000000000..b385c2ebf7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
@@ -0,0 +1,250 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { TOP_SITES_SOURCE } from "../TopSites/TopSitesConstants";
+import React from "react";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+// Per analytical requirement, we set the minimal intersection ratio to
+// 0.5, and an impression is identified when the wrapped item has at least
+// 50% visibility.
+//
+// This constant is exported for unit test
+export const INTERSECTION_RATIO = 0.5;
+
+/**
+ * Impression wrapper for Discovery Stream related React components.
+ *
+ * It makses use of the Intersection Observer API to detect the visibility,
+ * and relies on page visibility to ensure the impression is reported
+ * only when the component is visible on the page.
+ *
+ * Note:
+ * * This wrapper used to be used either at the individual card level,
+ * or by the card container components.
+ * It is now only used for individual card level.
+ * * Each impression will be sent only once as soon as the desired
+ * visibility is detected
+ * * Batching is not yet implemented, hence it might send multiple
+ * impression pings separately
+ */
+export class ImpressionStats extends React.PureComponent {
+ // This checks if the given cards are the same as those in the last impression ping.
+ // If so, it should not send the same impression ping again.
+ _needsImpressionStats(cards) {
+ if (
+ !this.impressionCardGuids ||
+ this.impressionCardGuids.length !== cards.length
+ ) {
+ return true;
+ }
+
+ for (let i = 0; i < cards.length; i++) {
+ if (cards[i].id !== this.impressionCardGuids[i]) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ _dispatchImpressionStats() {
+ const { props } = this;
+ const cards = props.rows;
+
+ if (this.props.flightId) {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_SPOC_IMPRESSION,
+ data: { flightId: this.props.flightId },
+ })
+ );
+
+ // Record sponsored topsites impressions if the source is `TOP_SITES_SOURCE`.
+ if (this.props.source === TOP_SITES_SOURCE) {
+ for (const card of cards) {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS,
+ data: {
+ type: "impression",
+ tile_id: card.id,
+ source: "newtab",
+ advertiser: card.advertiser,
+ // Keep the 0-based position, can be adjusted by the telemetry
+ // sender if necessary.
+ position: card.pos,
+ },
+ })
+ );
+ }
+ }
+ }
+
+ if (this._needsImpressionStats(cards)) {
+ props.dispatch(
+ ac.DiscoveryStreamImpressionStats({
+ source: props.source.toUpperCase(),
+ window_inner_width: window.innerWidth,
+ window_inner_height: window.innerHeight,
+ tiles: cards.map(link => ({
+ id: link.id,
+ pos: link.pos,
+ type: this.props.flightId ? "spoc" : "organic",
+ ...(link.shim ? { shim: link.shim } : {}),
+ })),
+ })
+ );
+ this.impressionCardGuids = cards.map(link => link.id);
+ }
+ }
+
+ // This checks if the given cards are the same as those in the last loaded content ping.
+ // If so, it should not send the same loaded content ping again.
+ _needsLoadedContent(cards) {
+ if (
+ !this.loadedContentGuids ||
+ this.loadedContentGuids.length !== cards.length
+ ) {
+ return true;
+ }
+
+ for (let i = 0; i < cards.length; i++) {
+ if (cards[i].id !== this.loadedContentGuids[i]) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ _dispatchLoadedContent() {
+ const { props } = this;
+ const cards = props.rows;
+
+ if (this._needsLoadedContent(cards)) {
+ props.dispatch(
+ ac.DiscoveryStreamLoadedContent({
+ source: props.source.toUpperCase(),
+ tiles: cards.map(link => ({ id: link.id, pos: link.pos })),
+ })
+ );
+ this.loadedContentGuids = cards.map(link => link.id);
+ }
+ }
+
+ setImpressionObserverOrAddListener() {
+ const { props } = this;
+
+ if (!props.dispatch) {
+ return;
+ }
+
+ if (props.document.visibilityState === VISIBLE) {
+ // Send the loaded content ping once the page is visible.
+ this._dispatchLoadedContent();
+ this.setImpressionObserver();
+ } else {
+ // We should only ever send the latest impression stats ping, so remove any
+ // older listeners.
+ if (this._onVisibilityChange) {
+ props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+
+ this._onVisibilityChange = () => {
+ if (props.document.visibilityState === VISIBLE) {
+ // Send the loaded content ping once the page is visible.
+ this._dispatchLoadedContent();
+ this.setImpressionObserver();
+ props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ };
+ props.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ /**
+ * Set an impression observer for the wrapped component. It makes use of
+ * the Intersection Observer API to detect if the wrapped component is
+ * visible with a desired ratio, and only sends impression if that's the case.
+ *
+ * See more details about Intersection Observer API at:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
+ */
+ setImpressionObserver() {
+ const { props } = this;
+
+ if (!props.rows.length) {
+ return;
+ }
+
+ this._handleIntersect = entries => {
+ if (
+ entries.some(
+ entry =>
+ entry.isIntersecting &&
+ entry.intersectionRatio >= INTERSECTION_RATIO
+ )
+ ) {
+ this._dispatchImpressionStats();
+ this.impressionObserver.unobserve(this.refs.impression);
+ }
+ };
+
+ const options = { threshold: INTERSECTION_RATIO };
+ this.impressionObserver = new props.IntersectionObserver(
+ this._handleIntersect,
+ options
+ );
+ this.impressionObserver.observe(this.refs.impression);
+ }
+
+ componentDidMount() {
+ if (this.props.rows.length) {
+ this.setImpressionObserverOrAddListener();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._handleIntersect && this.impressionObserver) {
+ this.impressionObserver.unobserve(this.refs.impression);
+ }
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ render() {
+ return (
+ <div ref={"impression"} className="impression-observer">
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+ImpressionStats.defaultProps = {
+ IntersectionObserver: global.IntersectionObserver,
+ document: global.document,
+ rows: [],
+ source: "",
+};
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss
new file mode 100644
index 0000000000..943e4e34a9
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss
@@ -0,0 +1,7 @@
+.impression-observer {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
diff --git a/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx b/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx
new file mode 100644
index 0000000000..1834a0a521
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx
@@ -0,0 +1,68 @@
+/* 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 { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton";
+import React from "react";
+
+export class ErrorBoundaryFallback extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.windowObj = this.props.windowObj || window;
+ this.onClick = this.onClick.bind(this);
+ }
+
+ /**
+ * Since we only get here if part of the page has crashed, do a
+ * forced reload to give us the best chance at recovering.
+ */
+ onClick() {
+ this.windowObj.location.reload(true);
+ }
+
+ render() {
+ const defaultClass = "as-error-fallback";
+ let className;
+ if ("className" in this.props) {
+ className = `${this.props.className} ${defaultClass}`;
+ } else {
+ className = defaultClass;
+ }
+
+ // "A11yLinkButton" to force normal link styling stuff (eg cursor on hover)
+ return (
+ <div className={className}>
+ <div data-l10n-id="newtab-error-fallback-info" />
+ <span>
+ <A11yLinkButton
+ className="reload-button"
+ onClick={this.onClick}
+ data-l10n-id="newtab-error-fallback-refresh-link"
+ />
+ </span>
+ </div>
+ );
+ }
+}
+ErrorBoundaryFallback.defaultProps = { className: "as-error-fallback" };
+
+export class ErrorBoundary extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ componentDidCatch(error, info) {
+ this.setState({ hasError: true });
+ }
+
+ render() {
+ if (!this.state.hasError) {
+ return this.props.children;
+ }
+
+ return <this.props.FallbackComponent className={this.props.className} />;
+ }
+}
+
+ErrorBoundary.defaultProps = { FallbackComponent: ErrorBoundaryFallback };
diff --git a/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss b/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss
new file mode 100644
index 0000000000..cc54f78a27
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss
@@ -0,0 +1,21 @@
+.as-error-fallback {
+ align-items: center;
+ border-radius: $border-radius;
+ box-shadow: inset $inner-box-shadow;
+ color: var(--newtab-text-secondary-color);
+ display: flex;
+ flex-direction: column;
+ font-size: $error-fallback-font-size;
+ justify-content: center;
+ justify-items: center;
+ line-height: $error-fallback-line-height;
+
+ &.borderless-error {
+ box-shadow: none;
+ }
+
+ a {
+ color: var(--newtab-text-secondary-color);
+ text-decoration: underline;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx b/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx
new file mode 100644
index 0000000000..583a5e4a01
--- /dev/null
+++ b/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx
@@ -0,0 +1,36 @@
+/* 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 React from "react";
+
+/**
+ * Set text on a child element/component depending on if the message is already
+ * translated plain text or a fluent id with optional args.
+ */
+export class FluentOrText extends React.PureComponent {
+ render() {
+ // Ensure we have a single child to attach attributes
+ const { children, message } = this.props;
+ const child = children ? React.Children.only(children) : <span />;
+
+ // For a string message, just use it as the child's text
+ let grandChildren = message;
+ let extraProps;
+
+ // Convert a message object to set desired fluent-dom attributes
+ if (typeof message === "object") {
+ const args = message.args || message.values;
+ extraProps = {
+ "data-l10n-args": args && JSON.stringify(args),
+ "data-l10n-id": message.id || message.string_id,
+ };
+
+ // Use original children potentially with data-l10n-name attributes
+ grandChildren = child.props.children;
+ }
+
+ // Add the message to the child via fluent attributes or text node
+ return React.cloneElement(child, extraProps, grandChildren);
+ }
+}
diff --git a/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx
new file mode 100644
index 0000000000..650a03eb95
--- /dev/null
+++ b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx
@@ -0,0 +1,110 @@
+/* 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 { actionCreators as ac } from "common/Actions.sys.mjs";
+import { connect } from "react-redux";
+import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu";
+import { LinkMenuOptions } from "content-src/lib/link-menu-options";
+import React from "react";
+
+const DEFAULT_SITE_MENU_OPTIONS = [
+ "CheckPinTopSite",
+ "EditTopSite",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+];
+
+export class _LinkMenu extends React.PureComponent {
+ getOptions() {
+ const { props } = this;
+ const {
+ site,
+ index,
+ source,
+ isPrivateBrowsingEnabled,
+ siteInfo,
+ platform,
+ userEvent = ac.UserEvent,
+ } = props;
+
+ // Handle special case of default site
+ const propOptions =
+ site.isDefault && !site.searchTopSite && !site.sponsored_position
+ ? DEFAULT_SITE_MENU_OPTIONS
+ : props.options;
+
+ const options = propOptions
+ .map(o =>
+ LinkMenuOptions[o](
+ site,
+ index,
+ source,
+ isPrivateBrowsingEnabled,
+ siteInfo,
+ platform
+ )
+ )
+ .map(option => {
+ const { action, impression, id, type, userEvent: eventName } = option;
+ if (!type && id) {
+ option.onClick = (event = {}) => {
+ const { ctrlKey, metaKey, shiftKey, button } = event;
+ // Only send along event info if there's something non-default to send
+ if (ctrlKey || metaKey || shiftKey || button === 1) {
+ action.data = Object.assign(
+ {
+ event: { ctrlKey, metaKey, shiftKey, button },
+ },
+ action.data
+ );
+ }
+ props.dispatch(action);
+ if (eventName) {
+ const userEventData = Object.assign(
+ {
+ event: eventName,
+ source,
+ action_position: index,
+ value: { card_type: site.flight_id ? "spoc" : "organic" },
+ },
+ siteInfo
+ );
+ props.dispatch(userEvent(userEventData));
+ }
+ if (impression && props.shouldSendImpressionStats) {
+ props.dispatch(impression);
+ }
+ };
+ }
+ return option;
+ });
+
+ // This is for accessibility to support making each item tabbable.
+ // We want to know which item is the first and which item
+ // is the last, so we can close the context menu accordingly.
+ options[0].first = true;
+ options[options.length - 1].last = true;
+ return options;
+ }
+
+ render() {
+ return (
+ <ContextMenu
+ onUpdate={this.props.onUpdate}
+ onShow={this.props.onShow}
+ options={this.getOptions()}
+ keyboardAccess={this.props.keyboardAccess}
+ />
+ );
+ }
+}
+
+const getState = state => ({
+ isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled,
+ platform: state.Prefs.values.platform,
+});
+export const LinkMenu = connect(getState)(_LinkMenu);
diff --git a/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx b/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx
new file mode 100644
index 0000000000..f2c332e5bd
--- /dev/null
+++ b/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx
@@ -0,0 +1,21 @@
+/* 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 React from "react";
+
+export class MoreRecommendations extends React.PureComponent {
+ render() {
+ const { read_more_endpoint } = this.props;
+ if (read_more_endpoint) {
+ return (
+ <a
+ className="more-recommendations"
+ href={read_more_endpoint}
+ data-l10n-id="newtab-pocket-more-recommendations"
+ />
+ );
+ }
+ return null;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss
new file mode 100644
index 0000000000..12a66b7c11
--- /dev/null
+++ b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss
@@ -0,0 +1,24 @@
+@use 'sass:math';
+
+.more-recommendations {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ line-height: math.div(16, 13); // (16 / 13) -> 16px computed
+
+ &::after {
+ background: url('chrome://global/skin/icons/arrow-right-12.svg') no-repeat center center;
+ content: '';
+ -moz-context-properties: fill;
+ display: inline-block;
+ fill: var(--newtab-primary-action-background);
+ height: 16px;
+ margin-inline-start: 5px;
+ vertical-align: top;
+ width: 12px;
+ }
+
+ &:dir(rtl)::after {
+ background-image: url('chrome://global/skin/icons/arrow-left-12.svg');
+ }
+}
diff --git a/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx b/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx
new file mode 100644
index 0000000000..53c22f319c
--- /dev/null
+++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx
@@ -0,0 +1,42 @@
+/* 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 { connect } from "react-redux";
+import React from "react";
+
+export class _PocketLoggedInCta extends React.PureComponent {
+ render() {
+ const { pocketCta } = this.props.Pocket;
+ return (
+ <span className="pocket-logged-in-cta">
+ <a
+ className="pocket-cta-button"
+ href={pocketCta.ctaUrl ? pocketCta.ctaUrl : "https://getpocket.com/"}
+ >
+ {pocketCta.ctaButton ? (
+ pocketCta.ctaButton
+ ) : (
+ <span data-l10n-id="newtab-pocket-cta-button" />
+ )}
+ </a>
+
+ <a
+ href={pocketCta.ctaUrl ? pocketCta.ctaUrl : "https://getpocket.com/"}
+ >
+ <span className="cta-text">
+ {pocketCta.ctaText ? (
+ pocketCta.ctaText
+ ) : (
+ <span data-l10n-id="newtab-pocket-cta-text" />
+ )}
+ </span>
+ </a>
+ </span>
+ );
+ }
+}
+
+export const PocketLoggedInCta = connect(state => ({ Pocket: state.Pocket }))(
+ _PocketLoggedInCta
+);
diff --git a/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss
new file mode 100644
index 0000000000..e1eed58e9c
--- /dev/null
+++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss
@@ -0,0 +1,42 @@
+@use 'sass:math';
+
+.pocket-logged-in-cta {
+ $max-button-width: 130px;
+ $min-button-height: 18px;
+
+ font-size: 13px;
+ margin-inline-end: 20px;
+ display: flex;
+ align-items: flex-start;
+
+ .pocket-cta-button {
+ white-space: nowrap;
+ background: var(--newtab-primary-action-background);
+ letter-spacing: -0.34px;
+ color: $white;
+ border-radius: 4px;
+ cursor: pointer;
+ max-width: $max-button-width;
+ // The button height is 2px taller than the rest of the cta text.
+ // So I move it up by 1px to align with the rest of the cta text.
+ margin-top: -1px;
+ min-height: $min-button-height;
+ padding: 0 8px;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 11px;
+ margin-inline-end: 10px;
+ }
+
+ .cta-text {
+ font-weight: normal;
+ font-size: 13px;
+ line-height: math.div(16, 13); // (16 / 13) -> 16px computed
+ }
+
+ .pocket-cta-button,
+ .cta-text {
+ vertical-align: top;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/Search/Search.jsx b/browser/components/newtab/content-src/components/Search/Search.jsx
new file mode 100644
index 0000000000..b131c884c1
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Search/Search.jsx
@@ -0,0 +1,223 @@
+/* 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/. */
+
+/* globals ContentSearchUIController, ContentSearchHandoffUIController */
+"use strict";
+
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { connect } from "react-redux";
+import { IS_NEWTAB } from "content-src/lib/constants";
+import React from "react";
+
+export class _Search extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onSearchClick = this.onSearchClick.bind(this);
+ this.onSearchHandoffClick = this.onSearchHandoffClick.bind(this);
+ this.onSearchHandoffPaste = this.onSearchHandoffPaste.bind(this);
+ this.onSearchHandoffDrop = this.onSearchHandoffDrop.bind(this);
+ this.onInputMount = this.onInputMount.bind(this);
+ this.onInputMountHandoff = this.onInputMountHandoff.bind(this);
+ this.onSearchHandoffButtonMount =
+ this.onSearchHandoffButtonMount.bind(this);
+ }
+
+ handleEvent(event) {
+ // Also track search events with our own telemetry
+ if (event.detail.type === "Search") {
+ this.props.dispatch(ac.UserEvent({ event: "SEARCH" }));
+ }
+ }
+
+ onSearchClick(event) {
+ window.gContentSearchController.search(event);
+ }
+
+ doSearchHandoff(text) {
+ this.props.dispatch(
+ ac.OnlyToMain({ type: at.HANDOFF_SEARCH_TO_AWESOMEBAR, data: { text } })
+ );
+ this.props.dispatch({ type: at.FAKE_FOCUS_SEARCH });
+ this.props.dispatch(ac.UserEvent({ event: "SEARCH_HANDOFF" }));
+ if (text) {
+ this.props.dispatch({ type: at.DISABLE_SEARCH });
+ }
+ }
+
+ onSearchHandoffClick(event) {
+ // When search hand-off is enabled, we render a big button that is styled to
+ // look like a search textbox. If the button is clicked, we style
+ // the button as if it was a focused search box and show a fake cursor but
+ // really focus the awesomebar without the focus styles ("hidden focus").
+ event.preventDefault();
+ this.doSearchHandoff();
+ }
+
+ onSearchHandoffPaste(event) {
+ event.preventDefault();
+ this.doSearchHandoff(event.clipboardData.getData("Text"));
+ }
+
+ onSearchHandoffDrop(event) {
+ event.preventDefault();
+ let text = event.dataTransfer.getData("text");
+ if (text) {
+ this.doSearchHandoff(text);
+ }
+ }
+
+ componentWillUnmount() {
+ delete window.gContentSearchController;
+ }
+
+ onInputMount(input) {
+ if (input) {
+ // The "healthReportKey" and needs to be "newtab" or "abouthome" so that
+ // BrowserUsageTelemetry.jsm knows to handle events with this name, and
+ // can add the appropriate telemetry probes for search. Without the correct
+ // name, certain tests like browser_UsageTelemetry_content.js will fail
+ // (See github ticket #2348 for more details)
+ const healthReportKey = IS_NEWTAB ? "newtab" : "abouthome";
+
+ // The "searchSource" needs to be "newtab" or "homepage" and is sent with
+ // the search data and acts as context for the search request (See
+ // nsISearchEngine.getSubmission). It is necessary so that search engine
+ // plugins can correctly atribute referrals. (See github ticket #3321 for
+ // more details)
+ const searchSource = IS_NEWTAB ? "newtab" : "homepage";
+
+ // gContentSearchController needs to exist as a global so that tests for
+ // the existing about:home can find it; and so it allows these tests to pass.
+ // In the future, when activity stream is default about:home, this can be renamed
+ window.gContentSearchController = new ContentSearchUIController(
+ input,
+ input.parentNode,
+ healthReportKey,
+ searchSource
+ );
+ addEventListener("ContentSearchClient", this);
+ } else {
+ window.gContentSearchController = null;
+ removeEventListener("ContentSearchClient", this);
+ }
+ }
+
+ onInputMountHandoff(input) {
+ if (input) {
+ // The handoff UI controller helps us set the search icon and reacts to
+ // changes to default engine to keep everything in sync.
+ this._handoffSearchController = new ContentSearchHandoffUIController();
+ }
+ }
+
+ getDefaultEngineName() {
+ // _handoffSearchController will manage engine names once it is initialized.
+ return this.props.Prefs.values["urlbar.placeholderName"];
+ }
+
+ getHandoffInputL10nAttributes() {
+ let defaultEngineName = this.getDefaultEngineName();
+ return defaultEngineName
+ ? {
+ "data-l10n-id": "newtab-search-box-handoff-input",
+ "data-l10n-args": `{"engine": "${defaultEngineName}"}`,
+ }
+ : {
+ "data-l10n-id": "newtab-search-box-handoff-input-no-engine",
+ };
+ }
+
+ getHandoffTextL10nAttributes() {
+ let defaultEngineName = this.getDefaultEngineName();
+ return defaultEngineName
+ ? {
+ "data-l10n-id": "newtab-search-box-handoff-text",
+ "data-l10n-args": `{"engine": "${defaultEngineName}"}`,
+ }
+ : {
+ "data-l10n-id": "newtab-search-box-handoff-text-no-engine",
+ };
+ }
+
+ onSearchHandoffButtonMount(button) {
+ // Keep a reference to the button for use during "paste" event handling.
+ this._searchHandoffButton = button;
+ }
+
+ /*
+ * Do not change the ID on the input field, as legacy newtab code
+ * specifically looks for the id 'newtab-search-text' on input fields
+ * in order to execute searches in various tests
+ */
+ render() {
+ const wrapperClassName = [
+ "search-wrapper",
+ this.props.disable && "search-disabled",
+ this.props.fakeFocus && "fake-focus",
+ ]
+ .filter(v => v)
+ .join(" ");
+
+ return (
+ <div className={wrapperClassName}>
+ {this.props.showLogo && (
+ <div className="logo-and-wordmark">
+ <div className="logo" />
+ <div className="wordmark" />
+ </div>
+ )}
+ {!this.props.handoffEnabled && (
+ <div className="search-inner-wrapper">
+ <input
+ id="newtab-search-text"
+ data-l10n-id="newtab-search-box-input"
+ maxLength="256"
+ ref={this.onInputMount}
+ type="search"
+ />
+ <button
+ id="searchSubmit"
+ className="search-button"
+ data-l10n-id="newtab-search-box-search-button"
+ onClick={this.onSearchClick}
+ />
+ </div>
+ )}
+ {this.props.handoffEnabled && (
+ <div className="search-inner-wrapper">
+ <button
+ className="search-handoff-button"
+ {...this.getHandoffInputL10nAttributes()}
+ ref={this.onSearchHandoffButtonMount}
+ onClick={this.onSearchHandoffClick}
+ tabIndex="-1"
+ >
+ <div
+ className="fake-textbox"
+ {...this.getHandoffTextL10nAttributes()}
+ />
+ <input
+ type="search"
+ className="fake-editable"
+ tabIndex="-1"
+ aria-hidden="true"
+ onDrop={this.onSearchHandoffDrop}
+ onPaste={this.onSearchHandoffPaste}
+ ref={this.onInputMountHandoff}
+ />
+ <div className="fake-caret" />
+ </button>
+ </div>
+ )}
+ </div>
+ );
+ }
+}
+
+export const Search = connect(state => ({
+ Prefs: state.Prefs,
+}))(_Search);
diff --git a/browser/components/newtab/content-src/components/Search/_Search.scss b/browser/components/newtab/content-src/components/Search/_Search.scss
new file mode 100644
index 0000000000..189198a16c
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Search/_Search.scss
@@ -0,0 +1,412 @@
+$search-height: 48px;
+$search-height-new: 52px;
+$search-icon-size: 24px;
+$search-icon-padding: 16px;
+$search-icon-width: 2 * $search-icon-padding + $search-icon-size - 4px;
+$search-button-width: 48px;
+$glyph-forward: url('chrome://browser/skin/forward.svg');
+
+.search-wrapper {
+ padding: 34px 0 38px;
+
+ .only-search & {
+ padding: 0 0 38px;
+ }
+
+ .logo-and-wordmark {
+ $logo-size: 82px;
+ $wordmark-size: 134px;
+
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ margin-bottom: 48px;
+
+ .logo {
+ display: inline-block;
+ height: $logo-size;
+ width: $logo-size;
+ background: url('chrome://branding/content/about-logo.png') no-repeat center;
+ background-size: $logo-size;
+
+ @media (min-resolution: 2x) {
+ background-image: url('chrome://branding/content/about-logo@2x.png');
+ }
+ }
+
+ .wordmark {
+ background: url('chrome://branding/content/firefox-wordmark.svg') no-repeat center center;
+ background-size: $wordmark-size;
+ -moz-context-properties: fill;
+ display: inline-block;
+ fill: var(--newtab-wordmark-color);
+ height: $logo-size;
+ margin-inline-start: 16px;
+ width: $wordmark-size;
+ }
+
+ @media (max-width: $break-point-medium - 1) {
+ $logo-size-small: 64px;
+ $wordmark-small-size: 100px;
+
+ .logo {
+ background-size: $logo-size-small;
+ height: $logo-size-small;
+ width: $logo-size-small;
+ }
+
+ .wordmark {
+ background-size: $wordmark-small-size;
+ height: $logo-size-small;
+ width: $wordmark-small-size;
+ margin-inline-start: 12px;
+ }
+ }
+ }
+
+ .search-inner-wrapper {
+ cursor: default;
+ display: flex;
+ min-height: $search-height-new;
+ margin: 0 auto;
+ position: relative;
+ width: $searchbar-width-small;
+
+ @media (min-width: $break-point-medium) {
+ width: $searchbar-width-medium;
+ }
+
+ @media (min-width: $break-point-large) {
+ width: $searchbar-width-large;
+ }
+
+ @media (min-width: $break-point-widest) {
+ width: $searchbar-width-largest;
+ }
+ }
+
+ .search-handoff-button,
+ input {
+ background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat;
+ background-size: $search-icon-size;
+ padding-inline-start: $search-icon-width;
+ padding-inline-end: 10px;
+ padding-block: 0;
+ width: 100%;
+ box-shadow: $shadow-card;
+ border: 1px solid transparent;
+ border-radius: 8px;
+ color: var(--newtab-text-primary-color);
+ -moz-context-properties: fill;
+ fill: var(--newtab-text-secondary-color);
+
+ &:dir(rtl) {
+ background-position-x: right $search-icon-padding;
+ }
+ }
+
+ .search-inner-wrapper:active input,
+ input:focus {
+ border: 1px solid var(--newtab-primary-action-background);
+ outline: 0;
+ box-shadow: $shadow-focus;
+ }
+
+ .search-button {
+ background: $glyph-forward no-repeat center center;
+ background-size: 16px 16px;
+ border: 0;
+ border-radius: 0 $border-radius $border-radius 0;
+ -moz-context-properties: fill;
+ fill: var(--newtab-text-secondary-color);
+ height: 100%;
+ inset-inline-end: 0;
+ position: absolute;
+ width: $search-button-width;
+
+ &:focus,
+ &:hover {
+ background-color: var(--newtab-element-hover-color);
+ cursor: pointer;
+ }
+
+ &:focus {
+ outline: 0;
+ box-shadow: $shadow-focus;
+ border: 1px solid var(--newtab-primary-action-background);
+ border-radius: 0 $border-radius-new $border-radius-new 0;
+ }
+
+ &:active {
+ background-color: var(--newtab-element-hover-color);
+ }
+
+ &:dir(rtl) {
+ transform: scaleX(-1);
+ }
+ }
+
+ &.fake-focus:not(.search.disabled) {
+ .search-handoff-button {
+ border: 1px solid var(--newtab-primary-action-background);
+ box-shadow: $shadow-focus;
+ }
+ }
+
+ .search-handoff-button {
+ padding-inline-end: 15px;
+ color: var(--newtab-text-primary-color);
+ fill: var(--newtab-text-secondary-color);
+ -moz-context-properties: fill;
+
+ .fake-caret {
+ top: 18px;
+ inset-inline-start: $search-icon-width;
+
+ &:dir(rtl) {
+ background-position-x: right $search-icon-padding;
+ }
+ }
+ }
+
+ &.visible-logo {
+ .logo-and-wordmark {
+ .wordmark {
+ fill: var(--newtab-wordmark-color);
+ }
+ }
+ }
+}
+
+.non-collapsible-section + .below-search-snippet-wrapper {
+ // If search is enabled, we need to invade its large bottom padding.
+ margin-top: -48px;
+}
+
+@media (max-height: 700px) {
+ .search-wrapper {
+ padding: 0 0 30px;
+ }
+
+ .non-collapsible-section + .below-search-snippet-wrapper {
+ // In shorter windows, search doesn't have such a large padding.
+ margin-top: -14px;
+ }
+
+ .below-search-snippet-wrapper {
+ min-height: 0;
+ }
+}
+
+.search-handoff-button {
+ background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat;
+ background-size: $search-icon-size;
+ border: solid 1px transparent;
+ border-radius: 3px;
+ box-shadow: $shadow-secondary, 0 0 0 1px $black-15;
+ cursor: text;
+ font-size: 15px;
+ padding: 0;
+ padding-inline-end: 48px;
+ padding-inline-start: 46px;
+ opacity: 1;
+ width: 100%;
+
+ &:dir(rtl) {
+ background-position-x: right $search-icon-padding;
+ }
+
+ .fake-focus:not(.search-disabled) & {
+ border: $input-border-active;
+ box-shadow: var(--newtab-textbox-focus-boxshadow);
+
+ .fake-caret {
+ display: block;
+ }
+ }
+
+ .search-disabled & {
+ opacity: 0.5;
+ box-shadow: none;
+ }
+
+ .fake-editable:focus {
+ outline: none;
+ caret-color: transparent;
+ }
+
+ .fake-editable {
+ color: transparent;
+ height: 100%;
+ opacity: 0;
+ position: absolute;
+ inset: 0;
+ }
+
+ .fake-textbox {
+ opacity: 0.54;
+ text-align: start;
+ }
+
+ .fake-caret {
+ animation: caret-animation 1.3s steps(5, start) infinite;
+ background: var(--newtab-text-primary-color);
+ display: none;
+ inset-inline-start: 47px;
+ height: 17px;
+ position: absolute;
+ top: 16px;
+ width: 1px;
+
+ @keyframes caret-animation {
+ to {
+ visibility: hidden;
+ }
+ }
+ }
+}
+
+@media (min-height: 701px) {
+ body:not(.inline-onboarding) .fixed-search {
+ main {
+ padding-top: 124px;
+ }
+
+ &.visible-logo {
+ main {
+ padding-top: 254px;
+ }
+ }
+
+ .search-wrapper {
+ $search-height: 45px;
+ $search-icon-size: 24px;
+ $search-icon-padding: 16px;
+ $search-header-bar-height: 95px;
+
+ border-bottom: solid 1px var(--newtab-border-color);
+ padding: 27px 0;
+ background-color: var(--newtab-overlay-color);
+ min-height: $search-header-bar-height;
+ left: 0;
+ position: fixed;
+ top: 0;
+ width: 100%;
+ z-index: 9;
+
+ .search-inner-wrapper {
+ min-height: $search-height;
+ }
+
+ input {
+ background-position-x: $search-icon-padding;
+ background-size: $search-icon-size;
+
+ &:dir(rtl) {
+ background-position-x: right $search-icon-padding;
+ }
+ }
+
+ .search-handoff-button .fake-caret {
+ top: 14px;
+ }
+
+ .logo-and-wordmark {
+ display: none;
+ }
+ }
+
+ .search-handoff-button {
+ background-position-x: $search-icon-padding;
+ background-size: $search-icon-size;
+
+ &:dir(rtl) {
+ background-position-x: right $search-icon-padding;
+ }
+
+ .fake-caret {
+ top: 10px;
+ }
+ }
+ }
+}
+
+@at-root {
+ // Adjust the style of the contentSearchUI-generated table
+ .contentSearchSuggestionTable {
+ border: 0;
+ box-shadow: $context-menu-shadow;
+ transform: translateY($textbox-shadow-size);
+ background-color: var(--newtab-background-color);
+
+ .contentSearchHeader {
+ color: var(--newtab-text-secondary-color);
+ background-color: var(--newtab-background-color-secondary);
+ }
+
+ .contentSearchHeader,
+ .contentSearchSettingsButton {
+ border-color: var(--newtab-border-color);
+ }
+
+ .contentSearchSuggestionsList {
+ color: var(--newtab-text-primary-color);
+ border: 0;
+ }
+
+ .contentSearchOneOffsTable {
+ border-top: solid 1px var(--newtab-border-color);
+ background-color: var(--newtab-background-color);
+ }
+
+ .contentSearchSearchWithHeaderSearchText {
+ color: var(--newtab-text-primary-color);
+ }
+
+ .contentSearchSuggestionRow {
+ &.selected {
+ background: var(--newtab-element-hover-color);
+ color: var(--newtab-text-primary-color);
+
+ &:active {
+ background: var(--newtab-element-active-color);
+ }
+
+ .historyIcon {
+ fill: var(--newtab-text-secondary-color);
+ }
+ }
+ }
+
+ .contentSearchOneOffItem {
+ // Make the border slightly shorter by offsetting from the top and bottom
+ $border-offset: 18%;
+
+ background-image: none;
+ border-image: linear-gradient(transparent $border-offset, var(--newtab-border-color) $border-offset, var(--newtab-border-color) 100% - $border-offset, transparent 100% - $border-offset) 1;
+ border-inline-end: 1px solid;
+ position: relative;
+
+ &.selected {
+ background: var(--newtab-element-hover-color);
+ }
+
+ &:active {
+ background: var(--newtab-element-active-color);
+ }
+ }
+
+ .contentSearchSettingsButton {
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ color: var(--newtab-text-primary-color);
+ }
+ }
+ }
+
+ .contentSearchHeaderRow > td > img,
+ .contentSearchSuggestionRow > td > .historyIcon {
+ margin-inline-start: 7px;
+ margin-inline-end: 15px;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/Sections/Sections.jsx b/browser/components/newtab/content-src/components/Sections/Sections.jsx
new file mode 100644
index 0000000000..e72e9145ad
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Sections/Sections.jsx
@@ -0,0 +1,378 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { Card, PlaceholderCard } from "content-src/components/Card/Card";
+import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
+import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+import { connect } from "react-redux";
+import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations";
+import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta";
+import React from "react";
+import { Topics } from "content-src/components/Topics/Topics";
+import { TopSites } from "content-src/components/TopSites/TopSites";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+const CARDS_PER_ROW_DEFAULT = 3;
+const CARDS_PER_ROW_COMPACT_WIDE = 4;
+
+export class Section extends React.PureComponent {
+ get numRows() {
+ const { rowsPref, maxRows, Prefs } = this.props;
+ return rowsPref ? Prefs.values[rowsPref] : maxRows;
+ }
+
+ _dispatchImpressionStats() {
+ const { props } = this;
+ let cardsPerRow = CARDS_PER_ROW_DEFAULT;
+ if (
+ props.compactCards &&
+ global.matchMedia(`(min-width: 1072px)`).matches
+ ) {
+ // If the section has compact cards and the viewport is wide enough, we show
+ // 4 columns instead of 3.
+ // $break-point-widest = 1072px (from _variables.scss)
+ cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE;
+ }
+ const maxCards = cardsPerRow * this.numRows;
+ const cards = props.rows.slice(0, maxCards);
+
+ if (this.needsImpressionStats(cards)) {
+ props.dispatch(
+ ac.ImpressionStats({
+ source: props.eventSource,
+ tiles: cards.map(link => ({ id: link.guid })),
+ })
+ );
+ this.impressionCardGuids = cards.map(link => link.guid);
+ }
+ }
+
+ // This sends an event when a user sees a set of new content. If content
+ // changes while the page is hidden (i.e. preloaded or on a hidden tab),
+ // only send the event if the page becomes visible again.
+ sendImpressionStatsOrAddListener() {
+ const { props } = this;
+
+ if (!props.shouldSendImpressionStats || !props.dispatch) {
+ return;
+ }
+
+ if (props.document.visibilityState === VISIBLE) {
+ this._dispatchImpressionStats();
+ } else {
+ // We should only ever send the latest impression stats ping, so remove any
+ // older listeners.
+ if (this._onVisibilityChange) {
+ props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+
+ // When the page becomes visible, send the impression stats ping if the section isn't collapsed.
+ this._onVisibilityChange = () => {
+ if (props.document.visibilityState === VISIBLE) {
+ if (!this.props.pref.collapsed) {
+ this._dispatchImpressionStats();
+ }
+ props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ };
+ props.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ componentWillMount() {
+ this.sendNewTabRehydrated(this.props.initialized);
+ }
+
+ componentDidMount() {
+ if (this.props.rows.length && !this.props.pref.collapsed) {
+ this.sendImpressionStatsOrAddListener();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const { props } = this;
+ const isCollapsed = props.pref.collapsed;
+ const wasCollapsed = prevProps.pref.collapsed;
+ if (
+ // Don't send impression stats for the empty state
+ props.rows.length &&
+ // We only want to send impression stats if the content of the cards has changed
+ // and the section is not collapsed...
+ ((props.rows !== prevProps.rows && !isCollapsed) ||
+ // or if we are expanding a section that was collapsed.
+ (wasCollapsed && !isCollapsed))
+ ) {
+ this.sendImpressionStatsOrAddListener();
+ }
+ }
+
+ componentWillUpdate(nextProps) {
+ this.sendNewTabRehydrated(nextProps.initialized);
+ }
+
+ componentWillUnmount() {
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ needsImpressionStats(cards) {
+ if (
+ !this.impressionCardGuids ||
+ this.impressionCardGuids.length !== cards.length
+ ) {
+ return true;
+ }
+
+ for (let i = 0; i < cards.length; i++) {
+ if (cards[i].guid !== this.impressionCardGuids[i]) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // The NEW_TAB_REHYDRATED event is used to inform feeds that their
+ // data has been consumed e.g. for counting the number of tabs that
+ // have rendered that data.
+ sendNewTabRehydrated(initialized) {
+ if (initialized && !this.renderNotified) {
+ this.props.dispatch(
+ ac.AlsoToMain({ type: at.NEW_TAB_REHYDRATED, data: {} })
+ );
+ this.renderNotified = true;
+ }
+ }
+
+ render() {
+ const {
+ id,
+ eventSource,
+ title,
+ rows,
+ Pocket,
+ topics,
+ emptyState,
+ dispatch,
+ compactCards,
+ read_more_endpoint,
+ contextMenuOptions,
+ initialized,
+ learnMore,
+ pref,
+ privacyNoticeURL,
+ isFirst,
+ isLast,
+ } = this.props;
+
+ const waitingForSpoc =
+ id === "topstories" && this.props.Pocket.waitingForSpoc;
+ const maxCardsPerRow = compactCards
+ ? CARDS_PER_ROW_COMPACT_WIDE
+ : CARDS_PER_ROW_DEFAULT;
+ const { numRows } = this;
+ const maxCards = maxCardsPerRow * numRows;
+ const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows;
+
+ const { pocketCta, isUserLoggedIn } = Pocket || {};
+ const { useCta } = pocketCta || {};
+
+ // Don't display anything until we have a definitve result from Pocket,
+ // to avoid a flash of logged out state while we render.
+ const isPocketLoggedInDefined =
+ isUserLoggedIn === true || isUserLoggedIn === false;
+
+ const hasTopics = topics && !!topics.length;
+
+ const shouldShowPocketCta =
+ id === "topstories" && useCta && isUserLoggedIn === false;
+
+ // Show topics only for top stories and if it has loaded with topics.
+ // The classs .top-stories-bottom-container ensures content doesn't shift as things load.
+ const shouldShowTopics =
+ id === "topstories" &&
+ hasTopics &&
+ ((useCta && isUserLoggedIn === true) ||
+ (!useCta && isPocketLoggedInDefined));
+
+ // We use topics to determine language support for read more.
+ const shouldShowReadMore = read_more_endpoint && hasTopics;
+
+ const realRows = rows.slice(0, maxCards);
+
+ // The empty state should only be shown after we have initialized and there is no content.
+ // Otherwise, we should show placeholders.
+ const shouldShowEmptyState = initialized && !rows.length;
+
+ const cards = [];
+ if (!shouldShowEmptyState) {
+ for (let i = 0; i < maxCards; i++) {
+ const link = realRows[i];
+ // On narrow viewports, we only show 3 cards per row. We'll mark the rest as
+ // .hide-for-narrow to hide in CSS via @media query.
+ const className = i >= maxCardsOnNarrow ? "hide-for-narrow" : "";
+ let usePlaceholder = !link;
+ // If we are in the third card and waiting for spoc,
+ // use the placeholder.
+ if (!usePlaceholder && i === 2 && waitingForSpoc) {
+ usePlaceholder = true;
+ }
+ cards.push(
+ !usePlaceholder ? (
+ <Card
+ key={i}
+ index={i}
+ className={className}
+ dispatch={dispatch}
+ link={link}
+ contextMenuOptions={contextMenuOptions}
+ eventSource={eventSource}
+ shouldSendImpressionStats={this.props.shouldSendImpressionStats}
+ isWebExtension={this.props.isWebExtension}
+ />
+ ) : (
+ <PlaceholderCard key={i} className={className} />
+ )
+ );
+ }
+ }
+
+ const sectionClassName = [
+ "section",
+ compactCards ? "compact-cards" : "normal-cards",
+ ].join(" ");
+
+ // <Section> <-- React component
+ // <section> <-- HTML5 element
+ return (
+ <ComponentPerfTimer {...this.props}>
+ <CollapsibleSection
+ className={sectionClassName}
+ title={title}
+ id={id}
+ eventSource={eventSource}
+ collapsed={this.props.pref.collapsed}
+ showPrefName={(pref && pref.feed) || id}
+ privacyNoticeURL={privacyNoticeURL}
+ Prefs={this.props.Prefs}
+ isFixed={this.props.isFixed}
+ isFirst={isFirst}
+ isLast={isLast}
+ learnMore={learnMore}
+ dispatch={this.props.dispatch}
+ isWebExtension={this.props.isWebExtension}
+ >
+ {!shouldShowEmptyState && (
+ <ul className="section-list" style={{ padding: 0 }}>
+ {cards}
+ </ul>
+ )}
+ {shouldShowEmptyState && (
+ <div className="section-empty-state">
+ <div className="empty-state">
+ <FluentOrText message={emptyState.message}>
+ <p className="empty-state-message" />
+ </FluentOrText>
+ </div>
+ </div>
+ )}
+ {id === "topstories" && (
+ <div className="top-stories-bottom-container">
+ {shouldShowTopics && (
+ <div className="wrapper-topics">
+ <Topics topics={this.props.topics} />
+ </div>
+ )}
+
+ {shouldShowPocketCta && (
+ <div className="wrapper-cta">
+ <PocketLoggedInCta />
+ </div>
+ )}
+
+ <div className="wrapper-more-recommendations">
+ {shouldShowReadMore && (
+ <MoreRecommendations
+ read_more_endpoint={read_more_endpoint}
+ />
+ )}
+ </div>
+ </div>
+ )}
+ </CollapsibleSection>
+ </ComponentPerfTimer>
+ );
+ }
+}
+
+Section.defaultProps = {
+ document: global.document,
+ rows: [],
+ emptyState: {},
+ pref: {},
+ title: "",
+};
+
+export const SectionIntl = connect(state => ({
+ Prefs: state.Prefs,
+ Pocket: state.Pocket,
+}))(Section);
+
+export class _Sections extends React.PureComponent {
+ renderSections() {
+ const sections = [];
+ const enabledSections = this.props.Sections.filter(
+ section => section.enabled
+ );
+ const { sectionOrder, "feeds.topsites": showTopSites } =
+ this.props.Prefs.values;
+ // Enabled sections doesn't include Top Sites, so we add it if enabled.
+ const expectedCount = enabledSections.length + ~~showTopSites;
+
+ for (const sectionId of sectionOrder.split(",")) {
+ const commonProps = {
+ key: sectionId,
+ isFirst: sections.length === 0,
+ isLast: sections.length === expectedCount - 1,
+ };
+ if (sectionId === "topsites" && showTopSites) {
+ sections.push(<TopSites {...commonProps} />);
+ } else {
+ const section = enabledSections.find(s => s.id === sectionId);
+ if (section) {
+ sections.push(<SectionIntl {...section} {...commonProps} />);
+ }
+ }
+ }
+ return sections;
+ }
+
+ render() {
+ return <div className="sections-list">{this.renderSections()}</div>;
+ }
+}
+
+export const Sections = connect(state => ({
+ Sections: state.Sections,
+ Prefs: state.Prefs,
+}))(_Sections);
diff --git a/browser/components/newtab/content-src/components/Sections/_Sections.scss b/browser/components/newtab/content-src/components/Sections/_Sections.scss
new file mode 100644
index 0000000000..e3fe15f762
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Sections/_Sections.scss
@@ -0,0 +1,123 @@
+.sections-list {
+ .section-list {
+ display: grid;
+ grid-gap: $base-gutter;
+ grid-template-columns: repeat(auto-fit, $card-width);
+ margin: 0;
+
+ @media (max-width: $break-point-medium) {
+ @include context-menu-open-left;
+ }
+
+ @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+ :nth-child(2n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
+ :nth-child(3n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
+ // 3n for normal cards, 4n for compact cards
+ :nth-child(3n),
+ :nth-child(4n) {
+ @include context-menu-open-left;
+ }
+ }
+ }
+
+ .section-empty-state {
+ border: $border-secondary;
+ border-radius: $border-radius;
+ display: flex;
+ height: $card-height;
+ width: 100%;
+
+ .empty-state {
+ margin: auto;
+ max-width: 350px;
+
+ .empty-state-message {
+ color: var(--newtab-text-primary-color);
+ font-size: 13px;
+ margin-bottom: 0;
+ text-align: center;
+ }
+ }
+
+ @media (min-width: $break-point-widest) {
+ height: $card-height-large;
+ }
+ }
+}
+
+.top-stories-bottom-container {
+ color: var(--newtab-text-primary-color);
+ font-size: 12px;
+ line-height: 1.6;
+ margin-top: $topic-margin-top;
+ display: flex;
+ justify-content: space-between;
+
+ a {
+ color: var(--newtab-primary-action-background);
+ font-weight: bold;
+
+ &.more-recommendations {
+ font-weight: normal;
+ font-size: 13px;
+ }
+ }
+
+ .wrapper-topics,
+ .wrapper-cta + .wrapper-more-recommendations {
+ @media (max-width: $break-point-large - 1) {
+ display: none;
+ }
+ }
+
+ @media (max-width: $break-point-medium - 1) {
+ .wrapper-cta {
+ text-align: center;
+
+ .pocket-logged-in-cta {
+ display: block;
+ margin-inline-end: 0;
+
+ .pocket-cta-button {
+ max-width: none;
+ display: block;
+ margin-inline-end: 0;
+ margin: 5px 0 10px;
+ }
+ }
+ }
+
+ .wrapper-more-recommendations {
+ width: 100%;
+
+ .more-recommendations {
+ justify-content: center;
+
+ &::after {
+ display: none;
+ }
+ }
+ }
+ }
+}
+
+@media (min-width: $break-point-widest) {
+ .sections-list {
+ // Compact cards stay the same size but normal cards get bigger.
+ .normal-cards {
+ .section-list {
+ grid-template-columns: repeat(auto-fit, $card-width-large);
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx
new file mode 100644
index 0000000000..4324c019f6
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx
@@ -0,0 +1,192 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import React from "react";
+import { TOP_SITES_SOURCE } from "./TopSitesConstants";
+
+export class SelectableSearchShortcut extends React.PureComponent {
+ render() {
+ const { shortcut, selected } = this.props;
+ const imageStyle = { backgroundImage: `url("${shortcut.tippyTopIcon}")` };
+ return (
+ <div className="top-site-outer search-shortcut">
+ <input
+ type="checkbox"
+ id={shortcut.keyword}
+ name={shortcut.keyword}
+ checked={selected}
+ onChange={this.props.onChange}
+ />
+ <label htmlFor={shortcut.keyword}>
+ <div className="top-site-inner">
+ <span>
+ <div className="tile">
+ <div
+ className="top-site-icon rich-icon"
+ style={imageStyle}
+ data-fallback="@"
+ />
+ <div className="top-site-icon search-topsite" />
+ </div>
+ <div className="title">
+ <span dir="auto">{shortcut.keyword}</span>
+ </div>
+ </span>
+ </div>
+ </label>
+ </div>
+ );
+ }
+}
+
+export class SearchShortcutsForm extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleChange = this.handleChange.bind(this);
+ this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
+ this.onSaveButtonClick = this.onSaveButtonClick.bind(this);
+
+ // clone the shortcuts and add them to the state so we can add isSelected property
+ const shortcuts = [];
+ const { rows, searchShortcuts } = props.TopSites;
+ searchShortcuts.forEach(shortcut => {
+ shortcuts.push({
+ ...shortcut,
+ isSelected: !!rows.find(
+ row =>
+ row &&
+ row.isPinned &&
+ row.searchTopSite &&
+ row.label === shortcut.keyword
+ ),
+ });
+ });
+ this.state = { shortcuts };
+ }
+
+ handleChange(event) {
+ const { target } = event;
+ const { name, checked } = target;
+ this.setState(prevState => {
+ const shortcuts = prevState.shortcuts.slice();
+ let shortcut = shortcuts.find(({ keyword }) => keyword === name);
+ shortcut.isSelected = checked;
+ return { shortcuts };
+ });
+ }
+
+ onCancelButtonClick(ev) {
+ ev.preventDefault();
+ this.props.onClose();
+ }
+
+ onSaveButtonClick(ev) {
+ ev.preventDefault();
+
+ // Check if there were any changes and act accordingly
+ const { rows } = this.props.TopSites;
+ const pinQueue = [];
+ const unpinQueue = [];
+ this.state.shortcuts.forEach(shortcut => {
+ const alreadyPinned = rows.find(
+ row =>
+ row &&
+ row.isPinned &&
+ row.searchTopSite &&
+ row.label === shortcut.keyword
+ );
+ if (shortcut.isSelected && !alreadyPinned) {
+ pinQueue.push(this._searchTopSite(shortcut));
+ } else if (!shortcut.isSelected && alreadyPinned) {
+ unpinQueue.push({
+ url: alreadyPinned.url,
+ searchVendor: shortcut.shortURL,
+ });
+ }
+ });
+
+ // Tell the feed to do the work.
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.UPDATE_PINNED_SEARCH_SHORTCUTS,
+ data: {
+ addedShortcuts: pinQueue,
+ deletedShortcuts: unpinQueue,
+ },
+ })
+ );
+
+ // Send the Telemetry pings.
+ pinQueue.forEach(shortcut => {
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "SEARCH_EDIT_ADD",
+ value: { search_vendor: shortcut.searchVendor },
+ })
+ );
+ });
+ unpinQueue.forEach(shortcut => {
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "SEARCH_EDIT_DELETE",
+ value: { search_vendor: shortcut.searchVendor },
+ })
+ );
+ });
+
+ this.props.onClose();
+ }
+
+ _searchTopSite(shortcut) {
+ return {
+ url: shortcut.url,
+ searchTopSite: true,
+ label: shortcut.keyword,
+ searchVendor: shortcut.shortURL,
+ };
+ }
+
+ render() {
+ return (
+ <form className="topsite-form">
+ <div className="search-shortcuts-container">
+ <h3
+ className="section-title grey-title"
+ data-l10n-id="newtab-topsites-add-search-engine-header"
+ />
+ <div>
+ {this.state.shortcuts.map(shortcut => (
+ <SelectableSearchShortcut
+ key={shortcut.keyword}
+ shortcut={shortcut}
+ selected={shortcut.isSelected}
+ onChange={this.handleChange}
+ />
+ ))}
+ </div>
+ </div>
+ <section className="actions">
+ <button
+ className="cancel"
+ type="button"
+ onClick={this.onCancelButtonClick}
+ data-l10n-id="newtab-topsites-cancel-button"
+ />
+ <button
+ className="done"
+ type="submit"
+ onClick={this.onSaveButtonClick}
+ data-l10n-id="newtab-topsites-save-button"
+ />
+ </section>
+ </form>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx
new file mode 100644
index 0000000000..90641008be
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx
@@ -0,0 +1,873 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import {
+ MIN_RICH_FAVICON_SIZE,
+ MIN_SMALL_FAVICON_SIZE,
+ TOP_SITES_CONTEXT_MENU_OPTIONS,
+ TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS,
+ TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS,
+ TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS,
+ TOP_SITES_SOURCE,
+} from "./TopSitesConstants";
+import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats";
+import React from "react";
+import { ScreenshotUtils } from "content-src/lib/screenshot-utils";
+import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { TopSiteImpressionWrapper } from "./TopSiteImpressionWrapper";
+import { connect } from "react-redux";
+
+const SPOC_TYPE = "SPOC";
+const NEWTAB_SOURCE = "newtab";
+
+// For cases if we want to know if this is sponsored by either sponsored_position or type.
+// We have two sources for sponsored topsites, and
+// sponsored_position is set by one sponsored source, and type is set by another.
+// This is not called in all cases, sometimes we want to know if it's one source
+// or the other. This function is only applicable in cases where we only care if it's either.
+function isSponsored(link) {
+ return link?.sponsored_position || link?.type === SPOC_TYPE;
+}
+
+export class TopSiteLink extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { screenshotImage: null };
+ this.onDragEvent = this.onDragEvent.bind(this);
+ this.onKeyPress = this.onKeyPress.bind(this);
+ }
+
+ /*
+ * Helper to determine whether the drop zone should allow a drop. We only allow
+ * dropping top sites for now. We don't allow dropping on sponsored top sites
+ * as their position is fixed.
+ */
+ _allowDrop(e) {
+ return (
+ (this.dragged || !isSponsored(this.props.link)) &&
+ e.dataTransfer.types.includes("text/topsite-index")
+ );
+ }
+
+ onDragEvent(event) {
+ switch (event.type) {
+ case "click":
+ // Stop any link clicks if we started any dragging
+ if (this.dragged) {
+ event.preventDefault();
+ }
+ break;
+ case "dragstart":
+ event.target.blur();
+ if (isSponsored(this.props.link)) {
+ event.preventDefault();
+ break;
+ }
+ this.dragged = true;
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/topsite-index", this.props.index);
+ this.props.onDragEvent(
+ event,
+ this.props.index,
+ this.props.link,
+ this.props.title
+ );
+ break;
+ case "dragend":
+ this.props.onDragEvent(event);
+ break;
+ case "dragenter":
+ case "dragover":
+ case "drop":
+ if (this._allowDrop(event)) {
+ event.preventDefault();
+ this.props.onDragEvent(event, this.props.index);
+ }
+ break;
+ case "mousedown":
+ // Block the scroll wheel from appearing for middle clicks on search top sites
+ if (event.button === 1 && this.props.link.searchTopSite) {
+ event.preventDefault();
+ }
+ // Reset at the first mouse event of a potential drag
+ this.dragged = false;
+ break;
+ }
+ }
+
+ /**
+ * Helper to obtain the next state based on nextProps and prevState.
+ *
+ * NOTE: Rename this method to getDerivedStateFromProps when we update React
+ * to >= 16.3. We will need to update tests as well. We cannot rename this
+ * method to getDerivedStateFromProps now because there is a mismatch in
+ * the React version that we are using for both testing and production.
+ * (i.e. react-test-render => "16.3.2", react => "16.2.0").
+ *
+ * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
+ */
+ static getNextStateFromProps(nextProps, prevState) {
+ const { screenshot } = nextProps.link;
+ const imageInState = ScreenshotUtils.isRemoteImageLocal(
+ prevState.screenshotImage,
+ screenshot
+ );
+ if (imageInState) {
+ return null;
+ }
+
+ // Since image was updated, attempt to revoke old image blob URL, if it exists.
+ ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage);
+
+ return {
+ screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot),
+ };
+ }
+
+ // NOTE: Remove this function when we update React to >= 16.3 since React will
+ // call getDerivedStateFromProps automatically. We will also need to
+ // rename getNextStateFromProps to getDerivedStateFromProps.
+ componentWillMount() {
+ const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state);
+ if (nextState) {
+ this.setState(nextState);
+ }
+ }
+
+ // NOTE: Remove this function when we update React to >= 16.3 since React will
+ // call getDerivedStateFromProps automatically. We will also need to
+ // rename getNextStateFromProps to getDerivedStateFromProps.
+ componentWillReceiveProps(nextProps) {
+ const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state);
+ if (nextState) {
+ this.setState(nextState);
+ }
+ }
+
+ componentWillUnmount() {
+ ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage);
+ }
+
+ onKeyPress(event) {
+ // If we have tabbed to a search shortcut top site, and we click 'enter',
+ // we should execute the onClick function. This needs to be added because
+ // search top sites are anchor tags without an href. See bug 1483135
+ if (this.props.link.searchTopSite && event.key === "Enter") {
+ this.props.onClick(event);
+ }
+ }
+
+ /*
+ * Takes the url as a string, runs it through a simple (non-secure) hash turning it into a random number
+ * Apply that random number to the color array. The same url will always generate the same color.
+ */
+ generateColor() {
+ let { title, colors } = this.props;
+ if (!colors) {
+ return "";
+ }
+
+ let colorArray = colors.split(",");
+
+ const hashStr = str => {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ let charCode = str.charCodeAt(i);
+ hash += charCode;
+ }
+ return hash;
+ };
+
+ let hash = hashStr(title);
+ let index = hash % colorArray.length;
+ return colorArray[index];
+ }
+
+ calculateStyle() {
+ const { defaultStyle, link } = this.props;
+
+ const { tippyTopIcon, faviconSize } = link;
+ let imageClassName;
+ let imageStyle;
+ let showSmallFavicon = false;
+ let smallFaviconStyle;
+ let hasScreenshotImage =
+ this.state.screenshotImage && this.state.screenshotImage.url;
+ let selectedColor;
+
+ if (defaultStyle) {
+ // force no styles (letter fallback) even if the link has imagery
+ selectedColor = this.generateColor();
+ } else if (link.searchTopSite) {
+ imageClassName = "top-site-icon rich-icon";
+ imageStyle = {
+ backgroundColor: link.backgroundColor,
+ backgroundImage: `url(${tippyTopIcon})`,
+ };
+ smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` };
+ } else if (link.customScreenshotURL) {
+ // assume high quality custom screenshot and use rich icon styles and class names
+ imageClassName = "top-site-icon rich-icon";
+ imageStyle = {
+ backgroundColor: link.backgroundColor,
+ backgroundImage: hasScreenshotImage
+ ? `url(${this.state.screenshotImage.url})`
+ : "",
+ };
+ } else if (
+ tippyTopIcon ||
+ link.type === SPOC_TYPE ||
+ faviconSize >= MIN_RICH_FAVICON_SIZE
+ ) {
+ // styles and class names for top sites with rich icons
+ imageClassName = "top-site-icon rich-icon";
+ imageStyle = {
+ backgroundColor: link.backgroundColor,
+ backgroundImage: `url(${tippyTopIcon || link.favicon})`,
+ };
+ } else if (faviconSize >= MIN_SMALL_FAVICON_SIZE) {
+ showSmallFavicon = true;
+ smallFaviconStyle = { backgroundImage: `url(${link.favicon})` };
+ } else {
+ selectedColor = this.generateColor();
+ imageClassName = "";
+ }
+
+ return {
+ showSmallFavicon,
+ smallFaviconStyle,
+ imageStyle,
+ imageClassName,
+ selectedColor,
+ };
+ }
+
+ render() {
+ const { children, className, isDraggable, link, onClick, title } =
+ this.props;
+ const topSiteOuterClassName = `top-site-outer${
+ className ? ` ${className}` : ""
+ }${link.isDragged ? " dragged" : ""}${
+ link.searchTopSite ? " search-shortcut" : ""
+ }`;
+ const [letterFallback] = title;
+ const {
+ showSmallFavicon,
+ smallFaviconStyle,
+ imageStyle,
+ imageClassName,
+ selectedColor,
+ } = this.calculateStyle();
+
+ let draggableProps = {};
+ if (isDraggable) {
+ draggableProps = {
+ onClick: this.onDragEvent,
+ onDragEnd: this.onDragEvent,
+ onDragStart: this.onDragEvent,
+ onMouseDown: this.onDragEvent,
+ };
+ }
+
+ let impressionStats = null;
+ if (link.type === SPOC_TYPE) {
+ // Record impressions for Pocket tiles.
+ impressionStats = (
+ <ImpressionStats
+ flightId={link.flightId}
+ rows={[
+ {
+ id: link.id,
+ pos: link.pos,
+ shim: link.shim && link.shim.impression,
+ advertiser: title.toLocaleLowerCase(),
+ },
+ ]}
+ dispatch={this.props.dispatch}
+ source={TOP_SITES_SOURCE}
+ />
+ );
+ } else if (isSponsored(link)) {
+ // Record impressions for non-Pocket sponsored tiles.
+ impressionStats = (
+ <TopSiteImpressionWrapper
+ actionType={at.TOP_SITES_SPONSORED_IMPRESSION_STATS}
+ tile={{
+ position: this.props.index,
+ tile_id: link.sponsored_tile_id || -1,
+ reporting_url: link.sponsored_impression_url,
+ advertiser: title.toLocaleLowerCase(),
+ source: NEWTAB_SOURCE,
+ }}
+ // For testing.
+ IntersectionObserver={this.props.IntersectionObserver}
+ document={this.props.document}
+ dispatch={this.props.dispatch}
+ />
+ );
+ } else {
+ // Record impressions for organic tiles.
+ impressionStats = (
+ <TopSiteImpressionWrapper
+ actionType={at.TOP_SITES_ORGANIC_IMPRESSION_STATS}
+ tile={{
+ position: this.props.index,
+ source: NEWTAB_SOURCE,
+ }}
+ // For testing.
+ IntersectionObserver={this.props.IntersectionObserver}
+ document={this.props.document}
+ dispatch={this.props.dispatch}
+ />
+ );
+ }
+
+ return (
+ <li
+ className={topSiteOuterClassName}
+ onDrop={this.onDragEvent}
+ onDragOver={this.onDragEvent}
+ onDragEnter={this.onDragEvent}
+ onDragLeave={this.onDragEvent}
+ {...draggableProps}
+ >
+ <div className="top-site-inner">
+ {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */}
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+ <a
+ className="top-site-button"
+ href={link.searchTopSite ? undefined : link.url}
+ tabIndex="0"
+ onKeyPress={this.onKeyPress}
+ onClick={onClick}
+ draggable={true}
+ data-is-sponsored-link={!!link.sponsored_tile_id}
+ >
+ <div className="tile" aria-hidden={true}>
+ <div
+ className={
+ selectedColor
+ ? "icon-wrapper letter-fallback"
+ : "icon-wrapper"
+ }
+ data-fallback={letterFallback}
+ style={selectedColor ? { backgroundColor: selectedColor } : {}}
+ >
+ <div className={imageClassName} style={imageStyle} />
+ {showSmallFavicon && (
+ <div
+ className="top-site-icon default-icon"
+ data-fallback={smallFaviconStyle ? "" : letterFallback}
+ style={smallFaviconStyle}
+ />
+ )}
+ </div>
+ {link.searchTopSite && (
+ <div className="top-site-icon search-topsite" />
+ )}
+ </div>
+ <div
+ className={`title${link.isPinned ? " has-icon pinned" : ""}${
+ link.type === SPOC_TYPE || link.show_sponsored_label
+ ? " sponsored"
+ : ""
+ }`}
+ >
+ <span dir="auto">
+ {link.isPinned && <div className="icon icon-pin-small" />}
+ {title || <br />}
+ <span
+ className="sponsored-label"
+ data-l10n-id="newtab-topsite-sponsored"
+ />
+ </span>
+ </div>
+ </a>
+ {children}
+ {impressionStats}
+ </div>
+ </li>
+ );
+ }
+}
+TopSiteLink.defaultProps = {
+ title: "",
+ link: {},
+ isDraggable: true,
+};
+
+export class TopSite extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { showContextMenu: false };
+ this.onLinkClick = this.onLinkClick.bind(this);
+ this.onMenuUpdate = this.onMenuUpdate.bind(this);
+ }
+
+ /**
+ * Report to telemetry additional information about the item.
+ */
+ _getTelemetryInfo() {
+ const value = { icon_type: this.props.link.iconType };
+ // Filter out "not_pinned" type for being the default
+ if (this.props.link.isPinned) {
+ value.card_type = "pinned";
+ }
+ if (this.props.link.searchTopSite) {
+ // Set the card_type as "search" regardless of its pinning status
+ value.card_type = "search";
+ value.search_vendor = this.props.link.hostname;
+ }
+ if (isSponsored(this.props.link)) {
+ value.card_type = "spoc";
+ }
+ return { value };
+ }
+
+ userEvent(event) {
+ this.props.dispatch(
+ ac.UserEvent(
+ Object.assign(
+ {
+ event,
+ source: TOP_SITES_SOURCE,
+ action_position: this.props.index,
+ },
+ this._getTelemetryInfo()
+ )
+ )
+ );
+ }
+
+ onLinkClick(event) {
+ this.userEvent("CLICK");
+
+ // Specially handle a top site link click for "typed" frecency bonus as
+ // specified as a property on the link.
+ event.preventDefault();
+ const { altKey, button, ctrlKey, metaKey, shiftKey } = event;
+ if (!this.props.link.searchTopSite) {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: Object.assign(this.props.link, {
+ event: { altKey, button, ctrlKey, metaKey, shiftKey },
+ }),
+ })
+ );
+
+ if (this.props.link.type === SPOC_TYPE) {
+ // Record a Pocket-specific click.
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source: TOP_SITES_SOURCE,
+ click: 0,
+ tiles: [
+ {
+ id: this.props.link.id,
+ pos: this.props.link.pos,
+ shim: this.props.link.shim && this.props.link.shim.click,
+ },
+ ],
+ })
+ );
+
+ // Record a click for a Pocket sponsored tile.
+ const title = this.props.link.label || this.props.link.hostname;
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS,
+ data: {
+ type: "click",
+ position: this.props.link.pos,
+ tile_id: this.props.link.id,
+ advertiser: title.toLocaleLowerCase(),
+ source: NEWTAB_SOURCE,
+ },
+ })
+ );
+ } else if (isSponsored(this.props.link)) {
+ // Record a click for a non-Pocket sponsored tile.
+ const title = this.props.link.label || this.props.link.hostname;
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS,
+ data: {
+ type: "click",
+ position: this.props.index,
+ tile_id: this.props.link.sponsored_tile_id || -1,
+ reporting_url: this.props.link.sponsored_click_url,
+ advertiser: title.toLocaleLowerCase(),
+ source: NEWTAB_SOURCE,
+ },
+ })
+ );
+ } else {
+ // Record a click for an organic tile.
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS,
+ data: {
+ type: "click",
+ position: this.props.index,
+ source: NEWTAB_SOURCE,
+ },
+ })
+ );
+ }
+
+ if (this.props.link.sendAttributionRequest) {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.PARTNER_LINK_ATTRIBUTION,
+ data: {
+ targetURL: this.props.link.url,
+ source: "newtab",
+ },
+ })
+ );
+ }
+ } else {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.FILL_SEARCH_TERM,
+ data: { label: this.props.link.label },
+ })
+ );
+ }
+ }
+
+ onMenuUpdate(isOpen) {
+ if (isOpen) {
+ this.props.onActivate(this.props.index);
+ } else {
+ this.props.onActivate();
+ }
+ }
+
+ render() {
+ const { props } = this;
+ const { link } = props;
+ const isContextMenuOpen = props.activeIndex === props.index;
+ const title = link.label || link.hostname;
+ let menuOptions;
+ if (link.sponsored_position) {
+ menuOptions = TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS;
+ } else if (link.searchTopSite) {
+ menuOptions = TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS;
+ } else if (link.type === SPOC_TYPE) {
+ menuOptions = TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS;
+ } else {
+ menuOptions = TOP_SITES_CONTEXT_MENU_OPTIONS;
+ }
+
+ return (
+ <TopSiteLink
+ {...props}
+ onClick={this.onLinkClick}
+ onDragEvent={this.props.onDragEvent}
+ className={`${props.className || ""}${
+ isContextMenuOpen ? " active" : ""
+ }`}
+ title={title}
+ >
+ <div>
+ <ContextMenuButton
+ tooltip="newtab-menu-content-tooltip"
+ tooltipArgs={{ title }}
+ onUpdate={this.onMenuUpdate}
+ >
+ <LinkMenu
+ dispatch={props.dispatch}
+ index={props.index}
+ onUpdate={this.onMenuUpdate}
+ options={menuOptions}
+ site={link}
+ shouldSendImpressionStats={link.type === SPOC_TYPE}
+ siteInfo={this._getTelemetryInfo()}
+ source={TOP_SITES_SOURCE}
+ />
+ </ContextMenuButton>
+ </div>
+ </TopSiteLink>
+ );
+ }
+}
+TopSite.defaultProps = {
+ link: {},
+ onActivate() {},
+};
+
+export class TopSitePlaceholder extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onEditButtonClick = this.onEditButtonClick.bind(this);
+ }
+
+ onEditButtonClick() {
+ this.props.dispatch({
+ type: at.TOP_SITES_EDIT,
+ data: { index: this.props.index },
+ });
+ }
+
+ render() {
+ return (
+ <TopSiteLink
+ {...this.props}
+ className={`placeholder ${this.props.className || ""}`}
+ isDraggable={false}
+ >
+ <button
+ aria-haspopup="dialog"
+ className="context-menu-button edit-button icon"
+ data-l10n-id="newtab-menu-topsites-placeholder-tooltip"
+ onClick={this.onEditButtonClick}
+ />
+ </TopSiteLink>
+ );
+ }
+}
+
+export class _TopSiteList extends React.PureComponent {
+ static get DEFAULT_STATE() {
+ return {
+ activeIndex: null,
+ draggedIndex: null,
+ draggedSite: null,
+ draggedTitle: null,
+ topSitesPreview: null,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = _TopSiteList.DEFAULT_STATE;
+ this.onDragEvent = this.onDragEvent.bind(this);
+ this.onActivate = this.onActivate.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (this.state.draggedSite) {
+ const prevTopSites = this.props.TopSites && this.props.TopSites.rows;
+ const newTopSites = nextProps.TopSites && nextProps.TopSites.rows;
+ if (
+ prevTopSites &&
+ prevTopSites[this.state.draggedIndex] &&
+ prevTopSites[this.state.draggedIndex].url ===
+ this.state.draggedSite.url &&
+ (!newTopSites[this.state.draggedIndex] ||
+ newTopSites[this.state.draggedIndex].url !==
+ this.state.draggedSite.url)
+ ) {
+ // We got the new order from the redux store via props. We can clear state now.
+ this.setState(_TopSiteList.DEFAULT_STATE);
+ }
+ }
+ }
+
+ userEvent(event, index) {
+ this.props.dispatch(
+ ac.UserEvent({
+ event,
+ source: TOP_SITES_SOURCE,
+ action_position: index,
+ })
+ );
+ }
+
+ onDragEvent(event, index, link, title) {
+ switch (event.type) {
+ case "dragstart":
+ this.dropped = false;
+ this.setState({
+ draggedIndex: index,
+ draggedSite: link,
+ draggedTitle: title,
+ activeIndex: null,
+ });
+ this.userEvent("DRAG", index);
+ break;
+ case "dragend":
+ if (!this.dropped) {
+ // If there was no drop event, reset the state to the default.
+ this.setState(_TopSiteList.DEFAULT_STATE);
+ }
+ break;
+ case "dragenter":
+ if (index === this.state.draggedIndex) {
+ this.setState({ topSitesPreview: null });
+ } else {
+ this.setState({
+ topSitesPreview: this._makeTopSitesPreview(index),
+ });
+ }
+ break;
+ case "drop":
+ if (index !== this.state.draggedIndex) {
+ this.dropped = true;
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.TOP_SITES_INSERT,
+ data: {
+ site: {
+ url: this.state.draggedSite.url,
+ label: this.state.draggedTitle,
+ customScreenshotURL:
+ this.state.draggedSite.customScreenshotURL,
+ // Only if the search topsites experiment is enabled
+ ...(this.state.draggedSite.searchTopSite && {
+ searchTopSite: true,
+ }),
+ },
+ index,
+ draggedFromIndex: this.state.draggedIndex,
+ },
+ })
+ );
+ this.userEvent("DROP", index);
+ }
+ break;
+ }
+ }
+
+ _getTopSites() {
+ // Make a copy of the sites to truncate or extend to desired length
+ let topSites = this.props.TopSites.rows.slice();
+ topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
+ return topSites;
+ }
+
+ /**
+ * Make a preview of the topsites that will be the result of dropping the currently
+ * dragged site at the specified index.
+ */
+ _makeTopSitesPreview(index) {
+ const topSites = this._getTopSites();
+ topSites[this.state.draggedIndex] = null;
+ const preview = topSites.map(site =>
+ site && (site.isPinned || isSponsored(site)) ? site : null
+ );
+ const unpinned = topSites.filter(
+ site => site && !site.isPinned && !isSponsored(site)
+ );
+ const siteToInsert = Object.assign({}, this.state.draggedSite, {
+ isPinned: true,
+ isDragged: true,
+ });
+
+ if (!preview[index]) {
+ preview[index] = siteToInsert;
+ } else {
+ // Find the hole to shift the pinned site(s) towards. We shift towards the
+ // hole left by the site being dragged.
+ let holeIndex = index;
+ const indexStep = index > this.state.draggedIndex ? -1 : 1;
+ while (preview[holeIndex]) {
+ holeIndex += indexStep;
+ }
+
+ // Shift towards the hole.
+ const shiftingStep = index > this.state.draggedIndex ? 1 : -1;
+ while (
+ index > this.state.draggedIndex ? holeIndex < index : holeIndex > index
+ ) {
+ let nextIndex = holeIndex + shiftingStep;
+ while (isSponsored(preview[nextIndex])) {
+ nextIndex += shiftingStep;
+ }
+ preview[holeIndex] = preview[nextIndex];
+ holeIndex = nextIndex;
+ }
+ preview[index] = siteToInsert;
+ }
+
+ // Fill in the remaining holes with unpinned sites.
+ for (let i = 0; i < preview.length; i++) {
+ if (!preview[i]) {
+ preview[i] = unpinned.shift() || null;
+ }
+ }
+
+ return preview;
+ }
+
+ onActivate(index) {
+ this.setState({ activeIndex: index });
+ }
+
+ render() {
+ const { props } = this;
+ const topSites = this.state.topSitesPreview || this._getTopSites();
+ const topSitesUI = [];
+ const commonProps = {
+ onDragEvent: this.onDragEvent,
+ dispatch: props.dispatch,
+ };
+ // We assign a key to each placeholder slot. We need it to be independent
+ // of the slot index (i below) so that the keys used stay the same during
+ // drag and drop reordering and the underlying DOM nodes are reused.
+ // This mostly (only?) affects linux so be sure to test on linux before changing.
+ let holeIndex = 0;
+
+ // On narrow viewports, we only show 6 sites per row. We'll mark the rest as
+ // .hide-for-narrow to hide in CSS via @media query.
+ const maxNarrowVisibleIndex = props.TopSitesRows * 6;
+
+ for (let i = 0, l = topSites.length; i < l; i++) {
+ const link =
+ topSites[i] &&
+ Object.assign({}, topSites[i], {
+ iconType: this.props.topSiteIconType(topSites[i]),
+ });
+
+ const slotProps = {
+ key: link ? link.url : holeIndex++,
+ index: i,
+ };
+ if (i >= maxNarrowVisibleIndex) {
+ slotProps.className = "hide-for-narrow";
+ }
+
+ let topSiteLink;
+ // Use a placeholder if the link is empty or it's rendering a sponsored
+ // tile for the about:home startup cache.
+ if (!link || (props.App.isForStartupCache && isSponsored(link))) {
+ topSiteLink = <TopSitePlaceholder {...slotProps} {...commonProps} />;
+ } else {
+ topSiteLink = (
+ <TopSite
+ link={link}
+ activeIndex={this.state.activeIndex}
+ onActivate={this.onActivate}
+ {...slotProps}
+ {...commonProps}
+ colors={props.colors}
+ />
+ );
+ }
+
+ topSitesUI.push(topSiteLink);
+ }
+ return (
+ <ul
+ className={`top-sites-list${
+ this.state.draggedSite ? " dnd-active" : ""
+ }`}
+ >
+ {topSitesUI}
+ </ul>
+ );
+ }
+}
+
+export const TopSiteList = connect(state => ({
+ App: state.App,
+}))(_TopSiteList);
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx
new file mode 100644
index 0000000000..7dd61bdc93
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx
@@ -0,0 +1,323 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton";
+import React from "react";
+import { TOP_SITES_SOURCE } from "./TopSitesConstants";
+import { TopSiteFormInput } from "./TopSiteFormInput";
+import { TopSiteLink } from "./TopSite";
+
+export class TopSiteForm extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ const { site } = props;
+ this.state = {
+ label: site ? site.label || site.hostname : "",
+ url: site ? site.url : "",
+ validationError: false,
+ customScreenshotUrl: site ? site.customScreenshotURL : "",
+ showCustomScreenshotForm: site ? site.customScreenshotURL : false,
+ };
+ this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this);
+ this.onLabelChange = this.onLabelChange.bind(this);
+ this.onUrlChange = this.onUrlChange.bind(this);
+ this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
+ this.onClearUrlClick = this.onClearUrlClick.bind(this);
+ this.onDoneButtonClick = this.onDoneButtonClick.bind(this);
+ this.onCustomScreenshotUrlChange =
+ this.onCustomScreenshotUrlChange.bind(this);
+ this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
+ this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this);
+ this.validateUrl = this.validateUrl.bind(this);
+ }
+
+ onLabelChange(event) {
+ this.setState({ label: event.target.value });
+ }
+
+ onUrlChange(event) {
+ this.setState({
+ url: event.target.value,
+ validationError: false,
+ });
+ }
+
+ onClearUrlClick() {
+ this.setState({
+ url: "",
+ validationError: false,
+ });
+ }
+
+ onEnableScreenshotUrlForm() {
+ this.setState({ showCustomScreenshotForm: true });
+ }
+
+ _updateCustomScreenshotInput(customScreenshotUrl) {
+ this.setState({
+ customScreenshotUrl,
+ validationError: false,
+ });
+ this.props.dispatch({ type: at.PREVIEW_REQUEST_CANCEL });
+ }
+
+ onCustomScreenshotUrlChange(event) {
+ this._updateCustomScreenshotInput(event.target.value);
+ }
+
+ onClearScreenshotInput() {
+ this._updateCustomScreenshotInput("");
+ }
+
+ onCancelButtonClick(ev) {
+ ev.preventDefault();
+ this.props.onClose();
+ }
+
+ onDoneButtonClick(ev) {
+ ev.preventDefault();
+
+ if (this.validateForm()) {
+ const site = { url: this.cleanUrl(this.state.url) };
+ const { index } = this.props;
+ if (this.state.label !== "") {
+ site.label = this.state.label;
+ }
+
+ if (this.state.customScreenshotUrl) {
+ site.customScreenshotURL = this.cleanUrl(
+ this.state.customScreenshotUrl
+ );
+ } else if (this.props.site && this.props.site.customScreenshotURL) {
+ // Used to flag that previously cached screenshot should be removed
+ site.customScreenshotURL = null;
+ }
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.TOP_SITES_PIN,
+ data: { site, index },
+ })
+ );
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "TOP_SITES_EDIT",
+ action_position: index,
+ })
+ );
+
+ this.props.onClose();
+ }
+ }
+
+ onPreviewButtonClick(event) {
+ event.preventDefault();
+ if (this.validateForm()) {
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.PREVIEW_REQUEST,
+ data: { url: this.cleanUrl(this.state.customScreenshotUrl) },
+ })
+ );
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "PREVIEW_REQUEST",
+ })
+ );
+ }
+ }
+
+ cleanUrl(url) {
+ // If we are missing a protocol, prepend http://
+ if (!url.startsWith("http:") && !url.startsWith("https:")) {
+ return `http://${url}`;
+ }
+ return url;
+ }
+
+ _tryParseUrl(url) {
+ try {
+ return new URL(url);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ validateUrl(url) {
+ const validProtocols = ["http:", "https:"];
+ const urlObj =
+ this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url));
+
+ return urlObj && validProtocols.includes(urlObj.protocol);
+ }
+
+ validateCustomScreenshotUrl() {
+ const { customScreenshotUrl } = this.state;
+ return !customScreenshotUrl || this.validateUrl(customScreenshotUrl);
+ }
+
+ validateForm() {
+ const validate =
+ this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl();
+
+ if (!validate) {
+ this.setState({ validationError: true });
+ }
+
+ return validate;
+ }
+
+ _renderCustomScreenshotInput() {
+ const { customScreenshotUrl } = this.state;
+ const requestFailed = this.props.previewResponse === "";
+ const validationError =
+ (this.state.validationError && !this.validateCustomScreenshotUrl()) ||
+ requestFailed;
+ // Set focus on error if the url field is valid or when the input is first rendered and is empty
+ const shouldFocus =
+ (validationError && this.validateUrl(this.state.url)) ||
+ !customScreenshotUrl;
+ const isLoading =
+ this.props.previewResponse === null &&
+ customScreenshotUrl &&
+ this.props.previewUrl === this.cleanUrl(customScreenshotUrl);
+
+ if (!this.state.showCustomScreenshotForm) {
+ return (
+ <A11yLinkButton
+ onClick={this.onEnableScreenshotUrlForm}
+ className="enable-custom-image-input"
+ data-l10n-id="newtab-topsites-use-image-link"
+ />
+ );
+ }
+ return (
+ <div className="custom-image-input-container">
+ <TopSiteFormInput
+ errorMessageId={
+ requestFailed
+ ? "newtab-topsites-image-validation"
+ : "newtab-topsites-url-validation"
+ }
+ loading={isLoading}
+ onChange={this.onCustomScreenshotUrlChange}
+ onClear={this.onClearScreenshotInput}
+ shouldFocus={shouldFocus}
+ typeUrl={true}
+ value={customScreenshotUrl}
+ validationError={validationError}
+ titleId="newtab-topsites-image-url-label"
+ placeholderId="newtab-topsites-url-input"
+ />
+ </div>
+ );
+ }
+
+ render() {
+ const { customScreenshotUrl } = this.state;
+ const requestFailed = this.props.previewResponse === "";
+ // For UI purposes, editing without an existing link is "add"
+ const showAsAdd = !this.props.site;
+ const previous =
+ (this.props.site && this.props.site.customScreenshotURL) || "";
+ const changed =
+ customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous;
+ // Preview mode if changes were made to the custom screenshot URL and no preview was received yet
+ // or the request failed
+ const previewMode = changed && !this.props.previewResponse;
+ const previewLink = Object.assign({}, this.props.site);
+ if (this.props.previewResponse) {
+ previewLink.screenshot = this.props.previewResponse;
+ previewLink.customScreenshotURL = this.props.previewUrl;
+ }
+ // Handles the form submit so an enter press performs the correct action
+ const onSubmit = previewMode
+ ? this.onPreviewButtonClick
+ : this.onDoneButtonClick;
+
+ const addTopsitesHeaderL10nId = "newtab-topsites-add-shortcut-header";
+ const editTopsitesHeaderL10nId = "newtab-topsites-edit-shortcut-header";
+ return (
+ <form className="topsite-form" onSubmit={onSubmit}>
+ <div className="form-input-container">
+ <h3
+ className="section-title grey-title"
+ data-l10n-id={
+ showAsAdd ? addTopsitesHeaderL10nId : editTopsitesHeaderL10nId
+ }
+ />
+ <div className="fields-and-preview">
+ <div className="form-wrapper">
+ <TopSiteFormInput
+ onChange={this.onLabelChange}
+ value={this.state.label}
+ titleId="newtab-topsites-title-label"
+ placeholderId="newtab-topsites-title-input"
+ autoFocusOnOpen={true}
+ />
+ <TopSiteFormInput
+ onChange={this.onUrlChange}
+ shouldFocus={
+ this.state.validationError &&
+ !this.validateUrl(this.state.url)
+ }
+ value={this.state.url}
+ onClear={this.onClearUrlClick}
+ validationError={
+ this.state.validationError &&
+ !this.validateUrl(this.state.url)
+ }
+ titleId="newtab-topsites-url-label"
+ typeUrl={true}
+ placeholderId="newtab-topsites-url-input"
+ errorMessageId="newtab-topsites-url-validation"
+ />
+ {this._renderCustomScreenshotInput()}
+ </div>
+ <TopSiteLink
+ link={previewLink}
+ defaultStyle={requestFailed}
+ title={this.state.label}
+ />
+ </div>
+ </div>
+ <section className="actions">
+ <button
+ className="cancel"
+ type="button"
+ onClick={this.onCancelButtonClick}
+ data-l10n-id="newtab-topsites-cancel-button"
+ />
+ {previewMode ? (
+ <button
+ className="done preview"
+ type="submit"
+ data-l10n-id="newtab-topsites-preview-button"
+ />
+ ) : (
+ <button
+ className="done"
+ type="submit"
+ data-l10n-id={
+ showAsAdd
+ ? "newtab-topsites-add-button"
+ : "newtab-topsites-save-button"
+ }
+ />
+ )}
+ </section>
+ </form>
+ );
+ }
+}
+
+TopSiteForm.defaultProps = {
+ site: null,
+ index: -1,
+};
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx
new file mode 100644
index 0000000000..c680edc7e4
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx
@@ -0,0 +1,111 @@
+/* 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 React from "react";
+
+export class TopSiteFormInput extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { validationError: this.props.validationError };
+ this.onChange = this.onChange.bind(this);
+ this.onMount = this.onMount.bind(this);
+ this.onClearIconPress = this.onClearIconPress.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.shouldFocus && !this.props.shouldFocus) {
+ this.input.focus();
+ }
+ if (nextProps.validationError && !this.props.validationError) {
+ this.setState({ validationError: true });
+ }
+ // If the component is in an error state but the value was cleared by the parent
+ if (this.state.validationError && !nextProps.value) {
+ this.setState({ validationError: false });
+ }
+ }
+
+ onClearIconPress(event) {
+ // If there is input in the URL or custom image URL fields,
+ // and we hit 'enter' while tabbed over the clear icon,
+ // we should execute the function to clear the field.
+ if (event.key === "Enter") {
+ this.props.onClear();
+ }
+ }
+
+ onChange(ev) {
+ if (this.state.validationError) {
+ this.setState({ validationError: false });
+ }
+ this.props.onChange(ev);
+ }
+
+ onMount(input) {
+ this.input = input;
+ }
+
+ renderLoadingOrCloseButton() {
+ const showClearButton = this.props.value && this.props.onClear;
+
+ if (this.props.loading) {
+ return (
+ <div className="loading-container">
+ <div className="loading-animation" />
+ </div>
+ );
+ } else if (showClearButton) {
+ return (
+ <button
+ type="button"
+ className="icon icon-clear-input icon-button-style"
+ onClick={this.props.onClear}
+ onKeyPress={this.onClearIconPress}
+ />
+ );
+ }
+ return null;
+ }
+
+ render() {
+ const { typeUrl } = this.props;
+ const { validationError } = this.state;
+
+ return (
+ <label>
+ <span data-l10n-id={this.props.titleId} />
+ <div
+ className={`field ${typeUrl ? "url" : ""}${
+ validationError ? " invalid" : ""
+ }`}
+ >
+ <input
+ type="text"
+ value={this.props.value}
+ ref={this.onMount}
+ onChange={this.onChange}
+ data-l10n-id={this.props.placeholderId}
+ // Set focus on error if the url field is valid or when the input is first rendered and is empty
+ // eslint-disable-next-line jsx-a11y/no-autofocus
+ autoFocus={this.props.autoFocusOnOpen}
+ disabled={this.props.loading}
+ />
+ {this.renderLoadingOrCloseButton()}
+ {validationError && (
+ <aside
+ className="error-tooltip"
+ data-l10n-id={this.props.errorMessageId}
+ />
+ )}
+ </div>
+ </label>
+ );
+ }
+}
+
+TopSiteFormInput.defaultProps = {
+ showClearButton: false,
+ value: "",
+ validationError: false,
+};
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx
new file mode 100644
index 0000000000..580809dd57
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx
@@ -0,0 +1,149 @@
+/* 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 { actionCreators as ac } from "common/Actions.sys.mjs";
+import React from "react";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+// Per analytical requirement, we set the minimal intersection ratio to
+// 0.5, and an impression is identified when the wrapped item has at least
+// 50% visibility.
+//
+// This constant is exported for unit test
+export const INTERSECTION_RATIO = 0.5;
+
+/**
+ * Impression wrapper for a TopSite tile.
+ *
+ * It makses use of the Intersection Observer API to detect the visibility,
+ * and relies on page visibility to ensure the impression is reported
+ * only when the component is visible on the page.
+ */
+export class TopSiteImpressionWrapper extends React.PureComponent {
+ _dispatchImpressionStats() {
+ const { actionType, tile } = this.props;
+ if (!actionType) {
+ return;
+ }
+
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: actionType,
+ data: {
+ type: "impression",
+ ...tile,
+ },
+ })
+ );
+ }
+
+ setImpressionObserverOrAddListener() {
+ const { props } = this;
+
+ if (!props.dispatch) {
+ return;
+ }
+
+ if (props.document.visibilityState === VISIBLE) {
+ this.setImpressionObserver();
+ } else {
+ // We should only ever send the latest impression stats ping, so remove any
+ // older listeners.
+ if (this._onVisibilityChange) {
+ props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+
+ this._onVisibilityChange = () => {
+ if (props.document.visibilityState === VISIBLE) {
+ this.setImpressionObserver();
+ props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ };
+ props.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ /**
+ * Set an impression observer for the wrapped component. It makes use of
+ * the Intersection Observer API to detect if the wrapped component is
+ * visible with a desired ratio, and only sends impression if that's the case.
+ *
+ * See more details about Intersection Observer API at:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
+ */
+ setImpressionObserver() {
+ const { props } = this;
+
+ if (!props.tile) {
+ return;
+ }
+
+ this._handleIntersect = entries => {
+ if (
+ entries.some(
+ entry =>
+ entry.isIntersecting &&
+ entry.intersectionRatio >= INTERSECTION_RATIO
+ )
+ ) {
+ this._dispatchImpressionStats();
+ this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper);
+ }
+ };
+
+ const options = { threshold: INTERSECTION_RATIO };
+ this.impressionObserver = new props.IntersectionObserver(
+ this._handleIntersect,
+ options
+ );
+ this.impressionObserver.observe(this.refs.topsite_impression_wrapper);
+ }
+
+ componentDidMount() {
+ if (this.props.tile) {
+ this.setImpressionObserverOrAddListener();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._handleIntersect && this.impressionObserver) {
+ this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper);
+ }
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ render() {
+ return (
+ <div
+ ref={"topsite_impression_wrapper"}
+ className="topsite-impression-observer"
+ >
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+TopSiteImpressionWrapper.defaultProps = {
+ IntersectionObserver: global.IntersectionObserver,
+ document: global.document,
+ actionType: null,
+ tile: null,
+};
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSites.jsx b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx
new file mode 100644
index 0000000000..fd1e3048a5
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx
@@ -0,0 +1,213 @@
+/* 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 {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE } from "./TopSitesConstants";
+import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
+import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
+import { connect } from "react-redux";
+import { ModalOverlayWrapper } from "../../asrouter/components/ModalOverlay/ModalOverlay";
+import React from "react";
+import { SearchShortcutsForm } from "./SearchShortcutsForm";
+import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs";
+import { TopSiteForm } from "./TopSiteForm";
+import { TopSiteList } from "./TopSite";
+
+function topSiteIconType(link) {
+ if (link.customScreenshotURL) {
+ return "custom_screenshot";
+ }
+ if (link.tippyTopIcon || link.faviconRef === "tippytop") {
+ return "tippytop";
+ }
+ if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) {
+ return "rich_icon";
+ }
+ if (link.screenshot) {
+ return "screenshot";
+ }
+ return "no_image";
+}
+
+/**
+ * Iterates through TopSites and counts types of images.
+ * @param acc Accumulator for reducer.
+ * @param topsite Entry in TopSites.
+ */
+function countTopSitesIconsTypes(topSites) {
+ const countTopSitesTypes = (acc, link) => {
+ acc[topSiteIconType(link)]++;
+ return acc;
+ };
+
+ return topSites.reduce(countTopSitesTypes, {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 0,
+ });
+}
+
+export class _TopSites extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onEditFormClose = this.onEditFormClose.bind(this);
+ this.onSearchShortcutsFormClose =
+ this.onSearchShortcutsFormClose.bind(this);
+ }
+
+ /**
+ * Dispatch session statistics about the quality of TopSites icons and pinned count.
+ */
+ _dispatchTopSitesStats() {
+ const topSites = this._getVisibleTopSites().filter(
+ topSite => topSite !== null && topSite !== undefined
+ );
+ const topSitesIconsStats = countTopSitesIconsTypes(topSites);
+ const topSitesPinned = topSites.filter(site => !!site.isPinned).length;
+ const searchShortcuts = topSites.filter(
+ site => !!site.searchTopSite
+ ).length;
+ // Dispatch telemetry event with the count of TopSites images types.
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: topSitesIconsStats,
+ topsites_pinned: topSitesPinned,
+ topsites_search_shortcuts: searchShortcuts,
+ },
+ })
+ );
+ }
+
+ /**
+ * Return the TopSites that are visible based on prefs and window width.
+ */
+ _getVisibleTopSites() {
+ // We hide 2 sites per row when not in the wide layout.
+ let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW;
+ // $break-point-widest = 1072px (from _variables.scss)
+ if (!global.matchMedia(`(min-width: 1072px)`).matches) {
+ sitesPerRow -= 2;
+ }
+ return this.props.TopSites.rows.slice(
+ 0,
+ this.props.TopSitesRows * sitesPerRow
+ );
+ }
+
+ componentDidUpdate() {
+ this._dispatchTopSitesStats();
+ }
+
+ componentDidMount() {
+ this._dispatchTopSitesStats();
+ }
+
+ onEditFormClose() {
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "TOP_SITES_EDIT_CLOSE",
+ })
+ );
+ this.props.dispatch({ type: at.TOP_SITES_CANCEL_EDIT });
+ }
+
+ onSearchShortcutsFormClose() {
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "SEARCH_EDIT_CLOSE",
+ })
+ );
+ this.props.dispatch({ type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL });
+ }
+
+ render() {
+ const { props } = this;
+ const { editForm, showSearchShortcutsForm } = props.TopSites;
+ const extraMenuOptions = ["AddTopSite"];
+ const colors = props.Prefs.values["newNewtabExperience.colors"];
+
+ if (props.Prefs.values["improvesearch.topSiteSearchShortcuts"]) {
+ extraMenuOptions.push("AddSearchShortcut");
+ }
+
+ return (
+ <ComponentPerfTimer
+ id="topsites"
+ initialized={props.TopSites.initialized}
+ dispatch={props.dispatch}
+ >
+ <CollapsibleSection
+ className="top-sites"
+ id="topsites"
+ title={props.title || { id: "newtab-section-header-topsites" }}
+ hideTitle={true}
+ extraMenuOptions={extraMenuOptions}
+ showPrefName="feeds.topsites"
+ eventSource={TOP_SITES_SOURCE}
+ collapsed={false}
+ isFixed={props.isFixed}
+ isFirst={props.isFirst}
+ isLast={props.isLast}
+ dispatch={props.dispatch}
+ >
+ <TopSiteList
+ TopSites={props.TopSites}
+ TopSitesRows={props.TopSitesRows}
+ dispatch={props.dispatch}
+ topSiteIconType={topSiteIconType}
+ colors={colors}
+ />
+ <div className="edit-topsites-wrapper">
+ {editForm && (
+ <div className="edit-topsites">
+ <ModalOverlayWrapper
+ unstyled={true}
+ onClose={this.onEditFormClose}
+ innerClassName="modal"
+ >
+ <TopSiteForm
+ site={props.TopSites.rows[editForm.index]}
+ onClose={this.onEditFormClose}
+ dispatch={this.props.dispatch}
+ {...editForm}
+ />
+ </ModalOverlayWrapper>
+ </div>
+ )}
+ {showSearchShortcutsForm && (
+ <div className="edit-search-shortcuts">
+ <ModalOverlayWrapper
+ unstyled={true}
+ onClose={this.onSearchShortcutsFormClose}
+ innerClassName="modal"
+ >
+ <SearchShortcutsForm
+ TopSites={props.TopSites}
+ onClose={this.onSearchShortcutsFormClose}
+ dispatch={this.props.dispatch}
+ />
+ </ModalOverlayWrapper>
+ </div>
+ )}
+ </div>
+ </CollapsibleSection>
+ </ComponentPerfTimer>
+ );
+ }
+}
+
+export const TopSites = connect((state, props) => ({
+ TopSites: state.TopSites,
+ Prefs: state.Prefs,
+ TopSitesRows: state.Prefs.values.topSitesRows,
+}))(_TopSites);
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js
new file mode 100644
index 0000000000..f488896238
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js
@@ -0,0 +1,39 @@
+/* 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/. */
+
+export const TOP_SITES_SOURCE = "TOP_SITES";
+export const TOP_SITES_CONTEXT_MENU_OPTIONS = [
+ "CheckPinTopSite",
+ "EditTopSite",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "DeleteUrl",
+];
+export const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "ShowPrivacyInfo",
+];
+export const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "AboutSponsored",
+];
+// the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite
+export const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = [
+ "CheckPinTopSite",
+ "Separator",
+ "BlockUrl",
+];
+// minimum size necessary to show a rich icon instead of a screenshot
+export const MIN_RICH_FAVICON_SIZE = 96;
+// minimum size necessary to show any icon
+export const MIN_SMALL_FAVICON_SIZE = 16;
diff --git a/browser/components/newtab/content-src/components/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss
new file mode 100644
index 0000000000..b893b6b33e
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss
@@ -0,0 +1,628 @@
+@use 'sass:math';
+
+$top-sites-size: $grid-unit-small;
+$top-sites-border-radius: 8px;
+$top-sites-icon-border-radius: 4px;
+$rich-icon-size: 96px;
+$default-icon-wrapper-size: 32px;
+$default-icon-size: 32px;
+$default-icon-offset: 6px;
+$half-base-gutter: math.div($base-gutter, 2);
+$hover-transition-duration: 150ms;
+$letter-fallback-color: $white;
+
+.top-sites-list {
+ list-style: none;
+ margin: 0 (-$half-base-gutter);
+ padding: 0;
+
+ a {
+ text-decoration: none;
+ }
+
+ &:not(.dnd-active) {
+ .top-site-outer:is(.active, :focus, :hover) {
+ background: var(--newtab-element-hover-color);
+ }
+ }
+
+ // Two columns
+ @media (max-width: $break-point-medium) {
+ > :nth-child(2n+1) {
+ @include context-menu-open-middle;
+ }
+
+ > :nth-child(2n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ // Four columns
+ @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+ :nth-child(4n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ @media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) {
+ :nth-child(4n+3) {
+ @include context-menu-open-left;
+ }
+ }
+
+ // Six columns
+ @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
+ :nth-child(6n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) {
+ :nth-child(6n+5) {
+ @include context-menu-open-left;
+ }
+ }
+
+ // Eight columns
+ @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
+ :nth-child(8n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) {
+ :nth-child(8n+7) {
+ @include context-menu-open-left;
+ }
+ }
+
+ .hide-for-narrow {
+ display: none;
+ }
+
+ @media (min-width: $break-point-medium) {
+ .hide-for-narrow {
+ display: inline-block;
+ }
+ }
+
+ @media (min-width: $break-point-large) {
+ .hide-for-narrow {
+ display: none;
+ }
+ }
+
+ @media (min-width: $break-point-widest) {
+ .hide-for-narrow {
+ display: inline-block;
+ }
+ }
+}
+
+// container for drop zone
+.top-site-outer {
+ width: 120px;
+ padding: 20px $half-base-gutter 4px;
+ border-radius: 8px;
+ display: inline-block;
+
+ // container for context menu
+ .top-site-inner {
+ position: relative;
+
+ > a {
+ color: inherit;
+ display: block;
+ outline: none;
+ }
+ }
+
+ &:is(:hover) {
+ .context-menu-button {
+ opacity: 1;
+ }
+ }
+
+ .context-menu-button {
+ background-image: url('chrome://global/skin/icons/more.svg');
+ border: 0;
+ border-radius: 4px;
+ cursor: pointer;
+ fill: var(--newtab-text-primary-color);
+ -moz-context-properties: fill;
+ height: 20px;
+ width: 20px;
+ inset-inline-end: -9px;
+ opacity: 0;
+ position: absolute;
+ top: -20px;
+ transition: opacity 200ms;
+
+ &:is(:active, :focus) {
+ outline: 0;
+ opacity: 1;
+ background-color: var(--newtab-element-hover-color);
+ fill: var(--newtab-primary-action-background);
+ }
+ }
+
+ .tile {
+ border-radius: $top-sites-border-radius;
+ box-shadow: $shadow-card;
+ background-color: var(--newtab-background-color-secondary);
+ justify-content: center;
+ margin: 0 auto;
+ height: $top-sites-size;
+ width: $top-sites-size;
+ cursor: pointer;
+ position: relative;
+
+ // For letter fallback
+ align-items: center;
+ color: var(--newtab-text-secondary-color);
+ display: flex;
+ font-size: 32px;
+ font-weight: 200;
+ text-transform: uppercase;
+
+ .icon-wrapper {
+ border-radius: 4px;
+ width: 48px;
+ height: 48px;
+ overflow: hidden;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &.letter-fallback::before {
+ content: attr(data-fallback);
+ text-transform: uppercase;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 64px;
+ font-weight: 800;
+ transform: rotate(-10deg);
+ top: 6px;
+ position: relative;
+ color: $letter-fallback-color;
+ }
+ }
+ }
+
+ // Some common styles for all icons (rich and default) in top sites
+ .top-site-icon {
+ background-color: var(--newtab-background-color-secondary);
+ background-position: center center;
+ background-repeat: no-repeat;
+ border-radius: $top-sites-icon-border-radius;
+ position: absolute;
+ }
+
+ .rich-icon {
+ background-size: cover;
+ height: 100%;
+ inset-inline-start: 0;
+ top: 0;
+ width: 100%;
+ }
+
+ .default-icon,
+ .search-topsite {
+ background-size: $default-icon-size;
+ height: $default-icon-wrapper-size;
+ width: $default-icon-wrapper-size;
+
+ // for corner letter fallback
+ align-items: center;
+ display: flex;
+ font-size: 20px;
+ justify-content: center;
+
+ &[data-fallback]::before {
+ content: attr(data-fallback);
+ }
+ }
+
+ .search-topsite {
+ background-image: url('chrome://global/skin/icons/search-glass.svg');
+ background-size: 16px;
+ background-color: var(--newtab-primary-action-background);
+ border-radius: $default-icon-wrapper-size;
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-element-text-color);
+ box-shadow: $shadow-card;
+ transition-duration: $hover-transition-duration;
+ transition-property: background-size, bottom, inset-inline-end, height, width;
+ height: 32px;
+ width: 32px;
+ bottom: -$default-icon-offset;
+ inset-inline-end: -$default-icon-offset;
+ }
+
+ &.placeholder {
+ .tile {
+ box-shadow: $inner-box-shadow;
+ }
+ }
+
+ .title {
+ color: var(--newtab-text-primary-color);
+ padding-top: 8px;
+ font: caption;
+ text-align: center;
+ position: relative;
+
+ .icon {
+ margin-inline-end: 2px;
+ fill: var(--newtab-text-primary-color);
+ }
+
+ span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .sponsored-label {
+ color: var(--newtab-text-secondary-color);
+ font-size: 0.9em;
+ }
+
+ &:not(.sponsored) .sponsored-label {
+ visibility: hidden;
+ }
+ }
+
+ // We want all search shortcuts to have a white background in case they have transparency.
+ &.search-shortcut {
+ .rich-icon {
+ background-color: $white;
+ }
+ }
+
+ .edit-button {
+ background-image: url('chrome://global/skin/icons/edit.svg');
+ }
+
+ &.dragged {
+ .tile {
+ *,
+ &::before {
+ display: none;
+ }
+ }
+
+ .title {
+ visibility: hidden;
+ }
+ }
+}
+
+.edit-topsites-wrapper {
+ .top-site-inner > .top-site-button > .tile {
+ border: 1px solid var(--newtab-border-color);
+ }
+
+ .modal {
+ box-shadow: $shadow-secondary;
+ left: 0;
+ margin: 0 auto;
+ max-height: calc(100% - 40px);
+ position: fixed;
+ right: 0;
+ top: 40px;
+ width: $wrapper-default-width;
+
+ @media (min-width: $break-point-medium) {
+ width: $wrapper-max-width-medium;
+ }
+
+ @media (min-width: $break-point-large) {
+ width: $wrapper-max-width-large;
+ }
+ }
+}
+
+.topsite-form {
+ $form-width: 300px;
+ $form-spacing: 32px;
+
+ .section-title {
+ font-size: 16px;
+ margin: 0 0 16px;
+ }
+
+ .form-input-container {
+ max-width: $form-width + 3 * $form-spacing + $rich-icon-size;
+ margin: 0 auto;
+ padding: $form-spacing;
+
+ .top-site-outer {
+ pointer-events: none;
+ }
+ }
+
+ .search-shortcuts-container {
+ max-width: 700px;
+ margin: 0 auto;
+ padding: $form-spacing;
+
+ > div {
+ margin-inline-end: -39px;
+ }
+
+ .top-site-outer {
+ margin-inline-start: 0;
+ margin-inline-end: 39px;
+ }
+ }
+
+ .top-site-outer {
+ padding: 0;
+ margin: 24px 0 0;
+ margin-inline-start: $form-spacing;
+ }
+
+ .fields-and-preview {
+ display: flex;
+ }
+
+ label {
+ font-size: $section-title-font-size;
+ }
+
+ .form-wrapper {
+ width: 100%;
+
+ .field {
+ position: relative;
+
+ .icon-clear-input {
+ position: absolute;
+ transform: translateY(-50%);
+ top: 50%;
+ inset-inline-end: 8px;
+ }
+ }
+
+ .url {
+ input:dir(ltr) {
+ padding-right: 32px;
+ }
+
+ input:dir(rtl) {
+ padding-left: 32px;
+
+ &:not(:placeholder-shown) {
+ direction: ltr;
+ text-align: right;
+ }
+ }
+ }
+
+ .enable-custom-image-input {
+ display: inline-block;
+ font-size: 13px;
+ margin-top: 4px;
+ cursor: pointer;
+ }
+
+ .custom-image-input-container {
+ margin-top: 4px;
+
+ .loading-container {
+ width: 16px;
+ height: 16px;
+ overflow: hidden;
+ position: absolute;
+ transform: translateY(-50%);
+ top: 50%;
+ inset-inline-end: 8px;
+ }
+
+ // This animation is derived from Firefox's tab loading animation
+ // See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216
+ .loading-animation {
+ @keyframes tab-throbber-animation {
+ 100% { transform: translateX(-960px); }
+ }
+
+ @keyframes tab-throbber-animation-rtl {
+ 100% { transform: translateX(960px); }
+ }
+
+ width: 960px;
+ height: 16px;
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-action-background);
+ background-image: url('chrome://browser/skin/tabbrowser/loading.svg');
+ animation: tab-throbber-animation 1.05s steps(60) infinite;
+
+ &:dir(rtl) {
+ animation-name: tab-throbber-animation-rtl;
+ }
+ }
+ }
+
+ input {
+ &[type='text'] {
+ background-color: var(--newtab-background-color-secondary);
+ border: $input-border;
+ margin: 8px 0;
+ padding: 0 8px;
+ height: 32px;
+ width: 100%;
+ font-size: 15px;
+
+ &[disabled] {
+ border: $input-border;
+ box-shadow: none;
+ opacity: 0.4;
+ }
+ }
+ }
+
+ .invalid {
+ input {
+ &[type='text'] {
+ border: $input-error-border;
+ box-shadow: $input-error-boxshadow;
+ }
+ }
+ }
+
+ .error-tooltip {
+ animation: fade-up-tt 450ms;
+ background: var(--newtab-status-error);
+ border-radius: 2px;
+ color: $white;
+ inset-inline-start: 3px;
+ padding: 5px 12px;
+ position: absolute;
+ top: 44px;
+ z-index: 1;
+
+ // tooltip caret
+ &::before {
+ background: var(--newtab-status-error);
+ bottom: -8px;
+ content: '.';
+ height: 16px;
+ inset-inline-start: 12px;
+ position: absolute;
+ text-indent: -999px;
+ top: -7px;
+ transform: rotate(45deg);
+ white-space: nowrap;
+ width: 16px;
+ z-index: -1;
+ }
+ }
+ }
+
+ .actions {
+ justify-content: flex-end;
+
+ button {
+ margin-inline-start: 10px;
+ margin-inline-end: 0;
+ }
+ }
+
+ @media (max-width: $break-point-medium) {
+ .fields-and-preview {
+ flex-direction: column;
+
+ .top-site-outer {
+ margin-inline-start: 0;
+ }
+ }
+ }
+
+ // prevent text selection of keyword label when clicking to select
+ .title {
+ user-select: none;
+ }
+
+ // CSS styled checkbox
+ [type='checkbox']:not(:checked),
+ [type='checkbox']:checked {
+ inset-inline-start: -9999px;
+ position: absolute;
+ }
+
+ [type='checkbox']:not(:checked) + label,
+ [type='checkbox']:checked + label {
+ cursor: pointer;
+ display: block;
+ position: relative;
+ }
+
+ $checkbox-offset: -8px;
+
+ [type='checkbox']:not(:checked) + label::before,
+ [type='checkbox']:checked + label::before {
+ background: var(--newtab-background-color);
+ border: $input-border;
+ border-radius: $border-radius;
+ content: '';
+ height: 21px;
+ left: $checkbox-offset;
+ position: absolute;
+ top: $checkbox-offset;
+ width: 21px;
+ z-index: 1;
+
+ [dir='rtl'] & {
+ left: auto;
+ right: $checkbox-offset;
+ }
+ }
+
+ // checkmark
+ [type='checkbox']:not(:checked) + label::after,
+ [type='checkbox']:checked + label::after {
+ background: url('chrome://global/skin/icons/check.svg') no-repeat center center;
+ content: '';
+ height: 21px;
+ left: $checkbox-offset;
+ position: absolute;
+ top: $checkbox-offset;
+ width: 21px;
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-action-background);
+ z-index: 2;
+
+ [dir='rtl'] & {
+ left: auto;
+ right: $checkbox-offset;
+ }
+ }
+
+ // when selected, highlight the tile
+ [type='checkbox']:checked + label {
+ .tile {
+ box-shadow: $shadow-focus;
+ }
+ }
+
+ // checkmark changes
+ [type='checkbox']:not(:checked) + label::after {
+ opacity: 0;
+ }
+
+ [type='checkbox']:checked + label::after {
+ opacity: 1;
+ }
+
+ // accessibility
+ [type='checkbox']:checked:focus + label::before,
+ [type='checkbox']:not(:checked):focus + label::before {
+ border: 1px dotted var(--newtab-primary-action-background);
+ }
+}
+
+// used for tooltips below form element
+@keyframes fade-up-tt {
+ 0% {
+ opacity: 0;
+ transform: translateY(15px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+// used for TopSites impression wrapper
+.topsite-impression-observer {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
diff --git a/browser/components/newtab/content-src/components/Topics/Topics.jsx b/browser/components/newtab/content-src/components/Topics/Topics.jsx
new file mode 100644
index 0000000000..ef59094c65
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Topics/Topics.jsx
@@ -0,0 +1,33 @@
+/* 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 React from "react";
+
+export class Topic extends React.PureComponent {
+ render() {
+ const { url, name } = this.props;
+ return (
+ <li>
+ <a key={name} href={url}>
+ {name}
+ </a>
+ </li>
+ );
+ }
+}
+
+export class Topics extends React.PureComponent {
+ render() {
+ const { topics } = this.props;
+ return (
+ <span className="topics">
+ <span data-l10n-id="newtab-pocket-read-more" />
+ <ul>
+ {topics &&
+ topics.map(t => <Topic key={t.name} url={t.url} name={t.name} />)}
+ </ul>
+ </span>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/Topics/_Topics.scss b/browser/components/newtab/content-src/components/Topics/_Topics.scss
new file mode 100644
index 0000000000..205f42e600
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Topics/_Topics.scss
@@ -0,0 +1,24 @@
+.topics {
+ ul {
+ margin: 0;
+ padding: 0;
+
+ @media (min-width: $break-point-large) {
+ display: inline;
+ padding-inline-start: 12px;
+ }
+ }
+
+ ul li {
+ display: inline-block;
+
+ &::after {
+ content: '•';
+ padding: 8px;
+ }
+
+ &:last-child::after {
+ content: none;
+ }
+ }
+}