summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/content-src
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/newtab/content-src
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/content-src')
-rw-r--r--browser/components/newtab/content-src/.eslintrc.js12
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx188
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss673
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx83
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx19
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx50
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx445
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx100
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx35
-rw-r--r--browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx60
-rw-r--r--browser/components/newtab/content-src/activity-stream.jsx54
-rw-r--r--browser/components/newtab/content-src/asrouter/README.md34
-rw-r--r--browser/components/newtab/content-src/asrouter/asrouter-content.jsx326
-rw-r--r--browser/components/newtab/content-src/asrouter/asrouter-utils.js108
-rw-r--r--browser/components/newtab/content-src/asrouter/components/Button/Button.jsx32
-rw-r--r--browser/components/newtab/content-src/asrouter/components/Button/_Button.scss94
-rw-r--r--browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx9
-rw-r--r--browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx76
-rw-r--r--browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx56
-rw-r--r--browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss104
-rw-r--r--browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx83
-rw-r--r--browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx121
-rw-r--r--browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss117
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.pngbin0 -> 257709 bytes
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/debugging-docs.md62
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/debugging-guide.pngbin0 -> 247644 bytes
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/experiment-guide.md52
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/first-run.md9
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/index.rst104
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/message-routing-overview.pngbin0 -> 50250 bytes
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst37
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md828
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/targeting-guide.md37
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.pngbin0 -> 104954 bytes
-rw-r--r--browser/components/newtab/content-src/asrouter/rich-text-strings.js44
-rw-r--r--browser/components/newtab/content-src/asrouter/schemas/message-format.md101
-rw-r--r--browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json63
-rw-r--r--browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json163
-rw-r--r--browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json75
-rw-r--r--browser/components/newtab/content-src/asrouter/template-utils.js21
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json75
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json365
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json96
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx153
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json159
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss54
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx38
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json187
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js30
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx34
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json177
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx52
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json142
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json39
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json36
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json97
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss131
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx76
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json234
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js38
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx133
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json110
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss198
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx225
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json155
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss135
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json163
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx409
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json225
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss176
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx24
-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.jsx1936
-rw-r--r--browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss273
-rw-r--r--browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx35
-rw-r--r--browser/components/newtab/content-src/components/Base/Base.jsx294
-rw-r--r--browser/components/newtab/content-src/components/Base/_Base.scss179
-rw-r--r--browser/components/newtab/content-src/components/Card/Card.jsx354
-rw-r--r--browser/components/newtab/content-src/components/Card/_Card.scss369
-rw-r--r--browser/components/newtab/content-src/components/Card/types.js30
-rw-r--r--browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx342
-rw-r--r--browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss188
-rw-r--r--browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx174
-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.jsx179
-rw-r--r--browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx72
-rw-r--r--browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss55
-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.jsx277
-rw-r--r--browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx38
-rw-r--r--browser/components/newtab/content-src/components/CustomizeMenu/ThemesSection/ThemesSection.jsx11
-rw-r--r--browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss297
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx393
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss70
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx109
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss164
-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.scss46
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx323
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss313
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx85
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss109
-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.scss68
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx97
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss87
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx157
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss23
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx90
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss33
-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.scss45
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx69
-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.jsx167
-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.scss119
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx207
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss282
-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.scss40
-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/List/List.jsx221
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss269
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx73
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss64
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx62
-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.scss26
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx157
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss119
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx223
-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.jsx108
-rw-r--r--browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx21
-rw-r--r--browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss22
-rw-r--r--browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx42
-rw-r--r--browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss39
-rw-r--r--browser/components/newtab/content-src/components/Search/Search.jsx208
-rw-r--r--browser/components/newtab/content-src/components/Search/_Search.scss516
-rw-r--r--browser/components/newtab/content-src/components/SectionMenu/SectionMenu.jsx122
-rw-r--r--browser/components/newtab/content-src/components/Sections/Sections.jsx390
-rw-r--r--browser/components/newtab/content-src/components/Sections/_Sections.scss135
-rw-r--r--browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx189
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSite.jsx823
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx330
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx111
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSites.jsx241
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js43
-rw-r--r--browser/components/newtab/content-src/components/TopSites/_TopSites.scss803
-rw-r--r--browser/components/newtab/content-src/components/Topics/Topics.jsx33
-rw-r--r--browser/components/newtab/content-src/components/Topics/_Topics.scss23
-rw-r--r--browser/components/newtab/content-src/lib/aboutwelcome-utils.js235
-rw-r--r--browser/components/newtab/content-src/lib/constants.js32
-rw-r--r--browser/components/newtab/content-src/lib/detect-user-session-start.js78
-rw-r--r--browser/components/newtab/content-src/lib/init-store.js175
-rw-r--r--browser/components/newtab/content-src/lib/link-menu-options.js276
-rw-r--r--browser/components/newtab/content-src/lib/perf-service.js104
-rw-r--r--browser/components/newtab/content-src/lib/screenshot-utils.js61
-rw-r--r--browser/components/newtab/content-src/lib/section-menu-options.js93
-rw-r--r--browser/components/newtab/content-src/lib/selectLayoutRender.js260
-rw-r--r--browser/components/newtab/content-src/styles/_OnboardingImages.scss71
-rw-r--r--browser/components/newtab/content-src/styles/_activity-stream.scss179
-rw-r--r--browser/components/newtab/content-src/styles/_icons.scss208
-rw-r--r--browser/components/newtab/content-src/styles/_mixins.scss50
-rw-r--r--browser/components/newtab/content-src/styles/_normalize.scss29
-rw-r--r--browser/components/newtab/content-src/styles/_theme.scss231
-rw-r--r--browser/components/newtab/content-src/styles/_variables.scss323
-rw-r--r--browser/components/newtab/content-src/styles/activity-stream-linux.scss13
-rw-r--r--browser/components/newtab/content-src/styles/activity-stream-mac.scss17
-rw-r--r--browser/components/newtab/content-src/styles/activity-stream-windows.scss13
178 files changed, 25582 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/.eslintrc.js b/browser/components/newtab/content-src/.eslintrc.js
new file mode 100644
index 0000000000..bf7904cc43
--- /dev/null
+++ b/browser/components/newtab/content-src/.eslintrc.js
@@ -0,0 +1,12 @@
+/* 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/. */
+
+/* eslint-disable import/no-commonjs */
+
+module.exports = {
+ rules: {
+ "import/no-commonjs": 2,
+ "react/jsx-no-bind": 0,
+ },
+};
diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx
new file mode 100644
index 0000000000..d9503584b9
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx
@@ -0,0 +1,188 @@
+/* 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 ReactDOM from "react-dom";
+import { MultiStageAboutWelcome } from "./components/MultiStageAboutWelcome";
+import { SimpleAboutWelcome } from "./components/SimpleAboutWelcome";
+import { ReturnToAMO } from "./components/ReturnToAMO";
+
+import {
+ AboutWelcomeUtils,
+ DEFAULT_WELCOME_CONTENT,
+} from "../lib/aboutwelcome-utils";
+
+class AboutWelcome extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { metricsFlowUri: null };
+ this.fetchFxAFlowUri = this.fetchFxAFlowUri.bind(this);
+ this.handleStartBtnClick = this.handleStartBtnClick.bind(this);
+ }
+
+ async fetchFxAFlowUri() {
+ this.setState({ metricsFlowUri: await window.AWGetFxAMetricsFlowURI() });
+ }
+
+ componentDidMount() {
+ this.fetchFxAFlowUri();
+
+ // Record impression with performance data after allowing the page to load
+ const recordImpression = domState => {
+ const { domComplete, domInteractive } = performance
+ .getEntriesByType("navigation")
+ .pop();
+ window.AWSendEventTelemetry({
+ event: "IMPRESSION",
+ event_context: {
+ domComplete,
+ domInteractive,
+ mountStart: performance.getEntriesByName("mount").pop().startTime,
+ domState,
+ source: this.props.UTMTerm,
+ page: "about:welcome",
+ },
+ message_id: this.props.messageId,
+ });
+ };
+ if (document.readyState === "complete") {
+ // Page might have already triggered a load event because it waited for async data,
+ // e.g., attribution, so the dom load timing could be of a empty content
+ // with domState in telemetry captured as 'complete'
+ recordImpression(document.readyState);
+ } else {
+ window.addEventListener("load", () => recordImpression("load"), {
+ once: true,
+ });
+ }
+
+ // Captures user has seen about:welcome by setting
+ // firstrun.didSeeAboutWelcome pref to true and capturing welcome UI unique messageId
+ window.AWSendToParent("SET_WELCOME_MESSAGE_SEEN", this.props.messageId);
+ }
+
+ handleStartBtnClick() {
+ AboutWelcomeUtils.handleUserAction(this.props.startButton.action);
+ const ping = {
+ event: "CLICK_BUTTON",
+ event_context: {
+ source: this.props.startButton.message_id,
+ page: "about:welcome",
+ },
+ message_id: this.props.messageId,
+ id: "ABOUT_WELCOME",
+ };
+ window.AWSendEventTelemetry(ping);
+ }
+
+ render() {
+ const { props } = this;
+ if (props.template === "simplified") {
+ return (
+ <SimpleAboutWelcome
+ metricsFlowUri={this.state.metricsFlowUri}
+ message_id={props.messageId}
+ utm_term={props.UTMTerm}
+ title={props.title}
+ subtitle={props.subtitle}
+ cards={props.cards}
+ startButton={props.startButton}
+ handleStartBtnClick={this.handleStartBtnClick}
+ />
+ );
+ } else if (props.template === "return_to_amo") {
+ return (
+ <ReturnToAMO
+ message_id={props.messageId}
+ name={props.name}
+ url={props.url}
+ iconURL={props.iconURL}
+ />
+ );
+ }
+
+ return (
+ <MultiStageAboutWelcome
+ screens={props.screens}
+ metricsFlowUri={this.state.metricsFlowUri}
+ message_id={props.messageId}
+ utm_term={props.UTMTerm}
+ />
+ );
+ }
+}
+
+AboutWelcome.defaultProps = DEFAULT_WELCOME_CONTENT;
+
+// Computes messageId and UTMTerm info used in telemetry
+function ComputeTelemetryInfo(welcomeContent, experimentId, branchId) {
+ let messageId =
+ welcomeContent.template === "return_to_amo"
+ ? "RTAMO_DEFAULT_WELCOME"
+ : "DEFAULT_ABOUTWELCOME";
+ let UTMTerm = "default";
+
+ if (welcomeContent.id) {
+ messageId = welcomeContent.id.toUpperCase();
+ }
+
+ if (experimentId && branchId) {
+ UTMTerm = `${experimentId}-${branchId}`.toLowerCase();
+ }
+ return {
+ messageId,
+ UTMTerm,
+ };
+}
+
+async function retrieveRenderContent() {
+ // Check for override content in pref browser.aboutwelcome.overrideContent
+ let aboutWelcomeProps = await window.AWGetWelcomeOverrideContent();
+ if (aboutWelcomeProps?.template) {
+ let { messageId, UTMTerm } = ComputeTelemetryInfo(aboutWelcomeProps);
+ return { aboutWelcomeProps, messageId, UTMTerm };
+ }
+
+ // Check for experiment and retrieve content
+ const { slug, branch } = await window.AWGetExperimentData();
+ aboutWelcomeProps = branch?.feature ? branch.feature.value : {};
+
+ // Check if there is any attribution data, this could take a while to await in series
+ // especially when there is an add-on that requires remote lookup
+ // Moving RTAMO as part of another screen of multistage is one option to fix the delay
+ // as it will allow the initial page to be fast while we fetch attribution data in parallel for a later screen.
+ const attribution = await window.AWGetAttributionData();
+ if (attribution?.template) {
+ aboutWelcomeProps = {
+ ...aboutWelcomeProps,
+ // If part of an experiment, render experiment template
+ template: aboutWelcomeProps?.template
+ ? aboutWelcomeProps.template
+ : attribution.template,
+ ...attribution.extraProps,
+ };
+ }
+
+ let { messageId, UTMTerm } = ComputeTelemetryInfo(
+ aboutWelcomeProps,
+ slug,
+ branch && branch.slug
+ );
+ return { aboutWelcomeProps, messageId, UTMTerm };
+}
+
+async function mount() {
+ let { aboutWelcomeProps, messageId, UTMTerm } = await retrieveRenderContent();
+ ReactDOM.render(
+ <AboutWelcome
+ messageId={messageId}
+ UTMTerm={UTMTerm}
+ {...aboutWelcomeProps}
+ />,
+ document.getElementById("root")
+ );
+}
+
+performance.mark("mount");
+mount();
diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss
new file mode 100644
index 0000000000..501b57952f
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss
@@ -0,0 +1,673 @@
+// sass-lint:disable no-css-comments
+/* 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 '../styles/normalize';
+@import '../styles/OnboardingImages';
+
+$break-point-medium: 610px;
+$break-point-large: 866px;
+$break-point-widest: 1122px;
+$logo-size: 112px;
+
+html {
+ height: 100%;
+}
+
+body {
+ // sass-lint:disable no-color-literals
+ --grey-subtitle: #4A4A4F;
+ --grey-subtitle-1: #696977;
+ --newtab-background-color: #EDEDF0;
+ --newtab-background-color-1: #F9F9FA;
+ --newtab-text-primary-color: #0C0C0D;
+ --newtab-text-conditional-color: #4A4A4F;
+ --newtab-button-primary-color: #0060DF;
+ --newtab-button-secondary-color: #0060DF;
+ --newtab-card-background-color: #FFF;
+ --newtab-card-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4);
+ --tiles-theme-section-border-width: 1px;
+ --welcome-header-text-color: #2B2156;
+ --welcome-header-text-color-1: #20133A;
+ --welcome-card-button-background-color: rgba(12, 12, 13, 0.1);
+ --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.2);
+ --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.3);
+ --welcome-button-box-shadow-color: #0A84FF;
+ --welcome-button-box-shadow-inset-color: rgba(10, 132, 255, 0.3);
+ --welcome-button-text-color: #FFF;
+ --welcome-button-background-hover-color: #003EAA;
+ --welcome-button-background-active-color: #002275;
+ --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #F9F9FA, #F9F9FA);
+
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu',
+ 'Helvetica Neue', sans-serif;
+ font-size: 16px;
+ position: relative;
+ /* these two rules fix test failures in
+ "browser_ext_themes_ntp_colors" & "browser_ext_themes_ntp_colors_perwindow".*/
+ color: var(--newtab-text-primary-color);
+ background-color: var(--newtab-background-color);
+
+ &[lwt-newtab-brighttext] {
+ --newtab-background-color: #2A2A2E;
+ --newtab-background-color-1: #1D1133;
+ --newtab-text-primary-color: #F9F9FA;
+ --newtab-text-conditional-color: #F9F9FA;
+ --grey-subtitle-1: #FFF;
+ --newtab-button-primary-color: #0060DF;
+ --newtab-button-secondary-color: #FFF;
+ --newtab-card-background-color: #38383D;
+ --newtab-card-shadow: 0 1px 8px 0 rgba(12, 12, 13, 0.4);
+ --welcome-header-text-color: rgba(255, 255, 255, 0.6);
+ --welcome-header-text-color-1: #7542E5;
+ --welcome-card-button-background-color: rgba(12, 12, 13, 0.3);
+ --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.5);
+ --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.7);
+ --welcome-button-box-shadow-color: #0A84FF;
+ --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #1D1133, #1D1133);
+ }
+}
+
+.welcomeCardGrid {
+ margin: 0;
+ margin-top: 32px;
+ display: grid;
+ grid-gap: 32px;
+ transition: opacity 0.4s;
+ transition-delay: 0.1s;
+ grid-auto-rows: 1fr;
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(auto-fit, 224px);
+ }
+
+ @media (min-width: $break-point-widest) {
+ grid-template-columns: repeat(auto-fit, 309px);
+ }
+}
+
+.welcomeContainer {
+ text-align: center;
+
+ @media (min-width: $break-point-medium) {
+ max-height: 1000px;
+ }
+
+ h1 {
+ font-size: 36px;
+ font-weight: 200;
+ margin: 0 0 40px;
+ color: var(--welcome-header-text-color);
+ }
+
+ .welcome-title {
+ margin-bottom: 5px;
+ line-height: 52px;
+ }
+
+ .welcome-subtitle {
+ font-size: 28px;
+ font-weight: 200;
+ margin: 6px 0 0;
+ color: var(--grey-subtitle);
+ line-height: 42px;
+ }
+}
+
+.welcomeContainerInner {
+ margin: auto;
+ padding: 40px 25px;
+
+ @media (min-width: $break-point-medium) {
+ width: 530px;
+ }
+
+ @media (min-width: $break-point-large) {
+ width: 786px;
+ }
+
+ @media (min-width: $break-point-widest) {
+ width: 1042px;
+ }
+}
+
+.welcomeCard {
+ position: relative;
+ background: var(--newtab-card-background-color);
+ border-radius: 4px;
+ box-shadow: var(--newtab-card-shadow);
+ font-size: 13px;
+ padding: 20px 20px 60px;
+
+ @media (max-width: $break-point-large) {
+ padding: 20px;
+ }
+
+ @media (min-width: $break-point-widest) {
+ font-size: 15px;
+ }
+}
+
+.welcomeCard .onboardingTitle {
+ font-weight: normal;
+ color: var(--newtab-text-primary-color);
+ margin: 10px 0 4px;
+ font-size: 15px;
+
+ @media (min-width: $break-point-widest) {
+ font-size: 18px;
+ }
+}
+
+.welcomeCard .onboardingText {
+ margin: 0 0 60px;
+ color: var(--newtab-text-conditional-color);
+ line-height: 1.5;
+ font-weight: 200;
+}
+
+.welcomeCard .onboardingButton {
+ color: var(--newtab-text-conditional-color);
+ background: var(--welcome-card-button-background-color);
+ border: 0;
+ border-radius: 4px;
+ margin: 14px;
+ min-width: 70%;
+ padding: 6px 14px;
+ white-space: pre-wrap;
+ cursor: pointer;
+
+ &:focus,
+ &:hover {
+ box-shadow: none;
+ background: var(--welcome-card-button-background-hover-color);
+ }
+
+ &:focus {
+ outline: dotted 1px;
+ }
+
+ &:active {
+ background: var(--welcome-card-button-background-active-color);
+ }
+}
+
+.welcomeCard .onboardingButtonContainer {
+ position: absolute;
+ bottom: 16px;
+ left: 0;
+ width: 100%;
+ text-align: center;
+}
+
+.onboardingMessageImage {
+ height: 112px;
+ width: 180px;
+ background-size: auto 140px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ display: inline-block;
+
+ @media (max-width: $break-point-large) {
+ height: 75px;
+ min-width: 80px;
+ background-size: 140px;
+ }
+}
+
+.start-button {
+ border: 0;
+ font-size: 15px;
+ font-family: inherit;
+ font-weight: 200;
+ margin-inline-start: 12px;
+ margin: 30px 0 25px;
+ padding: 8px 16px;
+ white-space: nowrap;
+ background-color: var(--newtab-button-primary-color);
+ color: var(--welcome-button-text-color);
+ cursor: pointer;
+ border-radius: 2px;
+
+ &:focus {
+ background: var(--welcome-button-background-hover-color);
+ box-shadow: 0 0 0 1px var(--welcome-button-box-shadow-inset-color) inset,
+ 0 0 0 1px var(--welcome-button-box-shadow-inset-color),
+ 0 0 0 4px var(--welcome-button-box-shadow-color);
+ }
+
+ &:hover {
+ background: var(--welcome-button-background-hover-color);
+ }
+
+ &:active {
+ background: var(--welcome-button-background-active-color);
+ }
+}
+
+
+.onboardingContainer {
+ text-align: center;
+ overflow-x: auto;
+ height: 100vh;
+ background-color: var(--newtab-background-color-1);
+
+ .screen {
+ display: flex;
+ flex-flow: column nowrap;
+ height: 100%;
+ }
+
+ .brand-logo {
+ background: url('chrome://branding/content/about-logo.svg') top
+ center / $logo-size no-repeat;
+ padding: $logo-size 0 20px;
+ margin-top: 60px;
+
+ &.cta-top {
+ margin-top: 25px;
+ }
+ }
+
+ .welcomeZap {
+ span {
+ position: relative;
+ z-index: 1;
+ white-space: nowrap;
+ }
+
+ .zap {
+ &::after {
+ display: block;
+ background-repeat: no-repeat;
+ background-size: 100% 100%;
+ content: '';
+ position: absolute;
+ top: calc(100% - 0.15em);
+ width: 100%;
+ height: 0.3em;
+ left: 0;
+ z-index: -1;
+ }
+
+ &.short::after {
+ background-image: url('chrome://activity-stream/content/data/content/assets/short-zap.svg');
+ }
+
+ &.long::after {
+ background-image: url('chrome://activity-stream/content/data/content/assets/long-zap.svg');
+ }
+ }
+
+
+ }
+
+ .welcome-text {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 20px;
+
+ h1,
+ h2 {
+ width: 860px;
+ @media (max-width: $break-point-large) {
+ width: 530px;
+ }
+
+ @media (max-width: $break-point-medium) {
+ width: 430px;
+ }
+ }
+
+ h1 {
+ font-size: 48px;
+ line-height: 56px;
+ font-weight: bold;
+ margin: 0 6px;
+ color: var(--welcome-header-text-color-1);
+ }
+
+ h2 {
+ font-size: 18px;
+ font-weight: normal;
+ margin: 10px 6px 0;
+ color: var(--grey-subtitle-1);
+ line-height: 28px;
+ max-width: 750px;
+ letter-spacing: -0.01em;
+ }
+
+ img {
+ margin-inline: 2px;
+ width: 20px;
+ height: 20px;
+ }
+ }
+
+ .tiles-theme-container {
+ margin: 10px auto;
+ border: 0;
+ }
+
+ .sr-only {
+ opacity: 0;
+ overflow: hidden;
+ position: absolute;
+
+ &.input {
+ height: 1px;
+ width: 1px;
+ }
+ }
+
+ .tiles-theme-section {
+ display: grid;
+ grid-gap: 21px;
+ grid-template-columns: repeat(4, auto);
+
+ /* --newtab-background-color-1 will be invisible, but it's necessary to
+ * keep the content from jumping around when it gets focus-within and
+ * does sprout a dotted border. This way it keeps a 1 pixel wide border
+ * either way so things don't change position.
+ */
+ border: var(--tiles-theme-section-border-width)
+ solid
+ var(--newtab-background-color-1);
+
+ @media (max-width: $break-point-medium) {
+ grid-template-columns: repeat(2, auto);
+ }
+
+ &:focus-within {
+ border: var(--tiles-theme-section-border-width) dotted;
+ }
+
+ .theme {
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ width: 180px;
+ height: 145px;
+ color: #000;
+ background-color: #FFF;
+ box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4);
+ border-radius: 4px;
+ cursor: pointer;
+
+ .icon {
+ background-size: cover;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ height: 91px;
+
+ &:dir(rtl) {
+ transform: scaleX(-1);
+ }
+
+ &.light {
+ background-image: url('chrome://mozapps/content/extensions/firefox-compact-light.svg');
+ }
+
+ &.dark {
+ background-image: url('chrome://mozapps/content/extensions/firefox-compact-dark.svg');
+ }
+
+ &.automatic {
+ background-image: url('chrome://mozapps/content/extensions/default-theme.svg');
+ }
+
+ &.alpenglow {
+ background-image: url('chrome://mozapps/content/extensions/firefox-alpenglow.svg');
+ }
+ }
+
+ .text {
+ display: flex;
+ font-size: 14px;
+ font-weight: bold;
+ line-height: 22px;
+ margin-inline-start: 12px;
+ margin-top: 9px;
+ }
+
+ &.selected {
+ outline: 4px solid #0090ED;
+ outline-offset: -4px;
+ }
+
+ &:focus,
+ &:active {
+ outline: 4px solid #0090ED;
+ outline-offset: -4px;
+ }
+ }
+ }
+
+ .tiles-container {
+ margin: 10px auto;
+
+ &.info {
+ padding: 6px 12px 12px;
+
+ &:hover,
+ &:focus {
+ background-color: rgba(217, 217, 227, 0.3);
+ border-radius: 4px;
+ }
+ }
+ }
+
+ .tiles-topsites-section {
+ $host-size: 12px;
+ $tile-size: 96px;
+
+ display: grid;
+ grid-gap: $tile-size / 4;
+ grid-template-columns: repeat(5, auto);
+
+ @media (max-width: $break-point-medium) {
+ grid-template-columns: repeat(3, auto);
+ }
+
+ .site {
+ width: $tile-size;
+ }
+
+ .icon {
+ background-size: cover;
+ border-radius: 4px;
+ box-shadow: var(--newtab-card-shadow);
+ color: rgba(255, 255, 255, 0.5);
+ font-size: $host-size * 2;
+ font-weight: bold;
+ height: $tile-size;
+ line-height: $tile-size;
+ }
+
+ .host {
+ font-size: $host-size;
+ line-height: $host-size * 3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .site:nth-child(1) .icon {
+ background-color: #7542E5;
+ }
+
+ .site:nth-child(2) .icon {
+ background-color: #952BB9;
+ }
+
+ .site:nth-child(3) .icon {
+ background-color: #E31587;
+ }
+
+ .site:nth-child(4) .icon {
+ background-color: #E25920;
+ }
+
+ .site:nth-child(5) .icon {
+ background-color: #0250BB;
+ }
+ }
+
+ // "tiles-media-section" styles here will support tiles of type
+ // "video" when the screen JSON it set in the below format:
+
+ // "tiles": {
+ // "type": "video",
+ // "source": {
+ // "default" : "<media-file-uri>",
+ // "dark" : "<media-file-dark-mode-uri>"
+ // }
+ // }
+
+ .tiles-media-section {
+ align-self: center;
+ position: relative;
+ margin-top: -12px;
+ margin-bottom: -155px;
+
+ .fade {
+ height: 390px;
+ width: 800px;
+ position: absolute;
+ background-image: var(--about-welcome-media-fade);
+ }
+
+ .media {
+ height: 390px;
+ width: 800px;
+ }
+
+ &.privacy {
+ background: top no-repeat url('chrome://activity-stream/content/data/content/assets/firefox-protections.svg');
+ height: 200px;
+ width: 800px;
+ margin: 0;
+
+ &.media {
+ opacity: 0;
+ }
+ }
+ }
+
+ button {
+ font-family: inherit;
+ cursor: pointer;
+ border: 0;
+ border-radius: 4px;
+
+ &.primary {
+ font-size: 16px;
+ margin-inline-start: 12px;
+ margin: 20px 0 0;
+ padding: 12px 20px;
+ white-space: nowrap;
+ background-color: var(--newtab-button-primary-color);
+ color: var(--welcome-button-text-color);
+ fill: currentColor;
+ position: relative;
+ z-index: 1;
+ // This transparent border will show up in Windows High Contrast Mode to improve accessibility.
+ border: 1px solid transparent;
+
+ &:focus {
+ background: var(--welcome-button-background-hover-color);
+ box-shadow: 0 0 0 4px var(--welcome-button-box-shadow-color);
+ }
+
+ &:hover {
+ background: var(--welcome-button-background-hover-color);
+ }
+
+ &:active {
+ background: var(--welcome-button-background-active-color);
+ }
+ }
+
+ &.secondary {
+ background-color: initial;
+ text-decoration: underline;
+ display: block;
+ padding: 0;
+ width: auto;
+ color: var(--newtab-button-secondary-color);
+ margin-top: 14px;
+
+ &:hover,
+ &:active {
+ background-color: initial;
+ }
+ }
+ }
+
+ .secondary-cta {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ font-size: 14px;
+
+ &.top {
+ justify-content: end;
+ align-items: end;
+ padding-inline-end: 30px;
+ padding-top: 4px;
+
+ @media (max-width: $break-point-medium) {
+ justify-content: center;
+ }
+ }
+
+ span {
+ color: var(--grey-subtitle-1);
+ margin: 0 4px;
+ }
+ }
+
+ .helptext {
+ padding: 1em;
+ text-align: center;
+ color: var(--grey-subtitle-1);
+ font-size: 12px;
+ line-height: 18px;
+
+ &.default {
+ align-self: center;
+ max-width: 40%;
+ }
+ }
+
+ .steps {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ margin-top: auto;
+ padding: 32px 0 66px;
+ z-index: 1;
+
+ &.has-helptext {
+ padding-bottom: 0;
+ }
+
+ .indicator {
+ width: 60px;
+ height: 4px;
+ margin-inline-end: 4px;
+ margin-inline-start: 4px;
+ background: var(--grey-subtitle-1);
+ border-radius: 5px;
+ // This transparent border will show up in Windows High Contrast Mode to improve accessibility.
+ border: 1px solid transparent;
+ opacity: 0.25;
+
+ &.current {
+ opacity: 1;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx b/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx
new file mode 100644
index 0000000000..4275c59c8f
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx
@@ -0,0 +1,83 @@
+/* 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 { addUtmParams } from "../../asrouter/templates/FirstRun/addUtmParams";
+import { OnboardingCard } from "../../asrouter/templates/OnboardingMessage/OnboardingMessage";
+import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils";
+
+export class FxCards extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = { flowParams: null };
+
+ this.fetchFxAFlowParams = this.fetchFxAFlowParams.bind(this);
+ this.onCardAction = this.onCardAction.bind(this);
+ }
+
+ componentDidUpdate() {
+ this.fetchFxAFlowParams();
+ }
+
+ componentDidMount() {
+ this.fetchFxAFlowParams();
+ }
+
+ async fetchFxAFlowParams() {
+ if (this.state.flowParams || !this.props.metricsFlowUri) {
+ return;
+ }
+
+ const flowParams = await AboutWelcomeUtils.fetchFlowParams(
+ this.props.metricsFlowUri
+ );
+
+ this.setState({ flowParams });
+ }
+
+ onCardAction(action) {
+ let { type, data } = action;
+ let UTMTerm = `aboutwelcome-${this.props.utm_term}-card`;
+
+ if (action.type === "OPEN_URL") {
+ let url = new URL(action.data.args);
+ addUtmParams(url, UTMTerm);
+
+ if (action.addFlowParams && this.state.flowParams) {
+ url.searchParams.append("device_id", this.state.flowParams.deviceId);
+ url.searchParams.append("flow_id", this.state.flowParams.flowId);
+ url.searchParams.append(
+ "flow_begin_time",
+ this.state.flowParams.flowBeginTime
+ );
+ }
+
+ data = { ...data, args: url.toString() };
+ }
+
+ AboutWelcomeUtils.handleUserAction({ type, data });
+ }
+
+ render() {
+ const { props } = this;
+ return (
+ <React.Fragment>
+ <div className={`welcomeCardGrid show`}>
+ {props.cards.map(card => (
+ <OnboardingCard
+ key={card.id}
+ message={card}
+ className="welcomeCard"
+ sendUserActionTelemetry={props.sendTelemetry}
+ onAction={this.onCardAction}
+ UISurface="ABOUT_WELCOME"
+ {...card}
+ />
+ ))}
+ </div>
+ </React.Fragment>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx b/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx
new file mode 100644
index 0000000000..bb3a296d54
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/components/HeroText.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";
+import { Localized } from "./MSLocalized";
+
+export const HeroText = props => {
+ return (
+ <React.Fragment>
+ <Localized text={props.title}>
+ <h1 className="welcome-title" />
+ </Localized>
+ <Localized text={props.subtitle}>
+ <h2 className="welcome-subtitle" />
+ </Localized>
+ </React.Fragment>
+ );
+};
diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx
new file mode 100644
index 0000000000..694a294028
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx
@@ -0,0 +1,50 @@
+/* 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 MS_STRING_PROP = "string_id";
+
+/**
+ * Based on the .text prop, localizes an inner element if a string_id
+ * is provided, OR renders plain text, OR hides it if nothing is provided.
+ *
+ * Examples:
+ *
+ * Localized text
+ * ftl:
+ * title = Welcome
+ * jsx:
+ * <Localized text={{string_id: "title"}}><h1 /></Localized>
+ * output:
+ * <h1 data-l10n-id="title">Welcome</h1>
+ *
+ * Unlocalized text
+ * jsx:
+ * <Localized text="Welcome"><h1 /></Localized>
+ * output:
+ * <h1>Welcome</h1>
+ */
+
+export const Localized = ({ text, children }) => {
+ if (!text) {
+ return null;
+ }
+
+ let props = children ? children.props : {};
+ let textNode;
+
+ if (typeof text === "object" && text[MS_STRING_PROP]) {
+ props = { ...props };
+ props["data-l10n-id"] = text[MS_STRING_PROP];
+ } else if (typeof text === "string") {
+ textNode = text;
+ }
+
+ if (!children) {
+ return React.createElement("span", props, textNode);
+ } else if (textNode) {
+ return React.cloneElement(children, props, textNode);
+ }
+ return React.cloneElement(children, props);
+};
diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx
new file mode 100644
index 0000000000..fde65d3673
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx
@@ -0,0 +1,445 @@
+/* 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, useEffect, useRef } from "react";
+import { Localized } from "./MSLocalized";
+import { Zap } from "./Zap";
+import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils";
+import {
+ BASE_PARAMS,
+ addUtmParams,
+} from "../../asrouter/templates/FirstRun/addUtmParams";
+
+export const MultiStageAboutWelcome = props => {
+ const [index, setScreenIndex] = useState(0);
+ useEffect(() => {
+ // Send impression ping when respective screen first renders
+ props.screens.forEach(screen => {
+ if (index === screen.order) {
+ AboutWelcomeUtils.sendImpressionTelemetry(
+ `${props.message_id}_${screen.id}`
+ );
+ }
+ });
+
+ // Remember that a new screen has loaded for browser navigation
+ if (index > window.history.state) {
+ window.history.pushState(index, "");
+ }
+ }, [index]);
+
+ useEffect(() => {
+ // Switch to the screen tracked in state (null for initial state)
+ const handler = ({ state }) => setScreenIndex(Number(state));
+
+ // Handle page load, e.g., going back to about:welcome from about:home
+ handler(window.history);
+
+ // Watch for browser back/forward button navigation events
+ window.addEventListener("popstate", handler);
+ return () => window.removeEventListener("popstate", handler);
+ }, []);
+
+ const [flowParams, setFlowParams] = useState(null);
+ const { metricsFlowUri } = props;
+ useEffect(() => {
+ (async () => {
+ if (metricsFlowUri) {
+ setFlowParams(await AboutWelcomeUtils.fetchFlowParams(metricsFlowUri));
+ }
+ })();
+ }, [metricsFlowUri]);
+
+ // Transition to next screen, opening about:home on last screen button CTA
+ const handleTransition =
+ index < props.screens.length - 1
+ ? () => setScreenIndex(prevState => prevState + 1)
+ : () =>
+ AboutWelcomeUtils.handleUserAction({
+ type: "OPEN_ABOUT_PAGE",
+ data: { args: "home", where: "current" },
+ });
+
+ // Update top sites with default sites by region when region is available
+ const [region, setRegion] = useState(null);
+ useEffect(() => {
+ (async () => {
+ setRegion(await window.AWGetRegion());
+ })();
+ }, []);
+
+ // Get the active theme so the rendering code can make it selected
+ // by default.
+ const [activeTheme, setActiveTheme] = useState(null);
+ const [initialTheme, setInitialTheme] = useState(null);
+ useEffect(() => {
+ (async () => {
+ let theme = await window.AWGetSelectedTheme();
+ setInitialTheme(theme);
+ setActiveTheme(theme);
+ })();
+ }, []);
+
+ const useImportable = props.message_id.includes("IMPORTABLE");
+ // Track whether we have already sent the importable sites impression telemetry
+ const importTelemetrySent = useRef(false);
+ const [topSites, setTopSites] = useState([]);
+ useEffect(() => {
+ (async () => {
+ let DEFAULT_SITES = await window.AWGetDefaultSites();
+ const importable = JSON.parse(await window.AWGetImportableSites());
+ const showImportable = useImportable && importable.length >= 5;
+ if (!importTelemetrySent.current) {
+ AboutWelcomeUtils.sendImpressionTelemetry(`${props.message_id}_SITES`, {
+ display: showImportable ? "importable" : "static",
+ importable: importable.length,
+ });
+ importTelemetrySent.current = true;
+ }
+ setTopSites(
+ showImportable
+ ? { data: importable, showImportable }
+ : { data: DEFAULT_SITES, showImportable }
+ );
+ })();
+ }, [useImportable, region]);
+
+ return (
+ <React.Fragment>
+ <div className={`outer-wrapper onboardingContainer`}>
+ {props.screens.map(screen => {
+ return index === screen.order ? (
+ <WelcomeScreen
+ key={screen.id}
+ id={screen.id}
+ totalNumberOfScreens={props.screens.length}
+ order={screen.order}
+ content={screen.content}
+ navigate={handleTransition}
+ topSites={topSites}
+ messageId={`${props.message_id}_${screen.id}`}
+ UTMTerm={props.utm_term}
+ flowParams={flowParams}
+ activeTheme={activeTheme}
+ initialTheme={initialTheme}
+ setActiveTheme={setActiveTheme}
+ />
+ ) : null;
+ })}
+ </div>
+ </React.Fragment>
+ );
+};
+
+export class WelcomeScreen extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleAction = this.handleAction.bind(this);
+ }
+
+ handleOpenURL(action, flowParams, UTMTerm) {
+ let { type, data } = action;
+ if (type === "SHOW_FIREFOX_ACCOUNTS") {
+ let params = {
+ ...BASE_PARAMS,
+ utm_term: `aboutwelcome-${UTMTerm}-screen`,
+ };
+ if (action.addFlowParams && flowParams) {
+ params = {
+ ...params,
+ ...flowParams,
+ };
+ }
+ data = { ...data, extraParams: params };
+ } else if (type === "OPEN_URL") {
+ let url = new URL(data.args);
+ addUtmParams(url, `aboutwelcome-${UTMTerm}-screen`);
+ if (action.addFlowParams && flowParams) {
+ url.searchParams.append("device_id", flowParams.deviceId);
+ url.searchParams.append("flow_id", flowParams.flowId);
+ url.searchParams.append("flow_begin_time", flowParams.flowBeginTime);
+ }
+ data = { ...data, args: url.toString() };
+ }
+ AboutWelcomeUtils.handleUserAction({ type, data });
+ }
+
+ async handleAction(event) {
+ let { props } = this;
+
+ let targetContent =
+ props.content[event.currentTarget.value] || props.content.tiles;
+ if (!(targetContent && targetContent.action)) {
+ return;
+ }
+
+ // Send telemetry before waiting on actions
+ AboutWelcomeUtils.sendActionTelemetry(
+ props.messageId,
+ event.currentTarget.value
+ );
+
+ let { action } = targetContent;
+
+ if (["OPEN_URL", "SHOW_FIREFOX_ACCOUNTS"].includes(action.type)) {
+ this.handleOpenURL(action, props.flowParams, props.UTMTerm);
+ } else if (action.type) {
+ AboutWelcomeUtils.handleUserAction(action);
+ // Wait until migration closes to complete the action
+ if (action.type === "SHOW_MIGRATION_WIZARD") {
+ await window.AWWaitForMigrationClose();
+ AboutWelcomeUtils.sendActionTelemetry(props.messageId, "migrate_close");
+ }
+ }
+
+ // A special tiles.action.theme value indicates we should use the event's value vs provided value.
+ if (action.theme) {
+ let themeToUse =
+ action.theme === "<event>"
+ ? event.currentTarget.value
+ : this.props.initialTheme || action.theme;
+
+ this.props.setActiveTheme(themeToUse);
+ window.AWSelectTheme(themeToUse);
+ }
+
+ if (action.navigate) {
+ props.navigate();
+ }
+ }
+
+ renderSecondaryCTA(className) {
+ return (
+ <div
+ className={className ? `secondary-cta ${className}` : `secondary-cta`}
+ >
+ <Localized text={this.props.content.secondary_button.text}>
+ <span />
+ </Localized>
+ <Localized text={this.props.content.secondary_button.label}>
+ <button
+ className="secondary"
+ value="secondary_button"
+ onClick={this.handleAction}
+ />
+ </Localized>
+ </div>
+ );
+ }
+
+ renderTiles() {
+ switch (this.props.content.tiles.type) {
+ case "topsites":
+ return this.props.topSites && this.props.topSites.data ? (
+ <div
+ className={`tiles-container ${
+ this.props.content.tiles.info ? "info" : ""
+ }`}
+ >
+ <div
+ className="tiles-topsites-section"
+ name="topsites-section"
+ id="topsites-section"
+ aria-labelledby="helptext"
+ role="region"
+ >
+ {this.props.topSites.data
+ .slice(0, 5)
+ .map(({ icon, label, title }) => (
+ <div
+ className="site"
+ key={icon + label}
+ aria-label={title ? title : label}
+ role="img"
+ >
+ <div
+ className="icon"
+ style={
+ icon
+ ? {
+ backgroundColor: "transparent",
+ backgroundImage: `url(${icon})`,
+ }
+ : {}
+ }
+ >
+ {icon ? "" : label && label[0].toUpperCase()}
+ </div>
+ {this.props.content.tiles.showTitles && (
+ <div className="host">{title || label}</div>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ ) : null;
+ case "theme":
+ return this.props.content.tiles.data ? (
+ <div className="tiles-theme-container">
+ <div>
+ <fieldset className="tiles-theme-section">
+ <Localized text={this.props.content.subtitle}>
+ <legend className="sr-only" />
+ </Localized>
+ {this.props.content.tiles.data.map(
+ ({ theme, label, tooltip, description }) => (
+ <Localized
+ key={theme + label}
+ text={typeof tooltip === "object" ? tooltip : {}}
+ >
+ <label
+ className={`theme${
+ theme === this.props.activeTheme ? " selected" : ""
+ }`}
+ title={theme + label}
+ >
+ <Localized
+ text={
+ typeof description === "object" ? description : {}
+ }
+ >
+ <input
+ type="radio"
+ value={theme}
+ name="theme"
+ checked={theme === this.props.activeTheme}
+ className="sr-only input"
+ onClick={this.handleAction}
+ data-l10n-attrs="aria-description"
+ />
+ </Localized>
+ <div className={`icon ${theme}`} />
+ {label && (
+ <Localized text={label}>
+ <div className="text" />
+ </Localized>
+ )}
+ </label>
+ </Localized>
+ )
+ )}
+ </fieldset>
+ </div>
+ </div>
+ ) : null;
+ case "video":
+ return this.props.content.tiles.source ? (
+ <div
+ className={`tiles-media-section ${this.props.content.tiles.media_type}`}
+ >
+ <div className="fade" />
+ <video
+ className="media"
+ autoPlay="true"
+ loop="true"
+ muted="true"
+ src={
+ AboutWelcomeUtils.hasDarkMode()
+ ? this.props.content.tiles.source.dark
+ : this.props.content.tiles.source.default
+ }
+ />
+ </div>
+ ) : null;
+ case "image":
+ return this.props.content.tiles.source ? (
+ <div className={`${this.props.content.tiles.media_type}`}>
+ <img
+ src={
+ AboutWelcomeUtils.hasDarkMode() &&
+ this.props.content.tiles.source.dark
+ ? this.props.content.tiles.source.dark
+ : this.props.content.tiles.source.default
+ }
+ role="presentation"
+ alt=""
+ />
+ </div>
+ ) : null;
+ }
+ return null;
+ }
+
+ renderStepsIndicator() {
+ let steps = [];
+ for (let i = 0; i < this.props.totalNumberOfScreens; i++) {
+ let className = i === this.props.order ? "current" : "";
+ steps.push(<div key={i} className={`indicator ${className}`} />);
+ }
+ return steps;
+ }
+
+ renderHelpText() {
+ return (
+ <Localized text={this.props.content.help_text.text}>
+ <p
+ id="helptext"
+ className={`helptext ${this.props.content.help_text.position}`}
+ />
+ </Localized>
+ );
+ }
+
+ render() {
+ const { content, topSites } = this.props;
+ const hasSecondaryTopCTA =
+ content.secondary_button && content.secondary_button.position === "top";
+ const showImportableSitesDisclaimer =
+ content.tiles &&
+ content.tiles.type === "topsites" &&
+ topSites &&
+ topSites.showImportable;
+
+ return (
+ <main className={`screen ${this.props.id}`}>
+ {hasSecondaryTopCTA ? this.renderSecondaryCTA("top") : null}
+ <div className={`brand-logo ${hasSecondaryTopCTA ? "cta-top" : ""}`} />
+ <div className="welcome-text">
+ <Zap hasZap={content.zap} text={content.title} />
+ <Localized text={content.subtitle}>
+ <h2 />
+ </Localized>
+ </div>
+ {content.tiles ? this.renderTiles() : null}
+ <div>
+ <Localized
+ text={content.primary_button ? content.primary_button.label : null}
+ >
+ <button
+ className="primary"
+ value="primary_button"
+ onClick={this.handleAction}
+ />
+ </Localized>
+ </div>
+ {content.secondary_button && content.secondary_button.position !== "top"
+ ? this.renderSecondaryCTA()
+ : null}
+ {content.help_text && content.help_text.position === "default"
+ ? this.renderHelpText()
+ : null}
+ <nav
+ className={
+ (content.help_text && content.help_text.position === "footer") ||
+ showImportableSitesDisclaimer
+ ? "steps has-helptext"
+ : "steps"
+ }
+ data-l10n-id={"onboarding-welcome-steps-indicator"}
+ data-l10n-args={`{"current": ${parseInt(this.props.order, 10) +
+ 1}, "total": ${this.props.totalNumberOfScreens}}`}
+ >
+ {/* These empty elements are here to help trigger the nav for screen readers. */}
+ <br />
+ <p />
+ {this.renderStepsIndicator()}
+ </nav>
+ {(content.help_text && content.help_text.position === "footer") ||
+ showImportableSitesDisclaimer
+ ? this.renderHelpText()
+ : null}
+ </main>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx b/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx
new file mode 100644
index 0000000000..2d5501dc46
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.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 React from "react";
+import {
+ AboutWelcomeUtils,
+ DEFAULT_RTAMO_CONTENT,
+} from "../../lib/aboutwelcome-utils";
+import { Localized } from "./MSLocalized";
+
+export class ReturnToAMO extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onClickAddExtension = this.onClickAddExtension.bind(this);
+ this.handleStartBtnClick = this.handleStartBtnClick.bind(this);
+ }
+
+ onClickAddExtension() {
+ const { content, message_id, url } = this.props;
+ if (!content?.primary_button?.action?.data) {
+ return;
+ }
+
+ // Set add-on url in action.data.url property from JSON
+ content.primary_button.action.data.url = url;
+ AboutWelcomeUtils.handleUserAction(content.primary_button.action);
+ const ping = {
+ event: "INSTALL",
+ event_context: {
+ source: "ADD_EXTENSION_BUTTON",
+ page: "about:welcome",
+ },
+ message_id,
+ };
+ window.AWSendEventTelemetry(ping);
+ }
+
+ handleStartBtnClick() {
+ const { content, message_id } = this.props;
+ AboutWelcomeUtils.handleUserAction(content.startButton.action);
+ const ping = {
+ event: "CLICK_BUTTON",
+ event_context: {
+ source: content.startButton.message_id,
+ page: "about:welcome",
+ },
+ message_id,
+ };
+ window.AWSendEventTelemetry(ping);
+ }
+
+ render() {
+ const { content } = this.props;
+ if (!content) {
+ return null;
+ }
+ // For experiments, when needed below rendered UI allows settings hard coded strings
+ // directly inside JSON except for ReturnToAMOText which picks add-on name and icon from fluent string
+ return (
+ <div className="outer-wrapper onboardingContainer">
+ <main className="screen">
+ <div className="brand-logo" />
+ <div className="welcome-text">
+ <Localized text={content.subtitle}>
+ <h1 />
+ </Localized>
+ <Localized text={content.text}>
+ <h2
+ data-l10n-args={
+ this.props.name
+ ? JSON.stringify({ "addon-name": this.props.name })
+ : null
+ }
+ >
+ <img
+ data-l10n-name="icon"
+ src={this.props.iconURL}
+ role="presentation"
+ alt=""
+ />
+ </h2>
+ </Localized>
+ <Localized text={content.primary_button.label}>
+ <button onClick={this.onClickAddExtension} className="primary" />
+ </Localized>
+ <Localized text={content.startButton.label}>
+ <button
+ onClick={this.handleStartBtnClick}
+ className="secondary"
+ />
+ </Localized>
+ </div>
+ </main>
+ </div>
+ );
+ }
+}
+
+ReturnToAMO.defaultProps = DEFAULT_RTAMO_CONTENT;
diff --git a/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx
new file mode 100644
index 0000000000..4186cb2d69
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.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";
+import { HeroText } from "./HeroText";
+import { FxCards } from "./FxCards";
+import { Localized } from "./MSLocalized";
+
+export class SimpleAboutWelcome extends React.PureComponent {
+ render() {
+ const { props } = this;
+ return (
+ <div className="outer-wrapper welcomeContainer">
+ <div className="welcomeContainerInner">
+ <main>
+ <HeroText title={props.title} subtitle={props.subtitle} />
+ <FxCards
+ cards={props.cards}
+ metricsFlowUri={this.props.metricsFlowUri}
+ sendTelemetry={window.AWSendEventTelemetry}
+ utm_term={this.props.UTMTerm}
+ />
+ <Localized text={props.startButton.label}>
+ <button
+ className="start-button"
+ onClick={this.props.handleStartBtnClick}
+ />
+ </Localized>
+ </main>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx b/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx
new file mode 100644
index 0000000000..a067c4d7fe
--- /dev/null
+++ b/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx
@@ -0,0 +1,60 @@
+/* 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, { useEffect } from "react";
+import { Localized } from "./MSLocalized";
+const MS_STRING_PROP = "string_id";
+const ZAP_SIZE_THRESHOLD = 160;
+
+function calculateZapLength() {
+ let span = document.querySelector(".zap");
+ if (!span) {
+ return;
+ }
+ let rect = span.getBoundingClientRect();
+ if (rect && rect.width > ZAP_SIZE_THRESHOLD) {
+ span.classList.add("long");
+ } else {
+ span.classList.add("short");
+ }
+}
+
+export const Zap = props => {
+ useEffect(() => {
+ requestAnimationFrame(() => calculateZapLength());
+ });
+
+ if (!props.text) {
+ return null;
+ }
+
+ if (props.hasZap) {
+ if (typeof props.text === "object" && props.text[MS_STRING_PROP]) {
+ return (
+ <Localized text={props.text}>
+ <h1 className="welcomeZap">
+ <span data-l10n-name="zap" className="zap" />
+ </h1>
+ </Localized>
+ );
+ } else if (typeof props.text === "string") {
+ // Parse string to zap style last word of the props.text
+ let titleArray = props.text.split(" ");
+ let lastWord = `${titleArray.pop()}`;
+ return (
+ <h1 className="welcomeZap">
+ {titleArray.join(" ").concat(" ")}
+ <span className="zap">{lastWord}</span>
+ </h1>
+ );
+ }
+ } else {
+ return (
+ <Localized text={props.text}>
+ <h1 />
+ </Localized>
+ );
+ }
+ return null;
+};
diff --git a/browser/components/newtab/content-src/activity-stream.jsx b/browser/components/newtab/content-src/activity-stream.jsx
new file mode 100644
index 0000000000..a49bfa7dce
--- /dev/null
+++ b/browser/components/newtab/content-src/activity-stream.jsx
@@ -0,0 +1,54 @@
+/* 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.jsm";
+import { Base } from "content-src/components/Base/Base";
+import { DetectUserSessionStart } from "content-src/lib/detect-user-session-start";
+import { initStore } from "content-src/lib/init-store";
+import { Provider } from "react-redux";
+import React from "react";
+import ReactDOM from "react-dom";
+import { reducers } from "common/Reducers.jsm";
+
+export const NewTab = ({ store }) => (
+ <Provider store={store}>
+ <Base />
+ </Provider>
+);
+
+export function renderWithoutState() {
+ const store = initStore(reducers);
+ new DetectUserSessionStart(store).sendEventOrAddListener();
+
+ // If this document has already gone into the background by the time we've reached
+ // here, we can deprioritize requesting the initial state until the event loop
+ // frees up. If, however, the visibility changes, we then send the request.
+ let didRequest = false;
+ let requestIdleCallbackId = 0;
+ function doRequest() {
+ if (!didRequest) {
+ if (requestIdleCallbackId) {
+ cancelIdleCallback(requestIdleCallbackId);
+ }
+ didRequest = true;
+ store.dispatch(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST }));
+ }
+ }
+
+ if (document.hidden) {
+ requestIdleCallbackId = requestIdleCallback(doRequest);
+ addEventListener("visibilitychange", doRequest, { once: true });
+ } else {
+ doRequest();
+ }
+
+ ReactDOM.hydrate(<NewTab store={store} />, document.getElementById("root"));
+}
+
+export function renderCache(initialState) {
+ const store = initStore(reducers, initialState);
+ new DetectUserSessionStart(store).sendEventOrAddListener();
+
+ ReactDOM.hydrate(<NewTab store={store} />, document.getElementById("root"));
+}
diff --git a/browser/components/newtab/content-src/asrouter/README.md b/browser/components/newtab/content-src/asrouter/README.md
new file mode 100644
index 0000000000..0ee3345630
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/README.md
@@ -0,0 +1,34 @@
+# Activity Stream Router
+
+## Preferences `browser.newtab.activity-stream.asrouter.*`
+
+Name | Used for | Type | Example value
+--- | --- | --- | ---
+`allowHosts` | Allow a host in order to fetch messages from its endpoint | `[String]` | `["gist.github.com", "gist.githubusercontent.com", "localhost:8000"]`
+`providers.snippets` | Message provider options for snippets | `Object` | [see below](#message-providers)
+`providers.cfr` | Message provider options for cfr | `Object` | [see below](#message-providers)
+`providers.onboarding` | Message provider options for onboarding | `Object` | [see below](#message-providers)
+`useRemoteL10n` | Controls whether to use the remote Fluent files for l10n, default as `true` | `Boolean` | `[true|false]`
+
+### Message providers examples
+
+```json
+{
+ "id" : "snippets",
+ "type" : "remote",
+ "enabled": true,
+ "url" : "https://snippets.cdn.mozilla.net/us-west/bundles/bundle_d6d90fb9098ce8b45e60acf601bcb91b68322309.json",
+ "updateCycleInMs" : 14400000
+}
+```
+
+```json
+{
+ "id" : "onboarding",
+ "enabled": true,
+ "type" : "local",
+ "localProvider" : "OnboardingMessageProvider"
+}
+```
+
+### [Snippet message format documentation](https://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/schemas/message-format.md)
diff --git a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx
new file mode 100644
index 0000000000..0ad8999ebc
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx
@@ -0,0 +1,326 @@
+/* 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 { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.jsm";
+import { actionTypes as at } from "common/Actions.jsm";
+import { ASRouterUtils } from "./asrouter-utils";
+import { generateBundles } from "./rich-text-strings";
+import { ImpressionsWrapper } from "./components/ImpressionsWrapper/ImpressionsWrapper";
+import { LocalizationProvider } from "fluent-react";
+import { NEWTAB_DARK_THEME } from "content-src/lib/constants";
+import React from "react";
+import ReactDOM from "react-dom";
+import { SnippetsTemplates } from "./templates/template-manifest";
+
+const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"];
+
+// Note: nextProps/prevProps refer to props passed to <ImpressionsWrapper />, not <ASRouterUISurface />
+function shouldSendImpressionOnUpdate(nextProps, prevProps) {
+ return (
+ nextProps.message.id &&
+ (!prevProps.message || prevProps.message.id !== nextProps.message.id)
+ );
+}
+
+export class ASRouterUISurface extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.sendClick = this.sendClick.bind(this);
+ this.sendImpression = this.sendImpression.bind(this);
+ this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this);
+ this.onUserAction = this.onUserAction.bind(this);
+ this.fetchFlowParams = this.fetchFlowParams.bind(this);
+ this.onBlockSelected = this.onBlockSelected.bind(this);
+ this.onBlockById = this.onBlockById.bind(this);
+ this.onDismiss = this.onDismiss.bind(this);
+ this.onMessageFromParent = this.onMessageFromParent.bind(this);
+
+ this.state = { message: {} };
+ if (props.document) {
+ this.footerPortal = props.document.getElementById(
+ "footer-asrouter-container"
+ );
+ }
+ }
+
+ async fetchFlowParams(params = {}) {
+ let result = {};
+ const { fxaEndpoint } = this.props;
+ if (!fxaEndpoint) {
+ const err =
+ "Tried to fetch flow params before fxaEndpoint pref was ready";
+ console.error(err); // eslint-disable-line no-console
+ }
+
+ try {
+ const urlObj = new URL(fxaEndpoint);
+ urlObj.pathname = "metrics-flow";
+ Object.keys(params).forEach(key => {
+ urlObj.searchParams.append(key, params[key]);
+ });
+ const response = await fetch(urlObj.toString(), { credentials: "omit" });
+ if (response.status === 200) {
+ const { deviceId, flowId, flowBeginTime } = await response.json();
+ result = { deviceId, flowId, flowBeginTime };
+ } else {
+ console.error("Non-200 response", response); // eslint-disable-line no-console
+ }
+ } catch (error) {
+ console.error(error); // eslint-disable-line no-console
+ }
+ return result;
+ }
+
+ sendUserActionTelemetry(extraProps = {}) {
+ const { message } = this.state;
+ const eventType = `${message.provider}_user_event`;
+ const source = extraProps.id;
+ delete extraProps.id;
+ ASRouterUtils.sendTelemetry({
+ source,
+ message_id: message.id,
+ action: eventType,
+ ...extraProps,
+ });
+ }
+
+ sendImpression(extraProps) {
+ if (this.state.message.provider === "preview") {
+ return Promise.resolve();
+ }
+
+ this.sendUserActionTelemetry({ event: "IMPRESSION", ...extraProps });
+ return ASRouterUtils.sendMessage({
+ type: msg.IMPRESSION,
+ data: this.state.message,
+ });
+ }
+
+ // If link has a `metric` data attribute send it as part of the `event_context`
+ // telemetry field which can have arbitrary values.
+ // Used for router messages with links as part of the content.
+ sendClick(event) {
+ const { dataset } = event.target;
+ const metric = {
+ event_context: dataset.metric,
+ // Used for the `source` of the event. Needed to differentiate
+ // from other snippet or onboarding events that may occur.
+ id: "NEWTAB_FOOTER_BAR_CONTENT",
+ };
+ const { entrypoint_name, entrypoint_value } = dataset;
+ // Assign the snippet referral for the action
+ const entrypoint = entrypoint_name
+ ? new URLSearchParams([[entrypoint_name, entrypoint_value]]).toString()
+ : entrypoint_value;
+ const action = {
+ type: dataset.action,
+ data: {
+ args: dataset.args,
+ ...(entrypoint && { entrypoint }),
+ },
+ };
+ if (action.type) {
+ ASRouterUtils.executeAction(action);
+ }
+ if (
+ !this.state.message.content.do_not_autoblock &&
+ !dataset.do_not_autoblock
+ ) {
+ this.onBlockById(this.state.message.id);
+ }
+ if (this.state.message.provider !== "preview") {
+ this.sendUserActionTelemetry({ event: "CLICK_BUTTON", ...metric });
+ }
+ }
+
+ onBlockSelected(options) {
+ return this.onBlockById(this.state.message.id, options);
+ }
+
+ onBlockById(id, options) {
+ return ASRouterUtils.blockById(id, options).then(clearAll => {
+ if (clearAll) {
+ this.setState({ message: {} });
+ }
+ });
+ }
+
+ onDismiss() {
+ this.clearMessage(this.state.message.id);
+ }
+
+ clearMessage(id) {
+ if (id === this.state.message.id) {
+ this.setState({ message: {} });
+ }
+ }
+
+ clearProvider(id) {
+ if (this.state.message.provider === id) {
+ this.setState({ message: {} });
+ }
+ }
+
+ onMessageFromParent({ type, data }) {
+ // These only exists due to onPrefChange events in ASRouter
+ switch (type) {
+ case "ClearMessages": {
+ data.forEach(id => this.clearMessage(id));
+ break;
+ }
+ case "ClearProviders": {
+ data.forEach(id => this.clearProvider(id));
+ break;
+ }
+ case "EnterSnippetsPreviewMode": {
+ this.props.dispatch({ type: at.SNIPPETS_PREVIEW_MODE });
+ break;
+ }
+ }
+ }
+
+ requestMessage(endpoint) {
+ ASRouterUtils.sendMessage({
+ type: "NEWTAB_MESSAGE_REQUEST",
+ data: { endpoint },
+ }).then(state => this.setState(state));
+ }
+
+ componentWillMount() {
+ const endpoint = ASRouterUtils.getPreviewEndpoint();
+ if (endpoint && endpoint.theme === "dark") {
+ global.window.dispatchEvent(
+ new CustomEvent("LightweightTheme:Set", {
+ detail: { data: NEWTAB_DARK_THEME },
+ })
+ );
+ }
+ if (endpoint && endpoint.dir === "rtl") {
+ //Set `dir = rtl` on the HTML
+ this.props.document.dir = "rtl";
+ }
+ ASRouterUtils.addListener(this.onMessageFromParent);
+ this.requestMessage(endpoint);
+ }
+
+ componentWillUnmount() {
+ ASRouterUtils.removeListener(this.onMessageFromParent);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.adminContent &&
+ JSON.stringify(prevProps.adminContent) !==
+ JSON.stringify(this.props.adminContent)
+ ) {
+ this.updateContent();
+ }
+ }
+
+ updateContent() {
+ this.setState({
+ ...this.props.adminContent,
+ });
+ }
+
+ async getMonitorUrl({ url, flowRequestParams = {} }) {
+ const flowValues = await this.fetchFlowParams(flowRequestParams);
+
+ // Note that flowParams are actually added dynamically on the page
+ const urlObj = new URL(url);
+ ["deviceId", "flowId", "flowBeginTime"].forEach(key => {
+ if (key in flowValues) {
+ urlObj.searchParams.append(key, flowValues[key]);
+ }
+ });
+
+ return urlObj.toString();
+ }
+
+ async onUserAction(action) {
+ switch (action.type) {
+ // This needs to be handled locally because its
+ case "ENABLE_FIREFOX_MONITOR":
+ const url = await this.getMonitorUrl(action.data.args);
+ ASRouterUtils.executeAction({ type: "OPEN_URL", data: { args: url } });
+ break;
+ default:
+ ASRouterUtils.executeAction(action);
+ }
+ }
+
+ renderSnippets() {
+ const { message } = this.state;
+ if (!SnippetsTemplates[message.template]) {
+ return null;
+ }
+ const SnippetComponent = SnippetsTemplates[message.template];
+ const { content } = this.state.message;
+
+ return (
+ <ImpressionsWrapper
+ id="NEWTAB_FOOTER_BAR"
+ message={this.state.message}
+ sendImpression={this.sendImpression}
+ shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate}
+ // This helps with testing
+ document={this.props.document}
+ >
+ <LocalizationProvider bundles={generateBundles(content)}>
+ <SnippetComponent
+ {...this.state.message}
+ UISurface="NEWTAB_FOOTER_BAR"
+ onBlock={this.onBlockSelected}
+ onDismiss={this.onDismiss}
+ onAction={this.onUserAction}
+ sendClick={this.sendClick}
+ sendUserActionTelemetry={this.sendUserActionTelemetry}
+ />
+ </LocalizationProvider>
+ </ImpressionsWrapper>
+ );
+ }
+
+ renderPreviewBanner() {
+ if (this.state.message.provider !== "preview") {
+ return null;
+ }
+
+ return (
+ <div className="snippets-preview-banner">
+ <span className="icon icon-small-spacer icon-info" />
+ <span>Preview Purposes Only</span>
+ </div>
+ );
+ }
+
+ render() {
+ const { message } = this.state;
+ if (!message.id) {
+ return null;
+ }
+ const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes(
+ message.template
+ );
+
+ return shouldRenderBelowSearch ? (
+ // Render special below search snippets in place;
+ <div className="below-search-snippet-wrapper">
+ {this.renderSnippets()}
+ </div>
+ ) : (
+ // For regular snippets etc. we should render everything in our footer
+ // container.
+ ReactDOM.createPortal(
+ <>
+ {this.renderPreviewBanner()}
+ {this.renderSnippets()}
+ </>,
+ this.footerPortal
+ )
+ );
+ }
+}
+
+ASRouterUISurface.defaultProps = { document: global.document };
diff --git a/browser/components/newtab/content-src/asrouter/asrouter-utils.js b/browser/components/newtab/content-src/asrouter/asrouter-utils.js
new file mode 100644
index 0000000000..fe7f0110f2
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/asrouter-utils.js
@@ -0,0 +1,108 @@
+/* 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 { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.jsm";
+import { actionCreators as ac } from "common/Actions.jsm";
+
+export const ASRouterUtils = {
+ addListener(listener) {
+ if (global.ASRouterAddParentListener) {
+ global.ASRouterAddParentListener(listener);
+ }
+ },
+ removeListener(listener) {
+ if (global.ASRouterRemoveParentListener) {
+ global.ASRouterRemoveParentListener(listener);
+ }
+ },
+ sendMessage(action) {
+ if (global.ASRouterMessage) {
+ return global.ASRouterMessage(action);
+ }
+ throw new Error(`Unexpected call:\n${JSON.stringify(action, null, 3)}`);
+ },
+ blockById(id, options) {
+ return ASRouterUtils.sendMessage({
+ type: msg.BLOCK_MESSAGE_BY_ID,
+ data: { id, ...options },
+ });
+ },
+ modifyMessageJson(content) {
+ return ASRouterUtils.sendMessage({
+ type: msg.MODIFY_MESSAGE_JSON,
+ data: { content },
+ });
+ },
+ executeAction(button_action) {
+ return ASRouterUtils.sendMessage({
+ type: msg.USER_ACTION,
+ data: button_action,
+ });
+ },
+ unblockById(id) {
+ return ASRouterUtils.sendMessage({
+ type: msg.UNBLOCK_MESSAGE_BY_ID,
+ data: { id },
+ });
+ },
+ blockBundle(bundle) {
+ return ASRouterUtils.sendMessage({
+ type: msg.BLOCK_BUNDLE,
+ data: { bundle },
+ });
+ },
+ unblockBundle(bundle) {
+ return ASRouterUtils.sendMessage({
+ type: msg.UNBLOCK_BUNDLE,
+ data: { bundle },
+ });
+ },
+ overrideMessage(id) {
+ return ASRouterUtils.sendMessage({
+ type: msg.OVERRIDE_MESSAGE,
+ data: { id },
+ });
+ },
+ sendTelemetry(ping) {
+ return ASRouterUtils.sendMessage(ac.ASRouterUserEvent(ping));
+ },
+ getPreviewEndpoint() {
+ if (
+ global.document &&
+ global.document.location &&
+ global.document.location.href.includes("endpoint")
+ ) {
+ const params = new URLSearchParams(
+ global.document.location.href.slice(
+ global.document.location.href.indexOf("endpoint")
+ )
+ );
+ try {
+ const endpoint = new URL(params.get("endpoint"));
+ return {
+ url: endpoint.href,
+ snippetId: params.get("snippetId"),
+ theme: this.getPreviewTheme(),
+ dir: this.getPreviewDir(),
+ };
+ } catch (e) {}
+ }
+
+ return null;
+ },
+ getPreviewTheme() {
+ return new URLSearchParams(
+ global.document.location.href.slice(
+ global.document.location.href.indexOf("theme")
+ )
+ ).get("theme");
+ },
+ getPreviewDir() {
+ return new URLSearchParams(
+ global.document.location.href.slice(
+ global.document.location.href.indexOf("dir")
+ )
+ ).get("dir");
+ },
+};
diff --git a/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx
new file mode 100644
index 0000000000..b3ece86f16
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx
@@ -0,0 +1,32 @@
+/* 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 ALLOWED_STYLE_TAGS = ["color", "backgroundColor"];
+
+export const Button = props => {
+ const style = {};
+
+ // Add allowed style tags from props, e.g. props.color becomes style={color: props.color}
+ for (const tag of ALLOWED_STYLE_TAGS) {
+ if (typeof props[tag] !== "undefined") {
+ style[tag] = props[tag];
+ }
+ }
+ // remove border if bg is set to something custom
+ if (style.backgroundColor) {
+ style.border = "0";
+ }
+
+ return (
+ <button
+ onClick={props.onClick}
+ className={props.className || "ASRouterButton secondary"}
+ style={style}
+ >
+ {props.children}
+ </button>
+ );
+};
diff --git a/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss
new file mode 100644
index 0000000000..330bfbb4fb
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss
@@ -0,0 +1,94 @@
+.ASRouterButton {
+ font-weight: 600;
+ font-size: 14px;
+ white-space: nowrap;
+ border-radius: 2px;
+ border: 0;
+ font-family: inherit;
+ padding: 8px 15px;
+ margin-inline-start: 12px;
+ color: inherit;
+ cursor: pointer;
+
+ .tall & {
+ margin-inline-start: 20px;
+ }
+
+ &.test-only {
+ width: 0;
+ height: 0;
+ overflow: hidden;
+ display: block;
+ visibility: hidden;
+ }
+
+ &.primary {
+ border: 1px solid var(--newtab-button-primary-color);
+ background-color: var(--newtab-button-primary-color);
+ color: $grey-10;
+
+ &:hover {
+ background-color: $blue-70;
+ }
+
+ &:active {
+ background-color: $blue-80;
+ }
+ }
+
+ &.secondary {
+ background-color: $grey-90-10;
+
+ &:hover {
+ background-color: $grey-90-20;
+ }
+
+ &:active {
+ background-color: $grey-90-30;
+ }
+
+ &:focus {
+ box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30;
+ }
+ }
+
+ &.slim {
+ background-color: $grey-90-10;
+ margin-inline-start: 0;
+ font-size: 12px;
+ padding: 6px 12px;
+
+ &:hover {
+ background-color: $grey-90-20;
+ }
+ }
+}
+
+[lwt-newtab-brighttext] {
+ .secondary {
+ background-color: $grey-10-10;
+
+ &:hover {
+ background-color: $grey-10-20;
+ }
+
+ &:active {
+ background-color: $grey-10-30;
+ }
+ }
+
+ // Snippets scene 2 footer
+ .footer {
+ .secondary {
+ background-color: $grey-10-30;
+
+ &:hover {
+ background-color: $grey-10-40;
+ }
+
+ &:active {
+ background-color: $grey-10-50;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx
new file mode 100644
index 0000000000..e4b0812f26
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx
@@ -0,0 +1,9 @@
+/* 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/. */
+
+// lifted from https://gist.github.com/kitze/23d82bb9eb0baabfd03a6a720b1d637f
+const ConditionalWrapper = ({ condition, wrap, children }) =>
+ condition && wrap ? wrap(children) : children;
+
+export default ConditionalWrapper;
diff --git a/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx
new file mode 100644
index 0000000000..8498bde03b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx
@@ -0,0 +1,76 @@
+/* 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 const VISIBLE = "visible";
+export const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+/**
+ * Component wrapper used to send telemetry pings on every impression.
+ */
+export class ImpressionsWrapper extends React.PureComponent {
+ // 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.
+ sendImpressionOrAddListener() {
+ if (this.props.document.visibilityState === VISIBLE) {
+ this.props.sendImpression({ id: this.props.id });
+ } else {
+ // We should only ever send the latest impression stats ping, so remove any
+ // older listeners.
+ if (this._onVisibilityChange) {
+ this.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 (this.props.document.visibilityState === VISIBLE) {
+ this.props.sendImpression({ id: this.props.id });
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ };
+ this.props.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ componentDidMount() {
+ if (this.props.sendOnMount) {
+ this.sendImpressionOrAddListener();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) {
+ this.sendImpressionOrAddListener();
+ }
+ }
+
+ render() {
+ return this.props.children;
+ }
+}
+
+ImpressionsWrapper.defaultProps = {
+ document: global.document,
+ sendOnMount: true,
+};
diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx
new file mode 100644
index 0000000000..fdfdf22db2
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx
@@ -0,0 +1,56 @@
+/* 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 ModalOverlayWrapper extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onKeyDown = this.onKeyDown.bind(this);
+ }
+
+ // The intended behaviour is to listen for an escape key
+ // but not for a click; see Bug 1582242
+ onKeyDown(event) {
+ if (event.key === "Escape") {
+ this.props.onClose(event);
+ }
+ }
+
+ componentWillMount() {
+ this.props.document.addEventListener("keydown", this.onKeyDown);
+ this.props.document.body.classList.add("modal-open");
+ }
+
+ componentWillUnmount() {
+ this.props.document.removeEventListener("keydown", this.onKeyDown);
+ this.props.document.body.classList.remove("modal-open");
+ }
+
+ render() {
+ const { props } = this;
+ let className = props.unstyled ? "" : "modalOverlayInner active";
+ if (props.innerClassName) {
+ className += ` ${props.innerClassName}`;
+ }
+ return (
+ <div
+ className="modalOverlayOuter active"
+ onKeyDown={this.onKeyDown}
+ role="presentation"
+ >
+ <div
+ className={className}
+ aria-labelledby={props.headerId}
+ id={props.id}
+ role="dialog"
+ >
+ {props.children}
+ </div>
+ </div>
+ );
+ }
+}
+
+ModalOverlayWrapper.defaultProps = { document: global.document };
diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss
new file mode 100644
index 0000000000..2cdbfcb1db
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss
@@ -0,0 +1,104 @@
+// Variable for the about:welcome modal scrollbars
+$modal-scrollbar-z-index: 1100;
+
+.activity-stream {
+ &.modal-open {
+ overflow: hidden;
+ }
+}
+
+.modalOverlayOuter {
+ background: var(--newtab-overlay-color);
+ height: 100%;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ display: none;
+ z-index: $modal-scrollbar-z-index;
+ overflow: auto;
+
+ &.active {
+ display: flex;
+ }
+}
+
+.modalOverlayInner {
+ min-width: min-content;
+ width: 100%;
+ max-width: 960px;
+ position: relative;
+ margin: auto;
+ background: var(--newtab-modal-color);
+ box-shadow: 0 1px 15px 0 $black-30;
+ border-radius: 4px;
+ display: none;
+ z-index: $modal-scrollbar-z-index;
+
+ // modal takes over entire screen
+ @media(max-width: 960px) {
+ height: 100%;
+ top: 0;
+ left: 0;
+ box-shadow: none;
+ border-radius: 0;
+ }
+
+ &.active {
+ display: block;
+ }
+
+ h2 {
+ color: $grey-60;
+ text-align: center;
+ font-weight: 200;
+ margin-top: 30px;
+ font-size: 28px;
+ line-height: 37px;
+ letter-spacing: -0.13px;
+
+ @media(max-width: 960px) {
+ margin-top: 100px;
+ }
+
+ @media(max-width: 850px) {
+ margin-top: 30px;
+ }
+ }
+
+ .footer {
+ border-top: 1px solid $grey-30;
+ border-radius: 4px;
+ height: 70px;
+ width: 100%;
+ position: absolute;
+ bottom: 0;
+ text-align: center;
+ background-color: $white;
+
+ // if modal is short enough, footer becomes sticky
+ @media(max-width: 850px) and (max-height: 730px) {
+ position: sticky;
+ }
+
+ // if modal is narrow enough, footer becomes sticky
+ @media(max-width: 650px) and (max-height: 600px) {
+ position: sticky;
+ }
+
+ .modalButton {
+ margin-top: 20px;
+ min-width: 150px;
+ height: 30px;
+ padding: 4px 30px 6px;
+ font-size: 15px;
+
+ &:focus,
+ &.active,
+ &:hover {
+ box-shadow: 0 0 0 5px $grey-30;
+ transition: box-shadow 150ms;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx
new file mode 100644
index 0000000000..45e35b83cc
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx
@@ -0,0 +1,83 @@
+/* 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 { Localized } from "fluent-react";
+import React from "react";
+import { RICH_TEXT_KEYS } from "../../rich-text-strings";
+import { safeURI } from "../../template-utils";
+
+// Elements allowed in snippet content
+const ALLOWED_TAGS = {
+ b: <b />,
+ i: <i />,
+ u: <u />,
+ strong: <strong />,
+ em: <em />,
+ br: <br />,
+};
+
+/**
+ * Transform an object (tag name: {url}) into (tag name: anchor) where the url
+ * is used as href, in order to render links inside a Fluent.Localized component.
+ */
+export function convertLinks(
+ links,
+ sendClick,
+ doNotAutoBlock,
+ openNewWindow = false
+) {
+ if (links) {
+ return Object.keys(links).reduce((acc, linkTag) => {
+ const { action } = links[linkTag];
+ // Setting the value to false will not include the attribute in the anchor
+ const url = action ? false : safeURI(links[linkTag].url);
+
+ acc[linkTag] = (
+ // eslint was getting a false positive caused by the dynamic injection
+ // of content.
+ // eslint-disable-next-line jsx-a11y/anchor-has-content
+ <a
+ href={url}
+ target={openNewWindow ? "_blank" : ""}
+ data-metric={links[linkTag].metric}
+ data-action={action}
+ data-args={links[linkTag].args}
+ data-do_not_autoblock={doNotAutoBlock}
+ data-entrypoint_name={links[linkTag].entrypoint_name}
+ data-entrypoint_value={links[linkTag].entrypoint_value}
+ onClick={sendClick}
+ />
+ );
+ return acc;
+ }, {});
+ }
+
+ return null;
+}
+
+/**
+ * Message wrapper used to sanitize markup and render HTML.
+ */
+export function RichText(props) {
+ if (!RICH_TEXT_KEYS.includes(props.localization_id)) {
+ throw new Error(
+ `ASRouter: ${props.localization_id} is not a valid rich text property. If you want it to be processed, you need to add it to asrouter/rich-text-strings.js`
+ );
+ }
+ return (
+ <Localized
+ id={props.localization_id}
+ {...ALLOWED_TAGS}
+ {...props.customElements}
+ {...convertLinks(
+ props.links,
+ props.sendClick,
+ props.doNotAutoBlock,
+ props.openNewWindow
+ )}
+ >
+ <span>{props.text}</span>
+ </Localized>
+ );
+}
diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx
new file mode 100644
index 0000000000..fd25337fbf
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx
@@ -0,0 +1,121 @@
+/* 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 SnippetBase extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onBlockClicked = this.onBlockClicked.bind(this);
+ this.onDismissClicked = this.onDismissClicked.bind(this);
+ this.setBlockButtonRef = this.setBlockButtonRef.bind(this);
+ this.onBlockButtonMouseEnter = this.onBlockButtonMouseEnter.bind(this);
+ this.onBlockButtonMouseLeave = this.onBlockButtonMouseLeave.bind(this);
+ this.state = { blockButtonHover: false };
+ }
+
+ componentDidMount() {
+ if (this.blockButtonRef) {
+ this.blockButtonRef.addEventListener(
+ "mouseenter",
+ this.onBlockButtonMouseEnter
+ );
+ this.blockButtonRef.addEventListener(
+ "mouseleave",
+ this.onBlockButtonMouseLeave
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.blockButtonRef) {
+ this.blockButtonRef.removeEventListener(
+ "mouseenter",
+ this.onBlockButtonMouseEnter
+ );
+ this.blockButtonRef.removeEventListener(
+ "mouseleave",
+ this.onBlockButtonMouseLeave
+ );
+ }
+ }
+
+ setBlockButtonRef(element) {
+ this.blockButtonRef = element;
+ }
+
+ onBlockButtonMouseEnter() {
+ this.setState({ blockButtonHover: true });
+ }
+
+ onBlockButtonMouseLeave() {
+ this.setState({ blockButtonHover: false });
+ }
+
+ onBlockClicked() {
+ if (this.props.provider !== "preview") {
+ this.props.sendUserActionTelemetry({
+ event: "BLOCK",
+ id: this.props.UISurface,
+ });
+ }
+
+ this.props.onBlock();
+ }
+
+ onDismissClicked() {
+ if (this.props.provider !== "preview") {
+ this.props.sendUserActionTelemetry({
+ event: "DISMISS",
+ id: this.props.UISurface,
+ });
+ }
+
+ this.props.onDismiss();
+ }
+
+ renderDismissButton() {
+ if (this.props.footerDismiss) {
+ return (
+ <div className="footer">
+ <div className="footer-content">
+ <button
+ className="ASRouterButton secondary"
+ onClick={this.onDismissClicked}
+ >
+ {this.props.content.scene2_dismiss_button_text}
+ </button>
+ </div>
+ </div>
+ );
+ }
+
+ const label = this.props.content.block_button_text || "Remove this";
+ return (
+ <button
+ className="blockButton"
+ title={label}
+ aria-label={label}
+ onClick={this.onBlockClicked}
+ ref={this.setBlockButtonRef}
+ />
+ );
+ }
+
+ render() {
+ const { props } = this;
+ const { blockButtonHover } = this.state;
+
+ const containerClassName = `SnippetBaseContainer${
+ props.className ? ` ${props.className}` : ""
+ }${blockButtonHover ? " active" : ""}`;
+
+ return (
+ <div className={containerClassName} style={this.props.textStyle}>
+ <div className="innerWrapper">{props.children}</div>
+ {this.renderDismissButton()}
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
new file mode 100644
index 0000000000..cfa090f89b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
@@ -0,0 +1,117 @@
+.SnippetBaseContainer {
+ position: fixed;
+ z-index: 2;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--newtab-snippets-background-color);
+ color: var(--newtab-text-primary-color);
+ font-size: 14px;
+ line-height: 20px;
+ border-top: 1px solid var(--newtab-snippets-hairline-color);
+ box-shadow: $shadow-secondary;
+ display: flex;
+ align-items: center;
+
+ a {
+ cursor: pointer;
+ color: var(--newtab-link-primary-color);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ [lwt-newtab-brighttext] & {
+ font-weight: bold;
+ }
+ }
+
+ input {
+ &[type='checkbox'] {
+ margin-inline-start: 0;
+ }
+ }
+
+ .innerWrapper {
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ padding: 12px $section-horizontal-padding;
+
+ // This is to account for the block button on smaller screens
+ padding-inline-end: 36px;
+ @media (min-width: $break-point-large) {
+ padding-inline-end: $section-horizontal-padding;
+ }
+
+ max-width: $wrapper-max-width-large + ($section-horizontal-padding * 2);
+ @media (min-width: $break-point-widest) {
+ max-width: $wrapper-max-width-widest + ($section-horizontal-padding * 2);
+ }
+ }
+
+ .blockButton {
+ display: none;
+ background: none;
+ border: 0;
+ position: absolute;
+ top: 50%;
+ inset-inline-end: 12px;
+ height: 16px;
+ width: 16px;
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-dismiss-16.svg');
+ -moz-context-properties: fill;
+ color: inherit;
+ fill: currentColor;
+ opacity: 0.5;
+ margin-top: -8px;
+ padding: 0;
+ cursor: pointer;
+ }
+
+ &:hover .blockButton {
+ display: block;
+ }
+
+ .icon {
+ height: 42px;
+ width: 42px;
+ margin-inline-end: 12px;
+ flex-shrink: 0;
+ }
+}
+
+.snippets-preview-banner {
+ font-size: 15px;
+ line-height: 42px;
+ color: $grey-60-70;
+ background: $grey-30-60;
+ text-align: center;
+ position: absolute;
+ top: 0;
+ width: 100%;
+
+ span {
+ vertical-align: middle;
+ }
+}
+
+// We show snippet icons for both themes and conditionally hide
+// based on which theme is currently active
+body {
+ &:not([lwt-newtab-brighttext]) {
+ .icon-dark-theme,
+ .icon.icon-dark-theme,
+ .scene2Icon .icon-dark-theme {
+ display: none;
+ }
+ }
+
+ &[lwt-newtab-brighttext] {
+ .icon-light-theme,
+ .icon.icon-light-theme,
+ .scene2Icon .icon-light-theme {
+ display: none;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png b/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png
new file mode 100644
index 0000000000..aee3bcf3bd
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png
Binary files differ
diff --git a/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md
new file mode 100644
index 0000000000..035118b987
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md
@@ -0,0 +1,62 @@
+# Using ASRouter Devtools
+
+## How to enable ASRouter devtools
+- In `about:config`, set `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true`
+- Visit `about:newtab#asrouter` to see the devtools.
+
+## Overview of ASRouter devtools
+
+![Devtools image](./debugging-guide.png)
+
+## How to enable/disable a provider
+
+To enable a provider such as `snippets`, Look at the list of "Message Providers" at the top of the page. Make sure the checkbox is checked next to the provider you want to enable.
+
+To disable it, uncheck the checkbox. You should see a red label indicating the provider is now disabled.
+
+## How to see all messages from a provider
+
+(Only available in Firefox 65+)
+
+In order to see all active messages for a current provider such as `snippets`, use the drop down selector under the "Messages" section. Select the name of the provider you are interested in.
+
+The messages on the page should now be filtered to include only the provider you selected.
+
+## How to test telemetry pings
+
+To test telemetry pings, complete the the following steps:
+
+- In about:config, set:
+ - `browser.newtabpage.activity-stream.telemetry` to `true`
+ - `browser.ping-centre.log` to `true`
+- Open the Browser Toolbox devtools (Tools > Web Developer > Browser Toolbox) and switch to the console tab. Add a filter for for `activity-stream` to only display relevant pings:
+
+![Devtools telemetry ping](./telemetry-screenshot.png)
+
+You should now see pings show up as you view/interact with ASR messages/templates.
+
+## Snippets debugging
+
+### How to view preview URLs
+
+Follow these steps to view preview URLs (e.g. `about:newtab?endpoint=https://gist.githubusercontent.com/piatra/d193ca7e0f513cc19fc6a1d396c214f7/raw/8bcaf9548212e4c613577e839198cc14e7317630/newsletter_snippet.json&theme=dark`)
+
+You can preview in the two different themes (light and dark) by adding `&theme=dark` or `&theme=light` at the end of the url.
+
+#### IMPORTANT NOTES
+- Links to URLs starting with `about:newtab` cannot be clicked on directly. They must be copy and pasted into the address bar.
+- Previews should only be tested in `Firefox 64` and later.
+- The endpoint must be HTTPS, the host must be allowed (see testing instructions below)
+- Errors are surfaced in the `Console` tab of the `Browser Toolbox`
+
+#### Testing instructions
+- If your endpoint URL has a host name of `snippets-admin.mozilla.org`, you can paste the URL into the address bar view it without any further steps.
+- If your endpoint URL starts with some other host name, it must be **allowed**. Open the Browser Toolbox devtools (Tools > Developer > Browser Toolbox) and paste the following code (where `gist.githubusercontent.com` is the hostname of your endpoint URL):
+```js
+Services.prefs.setStringPref(
+ "browser.newtab.activity-stream.asrouter.allowHosts",
+ "[\"gist.githubusercontent.com\"]"
+);
+```
+- Restart the browser
+- You should now be able to paste the URL into the address bar and view it.
diff --git a/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png b/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png
new file mode 100644
index 0000000000..8616a29ab3
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png
Binary files differ
diff --git a/browser/components/newtab/content-src/asrouter/docs/experiment-guide.md b/browser/components/newtab/content-src/asrouter/docs/experiment-guide.md
new file mode 100644
index 0000000000..ac2784bb1f
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/experiment-guide.md
@@ -0,0 +1,52 @@
+# How to run experiments with ASRouter
+
+This guide will tell you how to run an experiment with ASRouter messages.
+Note that the actual experiment process and infrastructure is handled by
+the experiments team (#ask-experimenter).
+
+## Why run an experiment
+
+* To measure the effect of a message on a Firefox metric (e.g. retention)
+* To test a potentially risky message on a smaller group of users
+* To compare the performance of multiple variants of messages in a controlled way
+
+## Choose cohort IDs and request an experiment
+
+First you should decide on a cohort ID (this can be any arbitrary unique string) for each
+individual group you need to segment for your experiment.
+
+For example, if I want to test two variants of an FXA Snippet, I might have two cohort IDs,
+`FXA_SNIPPET_V1` and `FXA_SNIPPET_V2`.
+
+You will then [request](https://experimenter.services.mozilla.com/) a new "pref-flip" study with the Firefox Experiments team.
+The preferences you will submit will be based on the cohort IDs you chose.
+
+For the FXA Snippet example, your preference name would be `browser.newtabpage.activity-stream.asrouter.providers.snippets` and values would be:
+
+Control (default value)
+```json
+{"id":"snippets","enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
+```
+
+Variant 1:
+```json
+{"id":"snippets", "cohort": "FXA_SNIPPET_V1", "enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
+```
+
+Variant 2:
+```json
+{"id":"snippets", "cohort": "FXA_SNIPPET_V1", "enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
+```
+
+## Add targeting to your messages
+
+You must now check for the cohort ID in the `targeting` expression of the messages you want to include in your experiments.
+
+For the previous example, you wold include the following to target the first cohort:
+
+```json
+{
+ "targeting": "providerCohorts.snippets == \"FXA_SNIPPET_V1\""
+}
+
+```
diff --git a/browser/components/newtab/content-src/asrouter/docs/first-run.md b/browser/components/newtab/content-src/asrouter/docs/first-run.md
new file mode 100644
index 0000000000..82ccde3e39
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/first-run.md
@@ -0,0 +1,9 @@
+# First run on-boarding flow
+
+First Run flow describes the entire experience users have after Firefox has successfully been installed up until the first instance of new tab is shown.
+First run help onboard new users by showing relevant messaging on about:welcome and about:newtab using triplets.
+
+### First Run Multistage
+A full-page multistep experience that shows up on first run since Fx80 with browser.aboutwelcome.enabled pref as true.
+
+Setting browser.aboutwelcome.enabled to false make first run looks like about:newtab and hides about:welcome
diff --git a/browser/components/newtab/content-src/asrouter/docs/index.rst b/browser/components/newtab/content-src/asrouter/docs/index.rst
new file mode 100644
index 0000000000..87476d32ac
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/index.rst
@@ -0,0 +1,104 @@
+================
+Messaging System
+================
+
+Vision
+------
+Firefox must be an opinionated user agent that keeps folks safe, informed and
+effective while browsing the Web. In order to have an opinion, Firefox must
+have a voice.
+
+That voice will **respect the user’s attention** while surfacing contextually
+relevant and timely information tailored to their individual needs and choices.
+
+What does Messaging System support?
+-----------------------------------
+There are several key windows of opportunity, such as the first-run activation
+phase or coordinated feature releases, where Firefox engages with users.
+
+The Firefox Messaging System supports this engagement by targeting messages
+exactly to the users who need to see them and enables the development of new
+user messages that can be easily tested and deployed. It offers standard
+mechanisms to measure user engagement and to perform user messaging experiments
+with reduced effort across engineering teams and a faster delivery cycle from
+ideation to analysis of results.
+
+This translates to **users seeing fewer and more relevant in-product
+messages**, while supporting fast delivery, experimentation, and protection of
+our users time and attention.
+
+Messaging System Overview
+-------------------------
+At the core of the Firefox Messaging System is the Messaging System Router
+(called ASRouter for historical reasons). The router is a generalized Firefox
+component and set of conventions that provides:
+
+* Flexible and configurable routing of local or remote Messages to UI
+ Templates. This allows new message campaigns to be started and controlled
+ on or off-trains
+* Traffic Cop message sequencing and intermediation to prevent multiple
+ messages being concurrently shown
+* Programmable message targeting language to show the right message to the
+ right user at the right time
+* A template library of reusable Message and Notification UIs
+* Full compatibility with Normandy pref-flip experiments
+* Generalized and privacy conscious event telemetry
+* Flexible Frequency Capping to mitigate user message fatigue
+* Localized off train Messages
+* Powerful development/debugging/QA tools on about:newtab#devtools
+
+Message Routing
+---------------
+.. image:: ./message-routing-overview.png
+ :align: center
+ :alt: Message Routing Overview
+
+The Firefox Messaging System implements a separation-of-concerns pattern for
+Messages, UI Templates, and Timing/Targeting mechanisms. This allows us to
+maintain a high standard of security and quality while still allowing for
+maximum flexibility around content creation.
+
+UI Templates
+------------
+We have built a library of reusable Notification and Message interfaces which
+land in the Firefox codebase and ride the trains. These templates have a
+defined schema according to the available design components (e.g. titles, text,
+icons) and access to a set of enhanced user actions such as triggering URLs,
+launching menus, or installing addons, which can be attached to interactive
+elements (such as buttons).
+
+Current templates include\:
+
+* What's New Panel - an icon in the toolbar and menu item that appears if a
+ message is available in the panel, usually after major Firefox releases
+* Moments Page - appears on start-up as a full content page
+* Contextual Feature Recommendation - highlighted word in the Location Bar
+ that, if clicked, drops down a panel with information about a feature
+ relevant to that user at that time
+* First Run - shown on startup in a content page as a set of onboarding cards
+ with calls to action that persist for several days
+* Snippets - short messages that appear on New Tab Page to highlight products,
+ features and initiatives
+* Badging - A colorful dot to highlight icons in the toolbar or menu items in
+ order to draw attention with minimal interruption
+
+Detailed Docs
+-------------
+
+* Read more about `trigger listeners and user action schemas`__.
+
+.. __: /toolkit/components/messaging-system/docs
+
+.. In theory, we ought to be able to use the :glob: directive here to
+.. automatically generate the list below. For unknown reasons, however,
+.. `mach doc` _sometimes_ gets confused and refuses to find patterns like
+.. `*.md`.
+.. toctree::
+ :maxdepth: 2
+
+ simple-cfr-template
+ debugging-docs
+ experiment-guide
+ first-run
+ targeting-attributes
+ targeting-guide
diff --git a/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png b/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png
new file mode 100644
index 0000000000..0ec2ec3c14
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png
Binary files differ
diff --git a/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst b/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst
new file mode 100644
index 0000000000..d553547420
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst
@@ -0,0 +1,37 @@
+Simple CFR Template
+--------------------
+
+The “Simple CFR Template” is a two-stage UI (a chiclet notification and a door-hanger)
+that shows up on a configurable `trigger condition`__, such as when the user visits a
+particular web page.
+
+.. __: /toolkit/components/messaging-system/docs/TriggerActionSchemas
+
+Warning! Before reading, you should consider whether a `Messaging Experiment is relevant for your needs`__.
+
+.. __: https://docs.google.com/document/d/1S45a_nFn8QRM8gvsxCM6HHROrIQlQQl6fUlJ2j63PGI/edit
+
+.. image:: ./cfr_doorhanger_screenshot.png
+ :align: center
+ :alt: Simple CFR Template 2 stage
+
+Doorhanger Configuration
+=========================
+
+Stage 1 – Chiclet
+++++++++++++++++++
+
+* **chiclet_label**: The text that shows up in the chiclet. 20 characters max.
+* **chiclet_color**: The background color of the chiclet as a HEX code.
+
+
+Stage 2 – Door-hanger
+++++++++++++++++++++++
+
+* **title**: Title text at the top of the door hanger.
+* **body**: A longer paragraph of text.
+* **icon**: An image (please provide a URL or the image file up to 96x96px).
+* **primary_button_label**: The label of the button.
+* **primary_button_action**: The special action triggered by clicking on the button. Choose any of the available `button actions`__. Common examples include opening a section of about:preferences, or opening a URL.
+
+.. __: /toolkit/components/messaging-system/docs/SpecialMessageActionSchemas
diff --git a/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md
new file mode 100644
index 0000000000..128ea2a0b3
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md
@@ -0,0 +1,828 @@
+# Targeting attributes
+
+When you create ASRouter messages such as snippets, contextual feature recommendations, or onboarding cards, you may choose to include **targeting information** with those messages.
+
+Targeting information must be captured in [an expression](./targeting-guide.md) that has access to the following attributes. You may combine and compare any of these attributes as needed.
+
+Please note that some targeting attributes require stricter controls on the telemetry than can be colleted, so when in doubt, ask for review.
+
+## Available attributes
+
+* [addonsInfo](#addonsinfo)
+* [attributionData](#attributiondata)
+* [browserSettings](#browsersettings)
+* [currentDate](#currentdate)
+* [devToolsOpenedCount](#devtoolsopenedcount)
+* [isDefaultBrowser](#isdefaultbrowser)
+* [firefoxVersion](#firefoxversion)
+* [locale](#locale)
+* [localeLanguageCode](#localelanguagecode)
+* [needsUpdate](#needsupdate)
+* [pinnedSites](#pinnedsites)
+* [previousSessionEnd](#previoussessionend)
+* [profileAgeCreated](#profileagecreated)
+* [profileAgeReset](#profileagereset)
+* [providerCohorts](#providercohorts)
+* [region](#region)
+* [searchEngines](#searchengines)
+* [sync](#sync)
+* [topFrecentSites](#topfrecentsites)
+* [totalBookmarksCount](#totalbookmarkscount)
+* [usesFirefoxSync](#usesfirefoxsync)
+* [isFxAEnabled](#isFxAEnabled)
+* [xpinstallEnabled](#xpinstallEnabled)
+* [hasPinnedTabs](#haspinnedtabs)
+* [hasAccessedFxAPanel](#hasaccessedfxapanel)
+* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled)
+* [totalBlockedCount](#totalblockedcount)
+* [recentBookmarks](#recentbookmarks)
+* [userPrefs](#userprefs)
+* [attachedFxAOAuthClients](#attachedfxaoauthclients)
+* [platformName](#platformname)
+* [scores](#scores)
+* [scoreThreshold](#scorethreshold)
+* [messageImpressions](#messageimpressions)
+* [blockedCountByType](#blockedcountbytype)
+* [isChinaRepack](#ischinarepack)
+* [userId](#userid)
+* [profileRestartCount](#profilerestartcount)
+* [homePageSettings](#homepagesettings)
+* [newtabSettings](#newtabsettings)
+* [isFissionExperimentEnabled](#isfissionexperimentenabled)
+* [activeNotifications](#activenotifications)
+
+## Detailed usage
+
+### `addonsInfo`
+Provides information about the add-ons the user has installed.
+
+Note that the `name`, `userDisabled`, and `installDate` is only available if `isFullData` is `true` (this is usually not the case right at start-up).
+
+**Due to an existing bug, `userDisabled` is not currently available**
+
+#### Examples
+* Has the user installed the unicorn addon?
+```java
+addonsInfo.addons["unicornaddon@mozilla.org"]
+```
+
+* Has the user installed and disabled the unicorn addon?
+```java
+addonsInfo.isFullData && addonsInfo.addons["unicornaddon@mozilla.org"].userDisabled
+```
+
+#### Definition
+```ts
+declare const addonsInfo: Promise<AddonsInfoResponse>;
+interface AddonsInfoResponse {
+ // Does this include extra information requiring I/O?
+ isFullData: boolean;
+ // addonId should be something like activity-stream@mozilla.org
+ [addonId: string]: {
+ // Version of the add-on
+ version: string;
+ // (string) e.g. "extension"
+ type: AddonType;
+ // Version of the add-on
+ isSystem: boolean;
+ // Is the add-on a webextension?
+ isWebExtension: boolean;
+ // The name of the add-on
+ name: string;
+ // Is the add-on disabled?
+ // CURRENTLY UNAVAILABLE due to an outstanding bug
+ userDisabled: boolean;
+ // When was it installed? e.g. "2018-03-10T03:41:06.000Z"
+ installDate: string;
+ };
+}
+```
+### `attributionData`
+
+An object containing information on exactly how Firefox was downloaded
+
+#### Examples
+* Was the browser installed via the `"back_to_school"` campaign?
+```java
+attributionData && attributionData.campaign == "back_to_school"
+```
+
+#### Definition
+```ts
+declare const attributionData: AttributionCode;
+interface AttributionCode {
+ // Descriptor for where the download started from
+ campaign: string,
+ // A source, like addons.mozilla.org, or google.com
+ source: string,
+ // The medium for the download, like if this was referral
+ medium: string,
+ // Additional content, like an addonID for instance
+ content: string
+}
+```
+
+### `browserSettings`
+
+Includes two properties:
+* `attribution`, which indicates how Firefox was downloaded - DEPRECATED - please use [attributionData](#attributiondata)
+* `update`, which has information about how Firefox updates
+
+Note that attribution can be `undefined`, so you should check that it exists first.
+
+#### Examples
+* Is updating enabled?
+```java
+browserSettings.update.enabled
+```
+
+#### Definition
+
+```ts
+declare const browserSettings: {
+ attribution: undefined | {
+ // Referring partner domain, when install happens via a known partner
+ // e.g. google.com
+ source: string;
+ // category of the source, such as "organic" for a search engine
+ // e.g. organic
+ medium: string;
+ // identifier of the particular campaign that led to the download of the product
+ // e.g. back_to_school
+ campaign: string;
+ // identifier to indicate the particular link within a campaign
+ // e.g. https://mozilla.org/some-page
+ content: string;
+ },
+ update: {
+ // Is auto-downloading enabled?
+ autoDownload: boolean;
+ // What release channel, e.g. "nightly"
+ channel: string;
+ // Is updating enabled?
+ enabled: boolean;
+ }
+}
+```
+
+### `currentDate`
+
+The current date at the moment message targeting is checked.
+
+#### Examples
+* Is the current date after Oct 3, 2018?
+```java
+currentDate > "Wed Oct 03 2018 00:00:00"|date
+```
+
+#### Definition
+
+```ts
+declare const currentDate; ECMA262DateString;
+// ECMA262DateString = Date.toString()
+type ECMA262DateString = string;
+```
+
+### `devToolsOpenedCount`
+Number of usages of the web console.
+
+#### Examples
+* Has the user opened the web console more than 10 times?
+```java
+devToolsOpenedCount > 10
+```
+
+#### Definition
+```ts
+declare const devToolsOpenedCount: number;
+```
+
+### `isDefaultBrowser`
+
+Is Firefox the user's default browser?
+
+#### Definition
+
+```ts
+declare const isDefaultBrowser: boolean;
+```
+
+### `firefoxVersion`
+
+The major Firefox version of the browser
+
+#### Examples
+* Is the version of the browser greater than 63?
+```java
+firefoxVersion > 63
+```
+
+#### Definition
+
+```ts
+declare const firefoxVersion: number;
+```
+
+### `locale`
+The current locale of the browser including country code, e.g. `en-US`.
+
+#### Examples
+* Is the locale of the browser either English (US) or German (Germany)?
+```java
+locale in ["en-US", "de-DE"]
+```
+
+#### Definition
+```ts
+declare const locale: string;
+```
+
+### `localeLanguageCode`
+The current locale of the browser NOT including country code, e.g. `en`.
+This is useful for matching all countries of a particular language.
+
+#### Examples
+* Is the locale of the browser any English locale?
+```java
+localeLanguageCode == "en"
+```
+
+#### Definition
+```ts
+declare const localeLanguageCode: string;
+```
+
+### `needsUpdate`
+
+Does the client have the latest available version installed
+
+```ts
+declare const needsUpdate: boolean;
+```
+
+### `pinnedSites`
+The sites (including search shortcuts) that are pinned on a user's new tab page.
+
+#### Examples
+* Has the user pinned any site on `foo.com`?
+```java
+"foo.com" in pinnedSites|mapToProperty("host")
+```
+
+* Does the user have a pinned `duckduckgo.com` search shortcut?
+```java
+"duckduckgo.com" in pinnedSites[.searchTopSite == true]|mapToProperty("host")
+```
+
+#### Definition
+```ts
+interface PinnedSite {
+ // e.g. https://foo.mozilla.com/foo/bar
+ url: string;
+ // e.g. foo.mozilla.com
+ host: string;
+ // is the pin a search shortcut?
+ searchTopSite: boolean;
+}
+declare const pinnedSites: Array<PinnedSite>
+```
+
+### `previousSessionEnd`
+
+Timestamp of the previously closed session.
+
+#### Definition
+```ts
+declare const previousSessionEnd: UnixEpochNumber;
+// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
+type UnixEpochNumber = number;
+```
+
+### `profileAgeCreated`
+
+The date the profile was created as a UNIX Epoch timestamp.
+
+#### Definition
+
+```ts
+declare const profileAgeCreated: UnixEpochNumber;
+// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
+type UnixEpochNumber = number;
+```
+
+### `profileAgeReset`
+
+The date the profile was reset as a UNIX Epoch timestamp (if it was reset).
+
+#### Examples
+* Was the profile never reset?
+```java
+!profileAgeReset
+```
+
+#### Definition
+```ts
+// profileAgeReset can be undefined if the profile was never reset
+// UnixEpochNumber is number, e.g. 1522843725924
+declare const profileAgeReset: undefined | UnixEpochNumber;
+// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
+type UnixEpochNumber = number;
+```
+
+### `providerCohorts`
+
+Information about cohort settings (from prefs, including shield studies) for each provider.
+
+#### Examples
+* Is the user in the "foo_test" cohort for snippets?
+```java
+providerCohorts.snippets == "foo_test"
+```
+
+#### Definition
+
+```ts
+declare const providerCohorts: {
+ [providerId: string]: string;
+}
+```
+
+### `region`
+
+Country code retrieved from `location.services.mozilla.com`. Can be `""` if request did not finish or encountered an error.
+
+#### Examples
+* Is the user in Canada?
+```java
+region == "CA"
+```
+
+#### Definition
+
+```ts
+declare const region: string;
+```
+
+### `searchEngines`
+
+Information about the current and available search engines.
+
+#### Examples
+* Is the current default search engine set to google?
+```java
+searchEngines.current == "google"
+```
+
+#### Definition
+
+```ts
+declare const searchEngines: Promise<SearchEnginesResponse>;
+interface SearchEnginesResponse: {
+ current: SearchEngineId;
+ installed: Array<SearchEngineId>;
+}
+// This is an identifier for a search engine such as "google" or "amazondotcom"
+type SearchEngineId = string;
+```
+
+### `sync`
+
+Information about synced devices.
+
+#### Examples
+* Is at least 1 mobile device synced to this profile?
+```java
+sync.mobileDevices > 0
+```
+
+#### Definition
+
+```ts
+declare const sync: {
+ desktopDevices: number;
+ mobileDevices: number;
+ totalDevices: number;
+}
+```
+
+### `topFrecentSites`
+
+Information about the browser's top 25 frecent sites.
+
+**Please note this is a restricted targeting property that influences what telemetry is allowed to be collected may not be used without review**
+
+
+#### Examples
+* Is mozilla.com in the user's top frecent sites with a frececy greater than 400?
+```java
+"mozilla.com" in topFrecentSites[.frecency >= 400]|mapToProperty("host")
+```
+
+#### Definition
+```ts
+declare const topFrecentSites: Promise<Array<TopSite>>
+interface TopSite {
+ // e.g. https://foo.mozilla.com/foo/bar
+ url: string;
+ // e.g. foo.mozilla.com
+ host: string;
+ frecency: number;
+ lastVisitDate: UnixEpochNumber;
+}
+// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
+type UnixEpochNumber = number;
+```
+
+### `totalBookmarksCount`
+
+Total number of bookmarks.
+
+#### Definition
+
+```ts
+declare const totalBookmarksCount: number;
+```
+
+### `usesFirefoxSync`
+
+Does the user use Firefox sync?
+
+#### Definition
+
+```ts
+declare const usesFirefoxSync: boolean;
+```
+
+### `isFxAEnabled`
+
+Does the user have Firefox sync enabled? The service could potentially be turned off [for enterprise builds](https://searchfox.org/mozilla-central/rev/b59a99943de4dd314bae4e44ab43ce7687ccbbec/browser/components/enterprisepolicies/Policies.jsm#327).
+
+#### Definition
+
+```ts
+declare const isFxAEnabled: boolean;
+```
+
+### `xpinstallEnabled`
+
+Pref used by system administrators to disallow add-ons from installed altogether.
+
+#### Definition
+
+```ts
+declare const xpinstallEnabled: boolean;
+```
+
+### `hasPinnedTabs`
+
+Does the user have any pinned tabs in any windows.
+
+#### Definition
+
+```ts
+declare const hasPinnedTabs: boolean;
+```
+
+### `hasAccessedFxAPanel`
+
+Boolean pref that gets set the first time the user opens the FxA toolbar panel
+
+#### Definition
+
+```ts
+declare const hasAccessedFxAPanel: boolean;
+```
+
+### `isWhatsNewPanelEnabled`
+
+Boolean pref that controls if the What's New panel feature is enabled
+
+#### Definition
+
+```ts
+declare const isWhatsNewPanelEnabled: boolean;
+```
+
+### `totalBlockedCount`
+
+Total number of events from the content blocking database
+
+#### Definition
+
+```ts
+declare const totalBlockedCount: number;
+```
+
+### `recentBookmarks`
+
+An array of GUIDs of recent bookmarks as provided by [`NewTabUtils.getRecentBookmarks`](https://searchfox.org/mozilla-central/rev/e0b0c38ee83f99d3cf868bad525ace4a395039f1/toolkit/modules/NewTabUtils.jsm#1087)
+
+#### Definition
+
+```ts
+interface Bookmark {
+ bookmarkGuid: string;
+ url: string;
+ title: string;
+ ...
+}
+declare const recentBookmarks: Array<Bookmark>
+```
+
+### `userPrefs`
+
+Information about user facing prefs configurable from `about:preferences`.
+
+#### Examples
+```java
+userPrefs.cfrFeatures == false
+```
+
+#### Definition
+
+```ts
+declare const userPrefs: {
+ cfrFeatures: boolean;
+ cfrAddons: boolean;
+ snippets: boolean;
+}
+```
+
+### `attachedFxAOAuthClients`
+
+Information about connected services associated with the FxA Account.
+Return an empty array if no account is found or an error occurs.
+
+#### Definition
+
+```
+interface OAuthClient {
+ // OAuth client_id of the service
+ // https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution
+ id: string;
+ lastAccessedDaysAgo: number;
+}
+
+declare const attachedFxAOAuthClients: Promise<OAuthClient[]>
+```
+
+#### Examples
+```javascript
+{
+ id: "7377719276ad44ee",
+ name: "Pocket",
+ lastAccessTime: 1513599164000
+}
+```
+
+### `platformName`
+
+[Platform information](https://searchfox.org/mozilla-central/rev/05a22d864814cb1e4352faa4004e1f975c7d2eb9/toolkit/modules/AppConstants.jsm#156).
+
+#### Definition
+
+```
+declare const platformName = "linux" | "win" | "macosx" | "android" | "other";
+```
+
+### `scores`
+
+#### Definition
+
+See more in [CFR Machine Learning Experiment](https://bugzilla.mozilla.org/show_bug.cgi?id=1594422).
+
+```
+declare const scores = { [cfrId: string]: number (integer); }
+```
+
+### `scoreThreshold`
+
+#### Definition
+
+See more in [CFR Machine Learning Experiment](https://bugzilla.mozilla.org/show_bug.cgi?id=1594422).
+
+```
+declare const scoreThreshold = integer;
+```
+
+### `messageImpressions`
+
+Dictionary that maps message ids to impression timestamps. Timestamps are stored in
+consecutive order. Can be used to detect first impression of a message, number of
+impressions. Can be used in targeting to show a message if another message has been
+seen.
+Impressions are used for frequency capping so we only store them if the message has
+`frequency` configured.
+Impressions for badges might not work as expected: we add a badge for every opened
+window so the number of impressions stored might be higher than expected. Additionally
+not all badges have `frequency` cap so `messageImpressions` might not be defined.
+Badge impressions should not be used for targeting.
+
+#### Definition
+
+```
+declare const messageImpressions: { [key: string]: Array<UnixEpochNumber> };
+```
+
+### `blockedCountByType`
+
+Returns a breakdown by category of all blocked resources in the past 42 days.
+
+#### Definition
+
+```
+declare const messageImpressions: { [key: string]: number };
+```
+
+#### Examples
+
+```javascript
+Object {
+ trackerCount: 0,
+ cookieCount: 34,
+ cryptominerCount: 0,
+ fingerprinterCount: 3,
+ socialCount: 2
+}
+```
+
+### `isChinaRepack`
+
+Does the user use [the partner repack distributed by Mozilla Online](https://github.com/mozilla-partners/mozillaonline),
+a wholly owned subsidiary of the Mozilla Corporation that operates in China.
+
+#### Definition
+
+```ts
+declare const isChinaRepack: boolean;
+```
+
+### `userId`
+
+A unique user id generated by Normandy (note that this is not clientId).
+
+#### Definition
+
+```ts
+declare const userId: string;
+```
+
+### `profileRestartCount`
+
+A session counter that shows how many times the browser was started.
+More info about the details in [the telemetry docs](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/concepts/sessions.html).
+
+#### Definition
+
+```ts
+declare const profileRestartCount: number;
+```
+
+### `homePageSettings`
+
+An object reflecting the current settings of the browser home page (about:home)
+
+#### Definition
+
+```ts
+declare const homePageSettings: {
+ isDefault: boolean;
+ isLocked: boolean;
+ isWebExt: boolean;
+ isCustomUrl: boolean;
+ urls: Array<URL>;
+}
+
+interface URL {
+ url: string;
+ host: string;
+}
+```
+
+#### Examples
+
+* Default about:home
+```javascript
+Object {
+ isDefault: true,
+ isLocked: false,
+ isCustomUrl: false,
+ isWebExt: false,
+ urls: [
+ { url: "about:home", host: "" }
+ ],
+}
+```
+
+* Default about:home with locked preference
+```javascript
+Object {
+ isDefault: true,
+ isLocked: true,
+ isCustomUrl: false,
+ isWebExt: false,
+ urls: [
+ { url: "about:home", host: "" }
+ ],
+}
+```
+
+* Custom URL
+```javascript
+Object {
+ isDefault: false,
+ isLocked: false,
+ isCustomUrl: true,
+ isWebExt: false,
+ urls: [
+ { url: "https://www.google.com", host: "google.com" }
+ ],
+}
+```
+
+* Custom URLs
+```javascript
+Object {
+ isDefault: false,
+ isLocked: false,
+ isCustomUrl: true,
+ isWebExt: false,
+ urls: [
+ { url: "https://www.google.com", host: "google.com" },
+ { url: "https://www.youtube.com", host: "youtube.com" }
+ ],
+}
+```
+
+* Web extension
+```javascript
+Object {
+ isDefault: false,
+ isLocked: false,
+ isCustomUrl: false,
+ isWebExt: true,
+ urls: [
+ { url: "moz-extension://123dsa43213acklncd/home.html", host: "" }
+ ],
+}
+```
+
+### `newtabSettings`
+
+An object reflecting the current settings of the browser newtab page (about:newtab)
+
+#### Definition
+
+```ts
+declare const newtabSettings: {
+ isDefault: boolean;
+ isWebExt: boolean;
+ isCustomUrl: boolean;
+ url: string;
+ host: string;
+}
+```
+
+#### Examples
+
+* Default about:newtab
+```javascript
+Object {
+ isDefault: true,
+ isCustomUrl: false,
+ isWebExt: false,
+ url: "about:newtab",
+ host: "",
+}
+```
+
+* Custom URL
+```javascript
+Object {
+ isDefault: false,
+ isCustomUrl: true,
+ isWebExt: false,
+ url: "https://www.google.com",
+ host: "google.com",
+}
+```
+
+* Web extension
+```javascript
+Object {
+ isDefault: false,
+ isCustomUrl: false,
+ isWebExt: true,
+ url: "moz-extension://123dsa43213acklncd/home.html",
+ host: "",
+}
+```
+
+### `isFissionExperimentEnabled`
+
+A boolean. `true` if we're running Fission experiment, `false` otherwise.
+
+### `activeNotifications`
+
+True when an infobar style message is displayed or when the awesomebar is
+expanded to show a message (for example onboarding tips).
diff --git a/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md b/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md
new file mode 100644
index 0000000000..901756bca5
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md
@@ -0,0 +1,37 @@
+# Guide to targeting with JEXL
+
+For a more in-depth explanation of JEXL syntax you can read the [Normady project docs](https://mozilla.github.io/normandy/user/filters.html?highlight=jexl).
+
+### How to write JEXL targeting expressions
+A message needs to contain the `targeting` property (JEXL string) which is evaluated against the provided attributes.
+Examples:
+
+```javascript
+{
+ "id": "7864",
+ "content": {...},
+ // simple equality check
+ "targeting": "usesFirefoxSync == true"
+}
+
+{
+ "id": "7865",
+ "content": {...},
+ // using JEXL transforms and combining two attributes
+ "targeting": "usesFirefoxSync == true && profileAgeCreated > '2018-01-07'|date"
+}
+
+{
+ "id": "7866",
+ "content": {...},
+ // targeting addon information
+ "targeting": "addonsInfo.addons['activity-stream@mozilla.org'].name == 'Activity Stream'"
+}
+
+{
+ "id": "7866",
+ "content": {...},
+ // targeting based on time
+ "targeting": "currentDate > '2018-08-08'|date"
+}
+```
diff --git a/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png b/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png
new file mode 100644
index 0000000000..b27b4ab958
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png
Binary files differ
diff --git a/browser/components/newtab/content-src/asrouter/rich-text-strings.js b/browser/components/newtab/content-src/asrouter/rich-text-strings.js
new file mode 100644
index 0000000000..6a52732ad1
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/rich-text-strings.js
@@ -0,0 +1,44 @@
+/* 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 { FluentBundle } from "fluent";
+
+/**
+ * Properties that allow rich text MUST be added to this list.
+ * key: the localization_id that should be used
+ * value: a property or array of properties on the message.content object
+ */
+const RICH_TEXT_CONFIG = {
+ text: ["text", "scene1_text"],
+ success_text: "success_text",
+ error_text: "error_text",
+ scene2_text: "scene2_text",
+ amo_html: "amo_html",
+ privacy_html: "scene2_privacy_html",
+ disclaimer_html: "scene2_disclaimer_html",
+};
+
+export const RICH_TEXT_KEYS = Object.keys(RICH_TEXT_CONFIG);
+
+/**
+ * Generates an array of messages suitable for fluent's localization provider
+ * including all needed strings for rich text.
+ * @param {object} content A .content object from an ASR message (i.e. message.content)
+ * @returns {FluentBundle[]} A array containing the fluent message context
+ */
+export function generateBundles(content) {
+ const bundle = new FluentBundle("en-US");
+
+ RICH_TEXT_KEYS.forEach(key => {
+ const attrs = RICH_TEXT_CONFIG[key];
+ const attrsToTry = Array.isArray(attrs) ? [...attrs] : [attrs];
+ let string = "";
+ while (!string && attrsToTry.length) {
+ const attr = attrsToTry.pop();
+ string = content[attr];
+ }
+ bundle.addMessages(`${key} = ${string}`);
+ });
+ return [bundle];
+}
diff --git a/browser/components/newtab/content-src/asrouter/schemas/message-format.md b/browser/components/newtab/content-src/asrouter/schemas/message-format.md
new file mode 100644
index 0000000000..debcce0572
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/schemas/message-format.md
@@ -0,0 +1,101 @@
+## Activity Stream Router message format
+
+Field name | Type | Required | Description | Example / Note
+--- | --- | --- | --- | ---
+`id` | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1`
+`template` | `string` | Yes | An id matching an existing Activity Stream Router template | [See example](https://github.com/mozilla/activity-stream/blob/33669c67c2269078a6d3d6d324fb48175d98f634/system-addon/content-src/message-center/templates/SimpleSnippet.jsx)
+`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset)
+`bundled` | `integer` | No | The number of messages of the same template this one should be shown with | [See example below](#a-bundled-message-example)
+`order` | `integer` | No | If bundled with other messages of the same template, which order should this one be placed in? Defaults to 0 if no order is desired | [See example below](#a-bundled-message-example)
+`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly`
+`targeting` | `string` `JEXL` | No | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [Examples](#targeting-attributes)
+`trigger` | `string` | No | An event or condition upon which the message will be immediately shown. This can be combined with `targeting`. Messages that define a trigger will not be shown during non-trigger-based passive message rotation.
+`trigger.params` | `[string]` | No | A set of hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-params)
+`trigger.patterns` | `[string]` | No | A set of patterns that match multiple hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-patterns)
+`frequency` | `object` | No | A definition for frequency cap information for the message
+`frequency.lifetime` | `integer` | No | The maximum number of lifetime impressions for the message.
+`frequency.custom` | `array` | No | An array of frequency cap definition objects including `period`, a time period in milliseconds, and `cap`, a max number of impressions for that period.
+
+### Message example
+```javascript
+{
+ id: "ONBOARDING_1",
+ template: "simple_snippet",
+ content: {
+ title: "Find it faster",
+ body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
+ },
+ targeting: "usesFirefoxSync && !addonsInfo.addons['activity-stream@mozilla.org']",
+ frequency: {
+ lifetime: 20,
+ custom: [{period: 86400000, cap: 5}, {period: 3600000, cap: 1}]
+ }
+}
+```
+
+### A Bundled Message example
+The following 2 messages have a `bundled` property, indicating that they should be shown together, since they have the same template. The number `2` indicates that this message should be shown in a bundle of 2 messages of the same template. The order property defines that ONBOARDING_2 should be shown after ONBOARDING_3 in the bundle.
+```javascript
+{
+ id: "ONBOARDING_2",
+ template: "onboarding",
+ bundled: 2,
+ order: 2,
+ content: {
+ title: "Private Browsing",
+ body: "Browse by yourself. Private Browsing with Tracking Protection blocks online trackers that follow you around the web."
+ },
+ targeting: "",
+ trigger: "firstRun"
+}
+{
+ id: "ONBOARDING_3",
+ template: "onboarding",
+ bundled: 2,
+ order: 1,
+ content: {
+ title: "Find it faster",
+ body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
+ },
+ targeting: "",
+ trigger: "firstRun"
+}
+```
+
+### HTML subset
+The following tags are allowed in the content of the snippet: `i, b, u, strong, em, br`.
+
+Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `<cta>link</cta>` and the url will be provided as part of the payload:
+```
+{
+ "id": "7899",
+ "content": {
+ "text": "Use the CMD (CTRL) + T keyboard shortcut to <cta>open a new tab quickly!</cta>",
+ "links": {
+ "cta": {
+ "url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly"
+ }
+ }
+ }
+}
+```
+If a tag that is not on the allowed is used, the text content will be extracted and displayed.
+
+Grouping multiple allowed elements is not possible, only the first level will be used: `<u><b>text</b></u>` will be interpreted as `<u>text</u>`.
+
+### Trigger params
+A set of hostnames that need to exactly match the location of the selected tab in order for the trigger to execute.
+```
+["github.com", "wwww.github.com"]
+```
+More examples in the [CFRMessageProvider](https://github.com/mozilla/activity-stream/blob/e76ce12fbaaac1182aa492b84fc038f78c3acc33/lib/CFRMessageProvider.jsm#L40-L47).
+
+### Trigger patterns
+A set of patterns that can match multiple hostnames. When the location of the selected tab matches one of the patterns it can execute a trigger.
+```
+["*://*.github.com"] // can match `github.com` but also match `https://gist.github.com/`
+```
+More [MatchPattern examples](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#Examples).
+
+### Targeting attributes
+(This section has moved to [targeting-attributes.md](../docs/targeting-attributes.md)).
diff --git a/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json b/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json
new file mode 100644
index 0000000000..64f30e7c49
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json
@@ -0,0 +1,63 @@
+{
+ "title": "MessageGroup",
+ "description": "Configuration object for groups of Messaging System messages",
+ "type": "object",
+ "version": "1.0.0",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the message that should not conflict with any other previous message."
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Enables or disables all messages associated with this group."
+ },
+ "userPreferences": {
+ "type": "array",
+ "description": "Collection of preferences that control if the group is enabled.",
+ "items": {
+ "type": "string",
+ "description": "Preference name"
+ }
+ },
+ "frequency": {
+ "type": "object",
+ "description": "An object containing frequency cap information for a message.",
+ "properties": {
+ "lifetime": {
+ "type": "integer",
+ "description": "The maximum lifetime impressions for a message.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "custom": {
+ "type": "array",
+ "description": "An array of custom frequency cap definitions.",
+ "items": {
+ "description": "A frequency cap definition containing time and max impression information",
+ "type": "object",
+ "properties": {
+ "period": {
+ "oneOf": [
+ {
+ "type": "integer",
+ "description": "Period of time in milliseconds (e.g. 86400000 for one day)"
+ }
+ ]
+ },
+ "cap": {
+ "type": "integer",
+ "description": "The maximum impressions for the message within the defined period.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": ["period", "cap"]
+ }
+ }
+ }
+ }
+ },
+ "required": ["id", "enabled"],
+ "additionalProperties": false
+}
diff --git a/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json b/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json
new file mode 100644
index 0000000000..2ea50d482d
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json
@@ -0,0 +1,163 @@
+{
+ "title": "CFRFxABookmark",
+ "description": "A message shown in the bookmark panel when user adds or edits a bookmark",
+ "version": "1.0.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "title": {
+ "description": "Shown at the top of the message in the largest font size.",
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "text": {
+ "description": "Longest part of the message, below the title, provides explanation.",
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "cta": {
+ "description": "Link shown at the bottom of the message, call to action",
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "info_icon": {
+ "type": "object",
+ "description": "The small icon displayed in the top right corner of the panel. Not configurable, only the tooltip text." ,
+ "properties": {
+ "tooltiptext": {
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ }
+ },
+ "required": ["tooltiptext"]
+ },
+ "close_button": {
+ "type": "object",
+ "description": "The small dissmiss icon displayed in the top right corner of the message. Not configurable, only the tooltip text." ,
+ "properties": {
+ "tooltiptext": {
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ }
+ },
+ "required": ["tooltiptext"]
+ },
+ "color": {
+ "description": "Message text color",
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Valid CSS color"}
+ ]
+ },
+ "background_color_1": {
+ "description": "Configurable background color through CSS gradient",
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Valid CSS color"}
+ ]
+ },
+ "background_color_2": {
+ "description": "Configurable background color through CSS gradient",
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Valid CSS color"}
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["title", "text", "cta", "info_icon"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json b/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json
new file mode 100644
index 0000000000..76e2249d31
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json
@@ -0,0 +1,75 @@
+{
+ "title": "ProviderResponse",
+ "description": "A response object for remote providers of AS Router",
+ "type": "object",
+ "version": "6.1.0",
+ "properties": {
+ "messages": {
+ "type": "array",
+ "description": "An array of router messages",
+ "items": {
+ "title": "RouterMessage",
+ "description": "A definition of an individual message",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the message that should not conflict with any other previous message"
+ },
+ "template": {
+ "type": "string",
+ "description": "An id matching an existing Activity Stream Router template",
+ "enum": ["simple_snippet"]
+ },
+ "bundled": {
+ "type": "integer",
+ "description": "The number of messages of the same template this one should be shown with (optional)"
+ },
+ "order": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "If bundled with other messages of the same template, which order should this one be placed in? (optional - defaults to 0)"
+ },
+ "content": {
+ "type": "object",
+ "description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details."
+ },
+ "targeting": {
+ "type": "string",
+ "description": "A JEXL expression representing targeting information"
+ },
+ "personalized": {
+ "type": "boolean",
+ "description": "Is a personalized score applied to the provider's messages?"
+ },
+ "personalizedModelVersion": {
+ "type": "string",
+ "description": "The version of the model use for personalization"
+ },
+ "trigger": {
+ "type": "object",
+ "description": "An action to trigger potentially showing the message",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A string identifying the trigger action",
+ "enum": ["firstRun", "openURL"]
+ },
+ "params": {
+ "type": "array",
+ "description": "An optional array of string parameters for the trigger action",
+ "items": {
+ "type": "string",
+ "description": "A parameter for the trigger action"
+ }
+ }
+ },
+ "required": ["id"]
+ }
+ },
+ "required": ["id", "template", "content"]
+ }
+ }
+ },
+ "required": ["messages"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/template-utils.js b/browser/components/newtab/content-src/asrouter/template-utils.js
new file mode 100644
index 0000000000..8d6109a968
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/template-utils.js
@@ -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/. */
+
+export function safeURI(url) {
+ if (!url) {
+ return "";
+ }
+ const { protocol } = new URL(url);
+ const isAllowed = [
+ "http:",
+ "https:",
+ "data:",
+ "resource:",
+ "chrome:",
+ ].includes(protocol);
+ if (!isAllowed) {
+ console.warn(`The protocol ${protocol} is not allowed for template URLs.`); // eslint-disable-line no-console
+ }
+ return isAllowed ? url : "";
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json
new file mode 100644
index 0000000000..5758efd686
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json
@@ -0,0 +1,75 @@
+{
+ "title": "CFRUrlbarChiclet",
+ "description": "A template with a chiclet button with text.",
+ "version": "1.0.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider"
+ },
+ "layout": {
+ "type": "string",
+ "description": "Describes how content should be displayed.",
+ "enum": ["chiclet_open_url"]
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "notification_text": {
+ "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Message shown in the location bar notification."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string for the location bar notification."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "active_color": {
+ "type": "string",
+ "description": "Background color of the button"
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "description": "The page to open when the button is clicked.",
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "Icon associated with the message"}
+ ]
+ },
+ "where": {
+ "description": "Should it open in a new tab or the current tab",
+ "enum": ["current", "tabshifted"]
+ }
+ },
+ "additionalProperties": "false",
+ "required": ["url", "where"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["layout", "category", "bucket_id", "notification_text", "action"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json
new file mode 100644
index 0000000000..fd9e3acc0e
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json
@@ -0,0 +1,365 @@
+{
+ "title": "ExtensionDoorhanger",
+ "description": "A template with a heading, addon icon, title and description. No markup allowed.",
+ "version": "1.0.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider"
+ },
+ "layout": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider",
+ "enum": ["short_message", "message_and_animation", "icon_and_message", "addon_recommendation"]
+ },
+ "anchor_id": {
+ "type": "string",
+ "description": "A DOM element ID that the pop-over will be anchored."
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "skip_address_bar_notifier": {
+ "type": "boolean",
+ "description": "Skip the 'Recommend' notifier and show directly."
+ },
+ "persistent_doorhanger": {
+ "type": "boolean",
+ "description": "Prevent the doorhanger from being dismissed if user interacts with the page or switches between applications."
+ },
+ "notification_text": {
+ "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Message shown in the location bar notification."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string for the location bar notification."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "info_icon": {
+ "type": "object",
+ "description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark." ,
+ "properties": {
+ "label": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "tooltiptext": {
+ "type": "string",
+ "description": "Text for button tooltip used to provide information about the doorhanger."
+ }
+ },
+ "required": ["tooltiptext"]
+ }
+ },
+ "required": ["attributes"]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string used to provide information about the doorhanger."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "sumo_path": {
+ "type": "string",
+ "description": "Last part of the path in the URL to the support page with the information about the doorhanger.",
+ "examples": ["extensionpromotions", "extensionrecommendations"]
+ }
+ }
+ },
+ "learn_more": {
+ "type": "string",
+ "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.",
+ "examples": ["extensionpromotions", "extensionrecommendations"]
+ },
+ "heading_text": {
+ "description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The message displayed in the title of the extension doorhanger"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for extension doorhanger title"
+ }
+ ]
+ },
+ "icon": {
+ "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg.",
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "Icon associated with the message"}
+ ]
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg."
+ },
+ "icon_class": {
+ "type": "string",
+ "description": "CSS class of the pop-over icon."
+ },
+ "addon": {
+ "description": "Addon information including AMO URL.",
+ "type": "object",
+ "properties": {
+ "id": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Unique addon ID"}
+ ]
+ },
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Addon name"}
+ ]
+ },
+ "author": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Addon author"}
+ ]
+ },
+ "icon": {
+ "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg.",
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "Addon icon"}
+ ]
+ },
+ "rating": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 5,
+ "description": "Star rating"
+ },
+ "users": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Installed users"
+ },
+ "amo_url": {
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "Link that offers more information related to the addon."}
+ ]
+ }
+ },
+ "required": ["title", "author", "icon", "amo_url"]
+ },
+ "text": {
+ "description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Description message of the addon."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of string to localized addon description"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "descriptionDetails": {
+ "description": "Additional information and steps on how to use",
+ "type": "object",
+ "properties": {
+ "steps": {
+ "description": "Array of messages or string_ids",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of string to localized addon description"
+ }
+ },
+ "required": ["string_id"]
+ }
+ }
+ },
+ "required": ["steps"]
+ },
+ "buttons": {
+ "description": "The label and functionality for the buttons in the pop-over.",
+ "type": "object",
+ "properties": {
+ "primary": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "value": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Button label override used when a localized version is not available."}
+ ]
+ },
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "accesskey": {
+ "type": "string",
+ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
+ }
+ },
+ "required": ["accesskey"],
+ "description": "Button attributes."
+ }
+ },
+ "required": ["value", "attributes"]
+ },
+ {
+ "properties": {
+ "string_id": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Id of localized string for button"}
+ ]
+ }
+ },
+ "required": ["string_id"]
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "$comment": "This is dynamically generated from the addon.id. See CFRPageActions.jsm",
+ "description": "URL used in combination with the primary action dispatched."
+ }
+ }
+ }
+ }
+ }
+ },
+ "secondary": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "value": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Button label override used when a localized version is not available."}
+ ]
+ },
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "accesskey": {
+ "type": "string",
+ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
+ }
+ },
+ "required": ["accesskey"],
+ "description": "Button attributes."
+ }
+ },
+ "required": ["value", "attributes"]
+ },
+ {
+ "properties": {
+ "string_id": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Id of localized string for button"}
+ ]
+ }
+ },
+ "required": ["string_id"]
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "URL used in combination with the primary action dispatched."}
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["layout", "category", "bucket_id", "notification_text", "heading_text", "text", "buttons"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json
new file mode 100644
index 0000000000..3bbaa1ca4f
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json
@@ -0,0 +1,96 @@
+{
+ "title": "InfoBar",
+ "description": "A template with an image, test and buttons.",
+ "version": "1.0.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).",
+ "enum": ["global", "tab"]
+ },
+ "text": {
+ "description": "The text show in the notification box.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Message shown in the location bar notification."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string for the location bar notification."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "buttons": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "description": "The text label of the button.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Message content for the button."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string for the button."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "primary": {
+ "type": "boolean",
+ "description": "Is this the primary button?"
+ },
+ "accessKey": {
+ "type": "string",
+ "description": "Keyboard shortcut letter."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["label", "action", "accessKey"],
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["text", "buttons"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx
new file mode 100644
index 0000000000..f324a69853
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx
@@ -0,0 +1,153 @@
+/* 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 { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet";
+
+class EOYSnippetBase extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ /**
+ * setFrequencyValue - `frequency` form parameter value should be `monthly`
+ * if `monthly-checkbox` is selected or `single` otherwise
+ */
+ setFrequencyValue() {
+ const frequencyCheckbox = this.refs.form.querySelector("#monthly-checkbox");
+ if (frequencyCheckbox.checked) {
+ this.refs.form.querySelector("[name='frequency']").value = "monthly";
+ }
+ }
+
+ handleSubmit(event) {
+ event.preventDefault();
+ this.props.sendClick(event);
+ this.setFrequencyValue();
+ if (!this.props.content.do_not_autoblock) {
+ this.props.onBlock();
+ }
+ this.refs.form.submit();
+ }
+
+ renderDonations() {
+ const fieldNames = ["first", "second", "third", "fourth"];
+ const numberFormat = new Intl.NumberFormat(
+ this.props.content.locale || navigator.language,
+ {
+ style: "currency",
+ currency: this.props.content.currency_code,
+ minimumFractionDigits: 0,
+ }
+ );
+ // Default to `second` button
+ const { selected_button } = this.props.content;
+ const btnStyle = {
+ color: this.props.content.button_color,
+ backgroundColor: this.props.content.button_background_color,
+ };
+ const donationURLParams = [];
+ const paramsStartIndex = this.props.content.donation_form_url.indexOf("?");
+ for (const entry of new URLSearchParams(
+ this.props.content.donation_form_url.slice(paramsStartIndex)
+ ).entries()) {
+ donationURLParams.push(entry);
+ }
+
+ return (
+ <form
+ className="EOYSnippetForm"
+ action={this.props.content.donation_form_url}
+ method={this.props.form_method}
+ onSubmit={this.handleSubmit}
+ data-metric="EOYSnippetForm"
+ ref="form"
+ >
+ {donationURLParams.map(([key, value], idx) => (
+ <input type="hidden" name={key} value={value} key={idx} />
+ ))}
+ {fieldNames.map((field, idx) => {
+ const button_name = `donation_amount_${field}`;
+ const amount = this.props.content[button_name];
+ return (
+ <React.Fragment key={idx}>
+ <input
+ type="radio"
+ name="amount"
+ value={amount}
+ id={field}
+ defaultChecked={button_name === selected_button}
+ />
+ <label htmlFor={field} className="donation-amount">
+ {numberFormat.format(amount)}
+ </label>
+ </React.Fragment>
+ );
+ })}
+
+ <div className="monthly-checkbox-container">
+ <input id="monthly-checkbox" type="checkbox" />
+ <label htmlFor="monthly-checkbox">
+ {this.props.content.monthly_checkbox_label_text}
+ </label>
+ </div>
+
+ <input type="hidden" name="frequency" value="single" />
+ <input
+ type="hidden"
+ name="currency"
+ value={this.props.content.currency_code}
+ />
+ <input
+ type="hidden"
+ name="presets"
+ value={fieldNames.map(
+ field => this.props.content[`donation_amount_${field}`]
+ )}
+ />
+ <button
+ style={btnStyle}
+ type="submit"
+ className="ASRouterButton primary donation-form-url"
+ >
+ {this.props.content.button_label}
+ </button>
+ </form>
+ );
+ }
+
+ render() {
+ const textStyle = {
+ color: this.props.content.text_color,
+ backgroundColor: this.props.content.background_color,
+ };
+ const customElement = (
+ <em style={{ backgroundColor: this.props.content.highlight_color }} />
+ );
+ return (
+ <SimpleSnippet
+ {...this.props}
+ className={this.props.content.test}
+ customElements={{ em: customElement }}
+ textStyle={textStyle}
+ extraContent={this.renderDonations()}
+ />
+ );
+ }
+}
+
+export const EOYSnippet = props => {
+ const extendedContent = {
+ monthly_checkbox_label_text: "Make my donation monthly",
+ locale: "en-US",
+ currency_code: "usd",
+ selected_button: "donation_amount_second",
+ ...props.content,
+ };
+
+ return (
+ <EOYSnippetBase {...props} content={extendedContent} form_method="GET" />
+ );
+};
diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json
new file mode 100644
index 0000000000..a82de98e09
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json
@@ -0,0 +1,159 @@
+{
+ "title": "EOYSnippet",
+ "description": "Fundraising Snippet",
+ "version": "1.1.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "donation_form_url": {
+ "type": "string",
+ "description": "Url to the donation form."
+ },
+ "currency_code": {
+ "type": "string",
+ "description": "The code for the currency. Examle gbp, cad, usd.",
+ "default": "usd"
+ },
+ "locale": {
+ "type": "string",
+ "description": "String for the locale code.",
+ "default": "en-US"
+ },
+ "text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "text_color": {
+ "type": "string",
+ "description": "Modify the text message color"
+ },
+ "background_color": {
+ "type": "string",
+ "description": "Snippet background color."
+ },
+ "highlight_color": {
+ "type": "string",
+ "description": "Paragraph em highlight color."
+ },
+ "donation_amount_first": {
+ "type": "number",
+ "description": "First button amount."
+ },
+ "donation_amount_second": {
+ "type": "number",
+ "description": "Second button amount."
+ },
+ "donation_amount_third": {
+ "type": "number",
+ "description": "Third button amount."
+ },
+ "donation_amount_fourth": {
+ "type": "number",
+ "description": "Fourth button amount."
+ },
+ "selected_button": {
+ "type": "string",
+ "description": "Default donation_amount_second. Donation amount button that's selected by default.",
+ "default": "donation_amount_second"
+ },
+ "icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "icon_alt_text": {
+ "type": "string",
+ "description": "Alt text for accessibility",
+ "default": ""
+ },
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Snippet title displayed before snippet text"}
+ ]
+ },
+ "title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ]
+ },
+ "button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "block_button_text": {
+ "type": "string",
+ "description": "Tooltip text used for dismiss button."
+ },
+ "monthly_checkbox_label_text": {
+ "type": "string",
+ "description": "Label text for monthly checkbox.",
+ "default": "Make my donation monthly"
+ },
+ "test": {
+ "type": "string",
+ "description": "Different styles for the snippet. Options are bold and takeover."
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked"
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ },
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for link action, example which specific menu the button should open"
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["text", "donation_form_url", "donation_amount_first", "donation_amount_second", "donation_amount_third", "donation_amount_fourth", "button_label", "currency_code"],
+ "dependencies": {
+ "button_color": ["button_label"],
+ "button_background_color": ["button_label"]
+ }
+}
+
diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss
new file mode 100644
index 0000000000..ef17606e80
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss
@@ -0,0 +1,54 @@
+.EOYSnippetForm {
+ margin: 10px 0 8px;
+ align-self: start;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+
+ .donation-amount,
+ .donation-form-url {
+ white-space: nowrap;
+ font-size: 14px;
+ padding: 8px 20px;
+ border-radius: 2px;
+ }
+
+ .donation-amount {
+ color: $grey-90;
+ margin-inline-end: 18px;
+ border: 1px solid $grey-40;
+ padding: 5px 14px;
+ background: $grey-10;
+ cursor: pointer;
+ }
+
+ input {
+ &[type='radio'] {
+ opacity: 0;
+ margin-inline-end: -18px;
+
+ &:checked + .donation-amount {
+ background: $grey-50;
+ color: $white;
+ border: 1px solid $grey-60;
+ }
+
+ // accessibility
+ &:checked:focus + .donation-amount,
+ &:not(:checked):focus + .donation-amount {
+ border: 1px dotted var(--newtab-link-primary-color);
+ }
+ }
+ }
+
+ .monthly-checkbox-container {
+ display: flex;
+ width: 100%;
+ }
+
+ .donation-form-url {
+ margin-inline-start: 18px;
+ align-self: flex-end;
+ display: flex;
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx
new file mode 100644
index 0000000000..1d8197d675
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx
@@ -0,0 +1,38 @@
+/* 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 { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx";
+
+export const FXASignupSnippet = props => {
+ const userAgent = window.navigator.userAgent.match(/Firefox\/([0-9]+)\./);
+ const firefox_version = userAgent ? parseInt(userAgent[1], 10) : 0;
+ const extendedContent = {
+ scene1_button_label: "Learn more",
+ retry_button_label: "Try again",
+ scene2_email_placeholder_text: "Your email here",
+ scene2_button_label: "Sign me up",
+ scene2_dismiss_button_text: "Dismiss",
+ ...props.content,
+ hidden_inputs: {
+ action: "email",
+ context: "fx_desktop_v3",
+ entrypoint: "snippets",
+ utm_source: "snippet",
+ utm_content: firefox_version,
+ utm_campaign: props.content.utm_campaign,
+ utm_term: props.content.utm_term,
+ ...props.content.hidden_inputs,
+ },
+ };
+
+ return (
+ <SubmitFormSnippet
+ {...props}
+ content={extendedContent}
+ form_action={"https://accounts.firefox.com/"}
+ form_method="GET"
+ />
+ );
+};
diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json
new file mode 100644
index 0000000000..d7d3e37bbc
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json
@@ -0,0 +1,187 @@
+{
+ "title": "FXASignupSnippet",
+ "description": "A snippet template for FxA sign up/sign in",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "scene1_title": {
+ "allof": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "snippet title displayed before snippet text"}
+ ]
+ },
+ "scene1_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_section_title_icon": {
+ "type": "string",
+ "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_text": {
+ "type": "string",
+ "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display."
+ },
+ "scene1_section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, scene1_section_title_text links to this"}
+ ]
+ },
+ "scene2_title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Title displayed before text in scene 2. Should be plain text."}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene2_email_placeholder_text": {
+ "type": "string",
+ "description": "Value to show while input is empty.",
+ "default": "Your email here"
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button",
+ "default": "Sign me up"
+ },
+ "scene2_dismiss_button_text": {
+ "type": "string",
+ "description": "Label for the dismiss button when the sign-up form is expanded.",
+ "default": "Dismiss"
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property.",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["email"]
+ },
+ "context": {
+ "type": "string",
+ "enum": ["fx_desktop_v3"]
+ },
+ "entrypoint": {
+ "type": "string",
+ "enum": ["snippets"]
+ },
+ "utm_content": {
+ "type": "number",
+ "description": "Firefox version number"
+ },
+ "utm_source": {
+ "type": "string",
+ "enum": ["snippet"]
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "additionalProperties": false
+ }
+ },
+ "scene1_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ],
+ "default": "Learn more"
+ },
+ "scene1_button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "scene1_button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked",
+ "default": false
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene1_text", "scene2_text", "scene1_button_label"],
+ "dependencies": {
+ "scene1_button_color": ["scene1_button_label"],
+ "scene1_button_background_color": ["scene1_button_label"]
+ }
+}
+
diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js
new file mode 100644
index 0000000000..cb29f66d6e
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.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/. */
+
+/**
+ * BASE_PARAMS keys/values can be modified from outside this file
+ */
+export const BASE_PARAMS = {
+ utm_source: "activity-stream",
+ utm_campaign: "firstrun",
+ utm_medium: "referral",
+};
+
+/**
+ * Takes in a url as a string or URL object and returns a URL object with the
+ * utm_* parameters added to it. If a URL object is passed in, the paraemeters
+ * are added to it (the return value can be ignored in that case as it's the
+ * same object).
+ */
+export function addUtmParams(url, utmTerm) {
+ let returnUrl = url;
+ if (typeof returnUrl === "string") {
+ returnUrl = new URL(url);
+ }
+ Object.keys(BASE_PARAMS).forEach(key => {
+ returnUrl.searchParams.append(key, BASE_PARAMS[key]);
+ });
+ returnUrl.searchParams.append("utm_term", utmTerm);
+ return returnUrl;
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx
new file mode 100644
index 0000000000..27c1684762
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.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 { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx";
+
+export const NewsletterSnippet = props => {
+ const extendedContent = {
+ scene1_button_label: "Learn more",
+ retry_button_label: "Try again",
+ scene2_email_placeholder_text: "Your email here",
+ scene2_button_label: "Sign me up",
+ scene2_dismiss_button_text: "Dismiss",
+ scene2_newsletter: "mozilla-foundation",
+ ...props.content,
+ hidden_inputs: {
+ newsletters: props.content.scene2_newsletter || "mozilla-foundation",
+ fmt: "H",
+ lang: props.content.locale || "en-US",
+ source_url: `https://snippets.mozilla.com/show/${props.id}`,
+ ...props.content.hidden_inputs,
+ },
+ };
+
+ return (
+ <SubmitFormSnippet
+ {...props}
+ content={extendedContent}
+ form_action={"https://basket.mozilla.org/subscribe.json"}
+ form_method="POST"
+ />
+ );
+};
diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json
new file mode 100644
index 0000000000..eeb63554ed
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json
@@ -0,0 +1,177 @@
+{
+ "title": "NewsletterSnippet",
+ "description": "A snippet template for send to device mobile download",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Two to five character string for the locale code",
+ "default": "en-US"
+ },
+ "scene1_title": {
+ "allof": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "snippet title displayed before snippet text"}
+ ]
+ },
+ "scene1_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_section_title_icon": {
+ "type": "string",
+ "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_text": {
+ "type": "string",
+ "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display."
+ },
+ "scene1_section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, scene1_section_title_text links to this"}
+ ]
+ },
+ "scene2_title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Title displayed before text in scene 2. Should be plain text."}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene2_email_placeholder_text": {
+ "type": "string",
+ "description": "Value to show while input is empty.",
+ "default": "Your email here"
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button",
+ "default": "Sign me up"
+ },
+ "scene2_privacy_html": {
+ "type": "string",
+ "description": "(send to device) Html for disclaimer and link underneath input box."
+ },
+ "scene2_dismiss_button_text": {
+ "type": "string",
+ "description": "Label for the dismiss button when the sign-up form is expanded.",
+ "default": "Dismiss"
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property.",
+ "properties": {
+ "fmt": {
+ "type": "string",
+ "description": "",
+ "default": "H"
+ }
+ }
+ },
+ "scene1_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ],
+ "default": "Learn more"
+ },
+ "scene1_button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "scene1_button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked",
+ "default": false
+ },
+ "success_text": {
+ "type": "string",
+ "description": "Message shown on successful registration."
+ },
+ "error_text": {
+ "type": "string",
+ "description": "Message shown if registration failed."
+ },
+ "scene2_newsletter": {
+ "type": "string",
+ "description": "Newsletter/basket id user is subscribing to.",
+ "default": "mozilla-foundation"
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene1_text", "scene2_text", "scene1_button_label"],
+ "dependencies": {
+ "scene1_button_color": ["scene1_button_label"],
+ "scene1_button_background_color": ["scene1_button_label"]
+ }
+}
+
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
new file mode 100644
index 0000000000..ce1a840247
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
@@ -0,0 +1,52 @@
+/* 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 { Localized } from "../../../aboutwelcome/components/MSLocalized";
+
+export class OnboardingCard extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick() {
+ const { props } = this;
+ const ping = {
+ event: "CLICK_BUTTON",
+ message_id: props.id,
+ id: props.UISurface,
+ };
+ props.sendUserActionTelemetry(ping);
+ props.onAction(props.content.primary_button.action, props.message);
+ }
+
+ render() {
+ const { content } = this.props;
+ const className = this.props.className || "onboardingMessage";
+ return (
+ <div className={className}>
+ <div className={`onboardingMessageImage ${content.icon}`} />
+ <div className="onboardingContent">
+ <span>
+ <Localized text={content.title}>
+ <h2 className="onboardingTitle" />
+ </Localized>
+ <Localized text={content.text}>
+ <p className="onboardingText" />
+ </Localized>
+ </span>
+ <span className="onboardingButtonContainer">
+ <Localized text={content.primary_button.label}>
+ <button
+ className="button onboardingButton"
+ onClick={this.onClick}
+ />
+ </Localized>
+ </span>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json
new file mode 100644
index 0000000000..f355d89da7
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json
@@ -0,0 +1,142 @@
+{
+ "title": "OnboardingMessage",
+ "description": "A template with a title, icon, button and description. No markup allowed.",
+ "version": "1.0.0",
+ "type": "object",
+ "properties": {
+ "title": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The message displayed in the title of the onboarding card"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for onboarding card title"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "text": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The message displayed in the description of the onboarding card"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ },
+ "args": {
+ "type": "object",
+ "description": "An optional argument to pass to the localization module"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for onboarding card description"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "icon": {
+ "allOf": [
+ {
+ "type": "string",
+ "description": "Image associated with the onboarding card"
+ }
+ ]
+ },
+ "primary_button": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The label of the onboarding messages' action button"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for onboarding messages' button"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for button action, for example which link the button should open."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "secondary_buttons": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The label of the onboarding messages' (optional) secondary action button"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for onboarding messages' button"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for button action, for example which link the button should open."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": ["title", "text", "icon", "primary_button"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
new file mode 100644
index 0000000000..b873e62b83
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
@@ -0,0 +1,39 @@
+{
+ "title": "ToolbarBadgeMessage",
+ "description": "A template that specifies to which element in the browser toolbar to add a notification.",
+ "version": "1.1.0",
+ "type": "object",
+ "properties": {
+ "target": {
+ "type": "string"
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Optional action to take in addition to showing the notification"
+ },
+ "delay": {
+ "type": "number",
+ "description": "Optional delay in ms after which to show the notification"
+ },
+ "badgeDescription": {
+ "type": "object",
+ "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent string id"
+ }
+ },
+ "required": ["string_id"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["target"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json
new file mode 100644
index 0000000000..7624c67d4c
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json
@@ -0,0 +1,36 @@
+{
+ "title": "UpdateActionMessage",
+ "description": "A template for messages that execute predetermined actions.",
+ "version": "1.0.0",
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "URL data to be used as argument to the action"
+ },
+ "expireDelta": {
+ "type": "number",
+ "description": "Expiration timestamp to be used as argument to the action"
+ }
+ }
+ },
+ "description": "Additional data provided as argument when executing the action"
+ },
+ "additionalProperties": false,
+ "description": "Optional action to take in addition to showing the notification"
+ },
+ "additionalProperties": false,
+ "required": ["id", "action"]
+ },
+ "additionalProperties": false,
+ "required": ["action"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json
new file mode 100644
index 0000000000..998f2cfc8d
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json
@@ -0,0 +1,97 @@
+{
+ "title": "WhatsNewMessage",
+ "description": "A template for the messages that appear in the What's New panel.",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "localizableText": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The string to be rendered."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string to be rendered."
+ }
+ ]
+ }
+ },
+ "properties": {
+ "layout": {
+ "description": "Different message layouts",
+ "enum": ["tracking-protections"]
+ },
+ "layout_title_content_variable": {
+ "description": "Select what profile specific value to show for the current layout.",
+ "type": "string"
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "published_date": {
+ "type": "integer",
+ "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published."
+ },
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "Id of localized string or message override of What's New message title"}
+ ]
+ },
+ "subtitle": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "Id of localized string or message override of What's New message subtitle"}
+ ]
+ },
+ "body": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "Id of localized string or message override of What's New message body"}
+ ]
+ },
+ "link_text": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "(optional) Id of localized string or message override of What's New message link text"}
+ ]
+ },
+ "cta_url": {
+ "description": "Target URL for the What's New message.",
+ "type": "string",
+ "format": "uri"
+ },
+ "cta_type": {
+ "description": "Type of url open action",
+ "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"]
+ },
+ "cta_where": {
+ "description": "How to open the cta: new window, tab, focused, unfocused.",
+ "enum": ["current", "tabshifted", "tab", "save", "window"]
+ },
+ "icon_url": {
+ "description": "(optional) URL for the What's New message icon.",
+ "type": "string",
+ "format": "uri"
+ },
+ "icon_alt": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "Alt text for image."}
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["published_date", "title", "body", "cta_url", "bucket_id"],
+ "dependencies": {
+ "layout": ["layout_title_content_variable"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
new file mode 100644
index 0000000000..d4109317a1
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
@@ -0,0 +1,131 @@
+@import '../../../styles/OnboardingImages';
+
+.onboardingMessage {
+ height: 340px;
+ text-align: center;
+ padding: 13px;
+ font-weight: 200;
+
+ // at 850px, img floats left, content floats right next to it
+ @media(max-width: 850px) {
+ height: 170px;
+ text-align: left;
+ padding: 10px;
+ border-bottom: 1px solid $grey-30;
+ display: flex;
+ margin-bottom: 11px;
+
+ &:last-child {
+ border: 0;
+ }
+
+ .onboardingContent {
+ padding-left: 10px;
+ height: 100%;
+
+ > span > h3 {
+ margin-top: 0;
+ margin-bottom: 4px;
+ font-weight: 400;
+ }
+
+ > span > p {
+ margin-top: 0;
+ line-height: 22px;
+ font-size: 15px;
+ }
+ }
+ }
+
+ @media(max-width: 650px) {
+ height: 250px;
+ }
+
+ .onboardingContent {
+ height: 175px;
+
+ > span > h3 {
+ color: $grey-90;
+ margin-bottom: 8px;
+ font-weight: 400;
+ }
+
+ > span > p {
+ color: $grey-60;
+ margin-top: 0;
+ height: 180px;
+ margin-bottom: 12px;
+ font-size: 15px;
+ line-height: 22px;
+
+ @media(max-width: 650px) {
+ margin-bottom: 0;
+ height: 160px;
+ }
+ }
+ }
+
+ .onboardingButton {
+ background-color: $grey-90-10;
+ border: 0;
+ width: 150px;
+ height: 30px;
+ margin-bottom: 23px;
+ padding: 4px 0 6px;
+ font-size: 15px;
+
+ // at 850px, the button shimmies down and to the right
+ @media(max-width: 850px) {
+ float: right;
+ margin-top: -105px;
+ margin-inline-end: -10px;
+ }
+
+ @media(max-width: 650px) {
+ float: none;
+ }
+
+ &:focus,
+ &.active,
+ &:hover {
+ box-shadow: 0 0 0 5px $grey-30;
+ transition: box-shadow 150ms;
+ }
+ }
+
+
+ &::before {
+ content: '';
+ height: 230px;
+ width: 1px;
+ position: absolute;
+ background-color: $grey-30;
+ margin-top: 40px;
+ margin-inline-start: 215px;
+
+ // at 850px, the line goes from vertical to horizontal
+ @media(max-width: 850px) {
+ content: none;
+ }
+ }
+
+ &:last-child::before {
+ content: none;
+ }
+}
+
+.onboardingMessageImage {
+ height: 112px;
+ width: 180px;
+ background-size: auto 140px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ display: inline-block;
+
+ // Cards will wrap into the next line after this breakpoint
+ @media(max-width: 865px) {
+ height: 75px;
+ min-width: 80px;
+ background-size: 140px;
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx
new file mode 100644
index 0000000000..0929b8f711
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx
@@ -0,0 +1,76 @@
+/* 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 { isEmailOrPhoneNumber } from "./isEmailOrPhoneNumber";
+import React from "react";
+import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx";
+
+function validateInput(value, content) {
+ const type = isEmailOrPhoneNumber(value, content);
+ return type ? "" : "Must be an email or a phone number.";
+}
+
+function processFormData(input, message) {
+ const { content } = message;
+ const type = content.include_sms
+ ? isEmailOrPhoneNumber(input.value, content)
+ : "email";
+ const formData = new FormData();
+ let url;
+ if (type === "phone") {
+ url = "https://basket.mozilla.org/news/subscribe_sms/";
+ formData.append("mobile_number", input.value);
+ formData.append("msg_name", content.message_id_sms);
+ formData.append("country", content.country);
+ } else if (type === "email") {
+ url = "https://basket.mozilla.org/news/subscribe/";
+ formData.append("email", input.value);
+ formData.append("newsletters", content.message_id_email);
+ formData.append(
+ "source_url",
+ encodeURIComponent(`https://snippets.mozilla.com/show/${message.id}`)
+ );
+ }
+ formData.append("lang", content.locale);
+ return { formData, url };
+}
+
+function addDefaultValues(props) {
+ return {
+ ...props,
+ content: {
+ scene1_button_label: "Learn more",
+ retry_button_label: "Try again",
+ scene2_dismiss_button_text: "Dismiss",
+ scene2_button_label: "Send",
+ scene2_input_placeholder: "Your email here",
+ locale: "en-US",
+ country: "us",
+ message_id_email: "",
+ include_sms: false,
+ ...props.content,
+ },
+ };
+}
+
+export const SendToDeviceSnippet = props => {
+ const propsWithDefaults = addDefaultValues(props);
+
+ return (
+ <SubmitFormSnippet
+ {...propsWithDefaults}
+ form_method="POST"
+ className="send_to_device_snippet"
+ inputType={propsWithDefaults.content.include_sms ? "text" : "email"}
+ validateInput={
+ propsWithDefaults.content.include_sms ? validateInput : null
+ }
+ processFormData={processFormData}
+ />
+ );
+};
+
+export const SendToDeviceScene2Snippet = props => {
+ return <SendToDeviceSnippet expandedAlt={true} {...props} />;
+};
diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json
new file mode 100644
index 0000000000..238840234a
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json
@@ -0,0 +1,234 @@
+{
+ "title": "SendToDeviceSnippet",
+ "description": "A snippet template for send to device mobile download",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Two to five character string for the locale code",
+ "default": "en-US"
+ },
+ "country": {
+ "type": "string",
+ "description": "Two character string for the country code (used for SMS)",
+ "default": "us"
+ },
+ "scene1_title": {
+ "allof": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "snippet title displayed before snippet text"}
+ ]
+ },
+ "scene1_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_section_title_icon": {
+ "type": "string",
+ "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_text": {
+ "type": "string",
+ "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display."
+ },
+ "scene1_section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, scene1_section_title_text links to this"}
+ ]
+ },
+ "scene2_title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Title displayed before text in scene 2. Should be plain text."}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "scene2_icon": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_dark_theme": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."
+ },
+ "scene1_title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button",
+ "default": "Send"
+ },
+ "scene2_input_placeholder": {
+ "type": "string",
+ "description": "(send to device) Value to show while input is empty.",
+ "default": "Your email here"
+ },
+ "scene2_disclaimer_html": {
+ "type": "string",
+ "description": "(send to device) Html for disclaimer and link underneath input box."
+ },
+ "scene2_dismiss_button_text": {
+ "type": "string",
+ "description": "Label for the dismiss button when the sign-up form is expanded.",
+ "default": "Dismiss"
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property.",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["email"]
+ },
+ "context": {
+ "type": "string",
+ "enum": ["fx_desktop_v3"]
+ },
+ "entrypoint": {
+ "type": "string",
+ "enum": ["snippets"]
+ },
+ "utm_content": {
+ "type": "string",
+ "description": "Firefox version number"
+ },
+ "utm_source": {
+ "type": "string",
+ "enum": ["snippet"]
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "additionalProperties": false
+ }
+ },
+ "scene1_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ],
+ "default": "Learn more"
+ },
+ "scene1_button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "scene1_button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked",
+ "default": false
+ },
+ "success_title": {
+ "type": "string",
+ "description": "(send to device) Title shown before text on successful registration."
+ },
+ "success_text": {
+ "type": "string",
+ "description": "Message shown on successful registration."
+ },
+ "error_text": {
+ "type": "string",
+ "description": "Message shown if registration failed."
+ },
+ "include_sms": {
+ "type": "boolean",
+ "description": "(send to device) Allow users to send an SMS message with the form?",
+ "default": false
+ },
+ "message_id_sms": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the SMS message to be sent."
+ },
+ "message_id_email": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/."
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene1_text", "scene2_text", "scene1_button_label"],
+ "dependencies": {
+ "scene1_button_color": ["scene1_button_label"],
+ "scene1_button_background_color": ["scene1_button_label"]
+ }
+}
+
diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js
new file mode 100644
index 0000000000..29addb688d
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js
@@ -0,0 +1,38 @@
+/* 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/. */
+
+/**
+ * Checks if a given string is an email or phone number or neither
+ * @param {string} val The user input
+ * @param {ASRMessageContent} content .content property on ASR message
+ * @returns {"email"|"phone"|""} The type of the input
+ */
+export function isEmailOrPhoneNumber(val, content) {
+ const { locale } = content;
+ // http://emailregex.com/
+ const email_re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+ const check_email = email_re.test(val);
+ let check_phone; // depends on locale
+ switch (locale) {
+ case "en-US":
+ case "en-CA":
+ // allow 10-11 digits in case user wants to enter country code
+ check_phone = val.length >= 10 && val.length <= 11 && !isNaN(val);
+ break;
+ case "de":
+ // allow between 2 and 12 digits for german phone numbers
+ check_phone = val.length >= 2 && val.length <= 12 && !isNaN(val);
+ break;
+ // this case should never be hit, but good to have a fallback just in case
+ default:
+ check_phone = !isNaN(val);
+ break;
+ }
+ if (check_email) {
+ return "email";
+ } else if (check_phone) {
+ return "phone";
+ }
+ return "";
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
new file mode 100644
index 0000000000..2641d51e86
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
@@ -0,0 +1,133 @@
+/* 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 { Button } from "../../components/Button/Button";
+import { RichText } from "../../components/RichText/RichText";
+import { safeURI } from "../../template-utils";
+import { SnippetBase } from "../../components/SnippetBase/SnippetBase";
+
+const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+// Alt text placeholder in case the prop from the server isn't available
+const ICON_ALT_TEXT = "";
+
+export class SimpleBelowSearchSnippet extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onButtonClick = this.onButtonClick.bind(this);
+ }
+
+ renderText() {
+ const { props } = this;
+ return props.content.text ? (
+ <RichText
+ text={props.content.text}
+ customElements={this.props.customElements}
+ localization_id="text"
+ links={props.content.links}
+ sendClick={props.sendClick}
+ />
+ ) : null;
+ }
+
+ renderTitle() {
+ const { title } = this.props.content;
+ return title ? (
+ <h3 className={"title title-inline"}>
+ {title}
+ <br />
+ </h3>
+ ) : null;
+ }
+
+ async onButtonClick() {
+ if (this.props.provider !== "preview") {
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ id: this.props.UISurface,
+ });
+ }
+ const { button_url } = this.props.content;
+ // If button_url is defined handle it as OPEN_URL action
+ const type = this.props.content.button_action || (button_url && "OPEN_URL");
+ await this.props.onAction({
+ type,
+ data: { args: this.props.content.button_action_args || button_url },
+ });
+ if (!this.props.content.do_not_autoblock) {
+ this.props.onBlock();
+ }
+ }
+
+ _shouldRenderButton() {
+ return (
+ this.props.content.button_action ||
+ this.props.onButtonClick ||
+ this.props.content.button_url
+ );
+ }
+
+ renderButton() {
+ const { props } = this;
+ if (!this._shouldRenderButton()) {
+ return null;
+ }
+
+ return (
+ <Button
+ onClick={props.onButtonClick || this.onButtonClick}
+ color={props.content.button_color}
+ backgroundColor={props.content.button_background_color}
+ >
+ {props.content.button_label}
+ </Button>
+ );
+ }
+
+ render() {
+ const { props } = this;
+ let className = "SimpleBelowSearchSnippet";
+ let containerName = "below-search-snippet";
+
+ if (props.className) {
+ className += ` ${props.className}`;
+ }
+ if (this._shouldRenderButton()) {
+ className += " withButton";
+ containerName += " withButton";
+ }
+
+ return (
+ <div className={containerName}>
+ <div className="snippet-hover-wrapper">
+ <SnippetBase
+ {...props}
+ className={className}
+ textStyle={this.props.textStyle}
+ >
+ <img
+ src={safeURI(props.content.icon) || DEFAULT_ICON_PATH}
+ className="icon icon-light-theme"
+ alt={props.content.icon_alt_text || ICON_ALT_TEXT}
+ />
+ <img
+ src={
+ safeURI(props.content.icon_dark_theme || props.content.icon) ||
+ DEFAULT_ICON_PATH
+ }
+ className="icon icon-dark-theme"
+ alt={props.content.icon_alt_text || ICON_ALT_TEXT}
+ />
+ <div className="textContainer">
+ {this.renderTitle()}
+ <p className="body">{this.renderText()}</p>
+ {this.props.extraContent}
+ </div>
+ {<div className="buttonContainer">{this.renderButton()}</div>}
+ </SnippetBase>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json
new file mode 100644
index 0000000000..049f66ef6b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json
@@ -0,0 +1,110 @@
+{
+ "title": "SimpleBelowSearchSnippet",
+ "description": "A simple template with an icon, rich text and an optional button. It gets inserted below the Activity Stream search box.",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Snippet title displayed before snippet text"}
+ ]
+ },
+ "text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing icon for screen readers",
+ "default": ""
+ },
+ "block_button_text": {
+ "type": "string",
+ "description": "Tooltip text used for dismiss button.",
+ "default": "Remove this"
+ },
+ "button_action": {
+ "type": "string",
+ "description": "The type of action the button should trigger."
+ },
+ "button_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, button_label links to this"}
+ ]
+ },
+ "button_action_args": {
+ "description": "Additional parameters for button action, example which specific menu the button should open"
+ },
+ "button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ]
+ },
+ "button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA link has been clicked"
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ },
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for link action, example which specific menu the button should open"
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["text"],
+ "dependencies": {
+ "button_action": ["button_label"],
+ "button_url": ["button_label"],
+ "button_color": ["button_label"],
+ "button_background_color": ["button_label"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
new file mode 100644
index 0000000000..dd9e637529
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
@@ -0,0 +1,198 @@
+
+.below-search-snippet {
+ margin: 0 auto 16px;
+
+ &.withButton {
+ margin: auto;
+ min-height: 60px;
+ background-color: transparent;
+
+ .snippet-hover-wrapper {
+ min-height: 60px;
+ border-radius: 4px;
+
+ &:hover {
+ background-color: var(--newtab-element-hover-color);
+
+ .blockButton {
+ display: block;
+ opacity: 1;
+ }
+ }
+ }
+ }
+}
+
+.SimpleBelowSearchSnippet {
+ background-color: transparent;
+ border: 0;
+ box-shadow: none;
+ position: relative;
+ margin: auto;
+ z-index: auto;
+
+ @media (min-width: $break-point-large) {
+ width: 736px;
+ }
+
+ &.active {
+ background-color: var(--newtab-element-hover-color);
+ border-radius: 4px;
+ }
+
+ .innerWrapper {
+ align-items: center;
+ background-color: transparent;
+ border-radius: 4px;
+ box-shadow: var(--newtab-card-shadow);
+ flex-direction: column;
+ padding: 16px;
+ text-align: center;
+ width: 100%;
+
+ @mixin full-width-styles {
+ align-items: flex-start;
+ background-color: transparent;
+ border-radius: 4px;
+ box-shadow: none;
+ flex-direction: row;
+ padding: 0;
+ text-align: inherit;
+ width: 696px;
+ }
+
+ @media (min-width: $break-point-medium) {
+ @include full-width-styles;
+ }
+
+ @media (max-width: 865px) {
+ margin-inline-start: 0;
+ }
+
+ // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 610px.
+ @media (max-width: $break-point-medium - 1px) {
+ margin: auto;
+ }
+ }
+
+ .blockButton {
+ display: block;
+ inset-inline-end: 10px;
+ opacity: 1;
+ top: 50%;
+
+ &:focus {
+ box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30;
+ border-radius: 2px;
+ }
+ }
+
+ .title {
+ font-size: inherit;
+ margin: 0;
+ }
+
+ .title-inline {
+ display: inline;
+ }
+
+ .textContainer {
+ margin: 10px;
+ margin-inline-start: 0;
+ padding-inline-end: 20px;
+ }
+
+ .icon {
+ margin-top: 8px;
+ margin-inline-start: 12px;
+ height: 32px;
+ width: 32px;
+
+ @mixin full-width-styles {
+ height: 24px;
+ width: 24px;
+ }
+
+ @media (min-width: $break-point-medium) {
+ @include full-width-styles;
+ }
+
+ @media (max-width: $break-point-medium) {
+ margin: auto;
+ }
+ }
+
+ &.withButton {
+ line-height: 20px;
+ margin-bottom: 10px;
+ min-height: 60px;
+ background-color: transparent;
+
+ .innerWrapper {
+ // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px.
+ @media (max-width: $break-point-widest + 1px) {
+ margin: 0 40px;
+ }
+ }
+
+ .blockButton {
+ display: block;
+ inset-inline-end: -10%;
+ opacity: 0;
+ margin: auto;
+ top: unset;
+
+ &:focus {
+ opacity: 1;
+ box-shadow: none;
+ }
+
+ // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px.
+ @media (max-width: $break-point-widest + 1px) {
+ inset-inline-end: 2%;
+ }
+ }
+
+ .icon {
+ width: 42px;
+ height: 42px;
+ flex-shrink: 0;
+ margin: auto 0;
+ margin-inline-end: 10px;
+
+ @media (max-width: $break-point-medium) {
+ margin: auto;
+ }
+ }
+
+ .buttonContainer {
+ margin: auto;
+ margin-inline-end: 0;
+
+ @media (max-width: $break-point-medium) {
+ margin: auto;
+ }
+ }
+ }
+
+ button {
+ @media (max-width: $break-point-medium) {
+ margin: auto;
+ }
+ }
+
+ .body {
+ display: inline;
+ position: sticky;
+ transform: translateY(-50%);
+ margin: 8px 0 0;
+
+ @media (min-width: $break-point-medium) {
+ margin: 12px 0;
+ }
+
+ a {
+ font-weight: 600;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
new file mode 100644
index 0000000000..8d7b8c1f7b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
@@ -0,0 +1,225 @@
+/* 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 { Button } from "../../components/Button/Button";
+import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper";
+import React from "react";
+import { RichText } from "../../components/RichText/RichText";
+import { safeURI } from "../../template-utils";
+import { SnippetBase } from "../../components/SnippetBase/SnippetBase";
+
+const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+// Alt text placeholder in case the prop from the server isn't available
+const ICON_ALT_TEXT = "";
+
+export class SimpleSnippet extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onButtonClick = this.onButtonClick.bind(this);
+ }
+
+ onButtonClick() {
+ if (this.props.provider !== "preview") {
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ id: this.props.UISurface,
+ });
+ }
+ const {
+ button_url,
+ button_entrypoint_value,
+ button_entrypoint_name,
+ } = this.props.content;
+ // If button_url is defined handle it as OPEN_URL action
+ const type = this.props.content.button_action || (button_url && "OPEN_URL");
+ // Assign the snippet referral for the action
+ const entrypoint = button_entrypoint_name
+ ? new URLSearchParams([
+ [button_entrypoint_name, button_entrypoint_value],
+ ]).toString()
+ : button_entrypoint_value;
+ this.props.onAction({
+ type,
+ data: {
+ args: this.props.content.button_action_args || button_url,
+ ...(entrypoint && { entrypoint }),
+ },
+ });
+ if (!this.props.content.do_not_autoblock) {
+ this.props.onBlock();
+ }
+ }
+
+ _shouldRenderButton() {
+ return (
+ this.props.content.button_action ||
+ this.props.onButtonClick ||
+ this.props.content.button_url
+ );
+ }
+
+ renderTitle() {
+ const { title } = this.props.content;
+ return title ? (
+ <h3
+ className={`title ${this._shouldRenderButton() ? "title-inline" : ""}`}
+ >
+ {this.renderTitleIcon()} {title}
+ </h3>
+ ) : null;
+ }
+
+ renderTitleIcon() {
+ const titleIconLight = safeURI(this.props.content.title_icon);
+ const titleIconDark = safeURI(
+ this.props.content.title_icon_dark_theme || this.props.content.title_icon
+ );
+ if (!titleIconLight) {
+ return null;
+ }
+
+ return (
+ <React.Fragment>
+ <span
+ className="titleIcon icon-light-theme"
+ style={{ backgroundImage: `url("${titleIconLight}")` }}
+ />
+ <span
+ className="titleIcon icon-dark-theme"
+ style={{ backgroundImage: `url("${titleIconDark}")` }}
+ />
+ </React.Fragment>
+ );
+ }
+
+ renderButton() {
+ const { props } = this;
+ if (!this._shouldRenderButton()) {
+ return null;
+ }
+
+ return (
+ <Button
+ onClick={props.onButtonClick || this.onButtonClick}
+ color={props.content.button_color}
+ backgroundColor={props.content.button_background_color}
+ >
+ {props.content.button_label}
+ </Button>
+ );
+ }
+
+ renderText() {
+ const { props } = this;
+ return (
+ <RichText
+ text={props.content.text}
+ customElements={this.props.customElements}
+ localization_id="text"
+ links={props.content.links}
+ sendClick={props.sendClick}
+ />
+ );
+ }
+
+ wrapSectionHeader(url) {
+ return function(children) {
+ return <a href={url}>{children}</a>;
+ };
+ }
+
+ wrapSnippetContent(children) {
+ return <div className="innerContentWrapper">{children}</div>;
+ }
+
+ renderSectionHeader() {
+ const { props } = this;
+
+ // an icon and text must be specified to render the section header
+ if (props.content.section_title_icon && props.content.section_title_text) {
+ const sectionTitleIconLight = safeURI(props.content.section_title_icon);
+ const sectionTitleIconDark = safeURI(
+ props.content.section_title_icon_dark_theme ||
+ props.content.section_title_icon
+ );
+ const sectionTitleURL = props.content.section_title_url;
+
+ return (
+ <div className="section-header">
+ <h3 className="section-title">
+ <ConditionalWrapper
+ condition={sectionTitleURL}
+ wrap={this.wrapSectionHeader(sectionTitleURL)}
+ >
+ <span
+ className="icon icon-small-spacer icon-light-theme"
+ style={{ backgroundImage: `url("${sectionTitleIconLight}")` }}
+ />
+ <span
+ className="icon icon-small-spacer icon-dark-theme"
+ style={{ backgroundImage: `url("${sectionTitleIconDark}")` }}
+ />
+ <span className="section-title-text">
+ {props.content.section_title_text}
+ </span>
+ </ConditionalWrapper>
+ </h3>
+ </div>
+ );
+ }
+
+ return null;
+ }
+
+ render() {
+ const { props } = this;
+ const sectionHeader = this.renderSectionHeader();
+ let className = "SimpleSnippet";
+
+ if (props.className) {
+ className += ` ${props.className}`;
+ }
+ if (props.content.tall) {
+ className += " tall";
+ }
+ if (sectionHeader) {
+ className += " has-section-header";
+ }
+
+ return (
+ <div className="snippet-hover-wrapper">
+ <SnippetBase
+ {...props}
+ className={className}
+ textStyle={this.props.textStyle}
+ >
+ {sectionHeader}
+ <ConditionalWrapper
+ condition={sectionHeader}
+ wrap={this.wrapSnippetContent}
+ >
+ <img
+ src={safeURI(props.content.icon) || DEFAULT_ICON_PATH}
+ className="icon icon-light-theme"
+ alt={props.content.icon_alt_text || ICON_ALT_TEXT}
+ />
+ <img
+ src={
+ safeURI(props.content.icon_dark_theme || props.content.icon) ||
+ DEFAULT_ICON_PATH
+ }
+ className="icon icon-dark-theme"
+ alt={props.content.icon_alt_text || ICON_ALT_TEXT}
+ />
+ <div>
+ {this.renderTitle()} <p className="body">{this.renderText()}</p>
+ {this.props.extraContent}
+ </div>
+ {<div>{this.renderButton()}</div>}
+ </ConditionalWrapper>
+ </SnippetBase>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
new file mode 100644
index 0000000000..1229700d67
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
@@ -0,0 +1,155 @@
+{
+ "title": "SimpleSnippet",
+ "description": "A simple template with an icon, text, and optional button.",
+ "version": "1.1.2",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Snippet title displayed before snippet text"}
+ ]
+ },
+ "text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon, dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing icon for screen readers",
+ "default": ""
+ },
+ "title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "title_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing title icon for screen readers",
+ "default": ""
+ },
+ "button_action": {
+ "type": "string",
+ "description": "The type of action the button should trigger."
+ },
+ "button_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, button_label links to this"}
+ ]
+ },
+ "button_action_args": {
+ "description": "Additional parameters for button action, example which specific menu the button should open"
+ },
+ "button_entrypoint_value": {
+ "description": "String used for telemetry attribution of clicks",
+ "type": "string"
+ },
+ "button_entrypoint_name": {
+ "description": "String used for telemetry attribution of clicks",
+ "type": "string"
+ },
+ "button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ]
+ },
+ "button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "block_button_text": {
+ "type": "string",
+ "description": "Tooltip text used for dismiss button.",
+ "default": "Remove this"
+ },
+ "tall": {
+ "type": "boolean",
+ "description": "To be used by fundraising only, increases height to roughly 120px. Defaults to false."
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked"
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ },
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for link action, example which specific menu the button should open"
+ }
+ }
+ },
+ "section_title_icon": {
+ "type": "string",
+ "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+ },
+ "section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+ },
+ "section_title_text": {
+ "type": "string",
+ "description": "Section title text. section_title_icon must also be specified to display."
+ },
+ "section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, section_title_text links to this"}
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["text"],
+ "dependencies": {
+ "button_action": ["button_label"],
+ "button_url": ["button_label"],
+ "button_color": ["button_label"],
+ "button_background_color": ["button_label"],
+ "section_title_url": ["section_title_text"],
+ "button_entrypoint_name": ["button_entrypoint_value"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss
new file mode 100644
index 0000000000..b16f78dc93
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss
@@ -0,0 +1,135 @@
+$section-header-height: 30px;
+$icon-width: 54px; // width of primary icon + margin
+
+.SimpleSnippet {
+ &.tall {
+ padding: 27px 0;
+ }
+
+ p em {
+ color: $grey-90;
+ font-style: normal;
+ background: $yellow-50;
+ }
+
+ &.bold,
+ &.takeover {
+ .donation-form-url,
+ .donation-amount {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+ }
+
+ &.bold {
+ height: 176px;
+
+ .body {
+ font-size: 14px;
+ line-height: 20px;
+ margin-bottom: 20px;
+ }
+
+ .icon {
+ width: 71px;
+ height: 71px;
+ }
+ }
+
+ &.takeover {
+ height: 344px;
+
+ .body {
+ font-size: 16px;
+ line-height: 24px;
+ margin-bottom: 35px;
+ }
+
+ .icon {
+ width: 79px;
+ height: 79px;
+ }
+ }
+
+ .title {
+ font-size: inherit;
+ margin: 0;
+ }
+
+ .title-inline {
+ display: inline;
+ }
+
+ .titleIcon {
+ background-repeat: no-repeat;
+ background-size: 14px;
+ background-position: center;
+ height: 16px;
+ width: 16px;
+ margin-top: 2px;
+ margin-inline-end: 2px;
+ display: inline-block;
+ vertical-align: top;
+ }
+
+ .body {
+ display: inline;
+ margin: 0;
+ }
+
+ &.tall .icon {
+ margin-inline-end: 20px;
+ }
+
+ &.takeover,
+ &.bold {
+ .icon {
+ margin-inline-end: 20px;
+ }
+ }
+
+ .icon {
+ align-self: flex-start;
+ }
+
+ &.has-section-header .innerWrapper {
+ // account for section header being 100% width
+ flex-wrap: wrap;
+ padding-top: 7px;
+ }
+
+ // wrapper div added if section-header is displayed that allows icon/text/button
+ // to squish instead of wrapping. this is effectively replicating layout behavior
+ // when section-header is *not* present.
+ .innerContentWrapper {
+ align-items: center;
+ display: flex;
+ }
+
+ .section-header {
+ flex: 0 0 100%;
+ margin-bottom: 10px;
+ }
+
+ .section-title {
+ // color should match that of 'Recommended by Pocket' and 'Highlights' in newtab page
+ color: var(--newtab-section-header-text-color);
+ display: inline-block;
+ font-size: 13px;
+ font-weight: bold;
+ margin: 0;
+
+ a {
+ color: var(--newtab-section-header-text-color);
+ font-weight: inherit;
+ text-decoration: none;
+ }
+
+ .icon {
+ height: 16px;
+ margin-inline-end: 6px;
+ margin-top: -2px;
+ width: 16px;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json
new file mode 100644
index 0000000000..f3dcde11af
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json
@@ -0,0 +1,163 @@
+{
+ "title": "SubmitFormSnippet",
+ "description": "A template with two states: a SimpleSnippet and another that contains a form",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Two to five character string for the locale code"
+ },
+ "country": {
+ "type": "string",
+ "description": "Two character string for the country code (used for SMS)"
+ },
+ "section_title_icon": {
+ "type": "string",
+ "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+ },
+ "section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+ },
+ "section_title_text": {
+ "type": "string",
+ "description": "Section title text. section_title_icon must also be specified to display."
+ },
+ "section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, section_title_text links to this"}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "form_action": {
+ "type": "string",
+ "description": "Endpoint to submit form data."
+ },
+ "success_title": {
+ "type": "string",
+ "description": "(send to device) Title shown before text on successful registration."
+ },
+ "success_text": {
+ "type": "string",
+ "description": "Message shown on successful registration."
+ },
+ "error_text": {
+ "type": "string",
+ "description": "Message shown if registration failed."
+ },
+ "scene2_email_placeholder_text": {
+ "type": "string",
+ "description": "Value to show while input is empty."
+ },
+ "scene2_input_placeholder": {
+ "type": "string",
+ "description": "(send to device) Value to show while input is empty."
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button"
+ },
+ "scene2_privacy_html": {
+ "type": "string",
+ "description": "Information about how the form data is used."
+ },
+ "scene2_disclaimer_html": {
+ "type": "string",
+ "description": "(send to device) Html for disclaimer and link underneath input box."
+ },
+ "scene2_icon": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_dark_theme": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing scene2 icon for screen readers",
+ "default": ""
+ },
+ "scene2_newsletter": {
+ "type": "string",
+ "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'."
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked"
+ },
+ "include_sms": {
+ "type": "boolean",
+ "description": "(send to device) Allow users to send an SMS message with the form?"
+ },
+ "message_id_sms": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the SMS message to be sent."
+ },
+ "message_id_email": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/."
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene2_text"],
+ "dependencies": {
+ "section_title_icon": ["section_title_text"],
+ "section_title_icon_dark_theme": ["section_title_text"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
new file mode 100644
index 0000000000..d1f267f2fa
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
@@ -0,0 +1,409 @@
+/* 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 { Button } from "../../components/Button/Button";
+import React from "react";
+import { RichText } from "../../components/RichText/RichText";
+import { safeURI } from "../../template-utils";
+import { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet";
+import { SnippetBase } from "../../components/SnippetBase/SnippetBase";
+import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper";
+
+// Alt text placeholder in case the prop from the server isn't available
+const ICON_ALT_TEXT = "";
+
+export class SubmitFormSnippet extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.expandSnippet = this.expandSnippet.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleSubmitAttempt = this.handleSubmitAttempt.bind(this);
+ this.onInputChange = this.onInputChange.bind(this);
+ this.state = {
+ expanded: false,
+ submitAttempted: false,
+ signupSubmitted: false,
+ signupSuccess: false,
+ disableForm: false,
+ };
+ }
+
+ handleSubmitAttempt() {
+ if (!this.state.submitAttempted) {
+ this.setState({ submitAttempted: true });
+ }
+ }
+
+ async handleSubmit(event) {
+ let json;
+
+ if (this.state.disableForm) {
+ return;
+ }
+
+ event.preventDefault();
+ this.setState({ disableForm: true });
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ event_context: "conversion-subscribe-activation",
+ id: "NEWTAB_FOOTER_BAR_CONTENT",
+ });
+
+ if (this.props.form_method.toUpperCase() === "GET") {
+ this.props.onBlock({ preventDismiss: true });
+ this.refs.form.submit();
+ return;
+ }
+
+ const { url, formData } = this.props.processFormData
+ ? this.props.processFormData(this.refs.mainInput, this.props)
+ : { url: this.refs.form.action, formData: new FormData(this.refs.form) };
+
+ try {
+ const fetchRequest = new Request(url, {
+ body: formData,
+ method: "POST",
+ credentials: "omit",
+ });
+ const response = await fetch(fetchRequest); // eslint-disable-line fetch-options/no-fetch-credentials
+ json = await response.json();
+ } catch (err) {
+ console.log(err); // eslint-disable-line no-console
+ }
+
+ if (json && json.status === "ok") {
+ this.setState({ signupSuccess: true, signupSubmitted: true });
+ if (!this.props.content.do_not_autoblock) {
+ this.props.onBlock({ preventDismiss: true });
+ }
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ event_context: "subscribe-success",
+ id: "NEWTAB_FOOTER_BAR_CONTENT",
+ });
+ } else {
+ // eslint-disable-next-line no-console
+ console.error(
+ "There was a problem submitting the form",
+ json || "[No JSON response]"
+ );
+ this.setState({ signupSuccess: false, signupSubmitted: true });
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ event_context: "subscribe-error",
+ id: "NEWTAB_FOOTER_BAR_CONTENT",
+ });
+ }
+
+ this.setState({ disableForm: false });
+ }
+
+ expandSnippet() {
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ event_context: "scene1-button-learn-more",
+ id: this.props.UISurface,
+ });
+
+ this.setState({
+ expanded: true,
+ signupSuccess: false,
+ signupSubmitted: false,
+ });
+ }
+
+ renderHiddenFormInputs() {
+ const { hidden_inputs } = this.props.content;
+
+ if (!hidden_inputs) {
+ return null;
+ }
+
+ return Object.keys(hidden_inputs).map((key, idx) => (
+ <input key={idx} type="hidden" name={key} value={hidden_inputs[key]} />
+ ));
+ }
+
+ renderDisclaimer() {
+ const { content } = this.props;
+ if (!content.scene2_disclaimer_html) {
+ return null;
+ }
+ return (
+ <p className="disclaimerText">
+ <RichText
+ text={content.scene2_disclaimer_html}
+ localization_id="disclaimer_html"
+ links={content.links}
+ doNotAutoBlock={true}
+ openNewWindow={true}
+ sendClick={this.props.sendClick}
+ />
+ </p>
+ );
+ }
+
+ renderFormPrivacyNotice() {
+ const { content } = this.props;
+ if (!content.scene2_privacy_html) {
+ return null;
+ }
+ return (
+ <p className="privacyNotice">
+ <input
+ type="checkbox"
+ id="id_privacy"
+ name="privacy"
+ required="required"
+ />
+ <label htmlFor="id_privacy">
+ <RichText
+ text={content.scene2_privacy_html}
+ localization_id="privacy_html"
+ links={content.links}
+ doNotAutoBlock={true}
+ openNewWindow={true}
+ sendClick={this.props.sendClick}
+ />
+ </label>
+ </p>
+ );
+ }
+
+ renderSignupSubmitted() {
+ const { content } = this.props;
+ const isSuccess = this.state.signupSuccess;
+ const successTitle = isSuccess && content.success_title;
+ const bodyText = isSuccess
+ ? { success_text: content.success_text }
+ : { error_text: content.error_text };
+ const retryButtonText = content.retry_button_label;
+ return (
+ <SnippetBase {...this.props}>
+ <div className="submissionStatus">
+ {successTitle ? (
+ <h2 className="submitStatusTitle">{successTitle}</h2>
+ ) : null}
+ <p>
+ <RichText
+ {...bodyText}
+ localization_id={isSuccess ? "success_text" : "error_text"}
+ />
+ {isSuccess ? null : (
+ <Button onClick={this.expandSnippet}>{retryButtonText}</Button>
+ )}
+ </p>
+ </div>
+ </SnippetBase>
+ );
+ }
+
+ onInputChange(event) {
+ if (!this.props.validateInput) {
+ return;
+ }
+ const hasError = this.props.validateInput(
+ event.target.value,
+ this.props.content
+ );
+ event.target.setCustomValidity(hasError);
+ }
+
+ wrapSectionHeader(url) {
+ return function(children) {
+ return <a href={url}>{children}</a>;
+ };
+ }
+
+ renderInput() {
+ const placholder =
+ this.props.content.scene2_email_placeholder_text ||
+ this.props.content.scene2_input_placeholder;
+ return (
+ <input
+ ref="mainInput"
+ type={this.props.inputType || "email"}
+ className={`mainInput${this.state.submitAttempted ? "" : " clean"}`}
+ name="email"
+ required={true}
+ placeholder={placholder}
+ onChange={this.props.validateInput ? this.onInputChange : null}
+ />
+ );
+ }
+
+ renderForm() {
+ return (
+ <form
+ action={this.props.form_action}
+ method={this.props.form_method}
+ onSubmit={this.handleSubmit}
+ ref="form"
+ >
+ {this.renderHiddenFormInputs()}
+ <div>
+ {this.renderInput()}
+ <button
+ type="submit"
+ className="ASRouterButton primary"
+ onClick={this.handleSubmitAttempt}
+ ref="formSubmitBtn"
+ >
+ {this.props.content.scene2_button_label}
+ </button>
+ </div>
+ {this.renderFormPrivacyNotice() || this.renderDisclaimer()}
+ </form>
+ );
+ }
+
+ renderScene2Icon() {
+ const { content } = this.props;
+ if (!content.scene2_icon) {
+ return null;
+ }
+
+ return (
+ <div className="scene2Icon">
+ <img
+ src={safeURI(content.scene2_icon)}
+ className="icon-light-theme"
+ alt={content.scene2_icon_alt_text || ICON_ALT_TEXT}
+ />
+ <img
+ src={safeURI(content.scene2_icon_dark_theme || content.scene2_icon)}
+ className="icon-dark-theme"
+ alt={content.scene2_icon_alt_text || ICON_ALT_TEXT}
+ />
+ </div>
+ );
+ }
+
+ renderSignupView() {
+ const { content } = this.props;
+ const containerClass = `SubmitFormSnippet ${this.props.className}`;
+ return (
+ <SnippetBase
+ {...this.props}
+ className={containerClass}
+ footerDismiss={true}
+ >
+ {this.renderScene2Icon()}
+ <div className="message">
+ <p>
+ {content.scene2_title && (
+ <h3 className="scene2Title">{content.scene2_title}</h3>
+ )}{" "}
+ {content.scene2_text && (
+ <RichText
+ scene2_text={content.scene2_text}
+ localization_id="scene2_text"
+ />
+ )}
+ </p>
+ </div>
+ {this.renderForm()}
+ </SnippetBase>
+ );
+ }
+
+ renderSectionHeader() {
+ const { props } = this;
+
+ // an icon and text must be specified to render the section header
+ if (props.content.section_title_icon && props.content.section_title_text) {
+ const sectionTitleIconLight = safeURI(props.content.section_title_icon);
+ const sectionTitleIconDark = safeURI(
+ props.content.section_title_icon_dark_theme ||
+ props.content.section_title_icon
+ );
+ const sectionTitleURL = props.content.section_title_url;
+
+ return (
+ <div className="section-header">
+ <h3 className="section-title">
+ <ConditionalWrapper
+ wrap={this.wrapSectionHeader(sectionTitleURL)}
+ condition={sectionTitleURL}
+ >
+ <span
+ className="icon icon-small-spacer icon-light-theme"
+ style={{ backgroundImage: `url("${sectionTitleIconLight}")` }}
+ />
+ <span
+ className="icon icon-small-spacer icon-dark-theme"
+ style={{ backgroundImage: `url("${sectionTitleIconDark}")` }}
+ />
+ <span className="section-title-text">
+ {props.content.section_title_text}
+ </span>
+ </ConditionalWrapper>
+ </h3>
+ </div>
+ );
+ }
+
+ return null;
+ }
+
+ renderSignupViewAlt() {
+ const { content } = this.props;
+ const containerClass = `SubmitFormSnippet ${this.props.className} scene2Alt`;
+ return (
+ <SnippetBase
+ {...this.props}
+ className={containerClass}
+ // Don't show bottom dismiss button
+ footerDismiss={false}
+ >
+ {this.renderSectionHeader()}
+ {this.renderScene2Icon()}
+ <div className="message">
+ <p>
+ {content.scene2_text && (
+ <RichText
+ scene2_text={content.scene2_text}
+ localization_id="scene2_text"
+ />
+ )}
+ </p>
+ {this.renderForm()}
+ </div>
+ </SnippetBase>
+ );
+ }
+
+ getFirstSceneContent() {
+ return Object.keys(this.props.content)
+ .filter(key => key.includes("scene1"))
+ .reduce((acc, key) => {
+ acc[key.substr(7)] = this.props.content[key];
+ return acc;
+ }, {});
+ }
+
+ render() {
+ const content = { ...this.props.content, ...this.getFirstSceneContent() };
+
+ if (this.state.signupSubmitted) {
+ return this.renderSignupSubmitted();
+ }
+ // Render only scene 2 (signup view). Must check before `renderSignupView`
+ // to catch the Failure/Try again scenario where we want to return and render
+ // the scene again.
+ if (this.props.expandedAlt) {
+ return this.renderSignupViewAlt();
+ }
+ if (this.state.expanded) {
+ return this.renderSignupView();
+ }
+ return (
+ <SimpleSnippet
+ {...this.props}
+ content={content}
+ onButtonClick={this.expandSnippet}
+ />
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json
new file mode 100644
index 0000000000..0fc3128d1c
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json
@@ -0,0 +1,225 @@
+{
+ "title": "SubmitFormSnippet",
+ "description": "A template with two states: a SimpleSnippet and another that contains a form",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Two to five character string for the locale code"
+ },
+ "country": {
+ "type": "string",
+ "description": "Two character string for the country code (used for SMS)"
+ },
+ "scene1_title": {
+ "allof": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "snippet title displayed before snippet text"}
+ ]
+ },
+ "scene1_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_section_title_icon": {
+ "type": "string",
+ "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_text": {
+ "type": "string",
+ "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display."
+ },
+ "scene1_section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, scene1_section_title_text links to this"}
+ ]
+ },
+ "scene2_title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Title displayed before text in scene 2. Should be plain text."}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing scene1 icon for screen readers",
+ "default": ""
+ },
+ "scene1_title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing scene1 title icon for screen readers",
+ "default": ""
+ },
+ "form_action": {
+ "type": "string",
+ "description": "Endpoint to submit form data."
+ },
+ "success_title": {
+ "type": "string",
+ "description": "(send to device) Title shown before text on successful registration."
+ },
+ "success_text": {
+ "type": "string",
+ "description": "Message shown on successful registration."
+ },
+ "error_text": {
+ "type": "string",
+ "description": "Message shown if registration failed."
+ },
+ "scene2_email_placeholder_text": {
+ "type": "string",
+ "description": "Value to show while input is empty."
+ },
+ "scene2_input_placeholder": {
+ "type": "string",
+ "description": "(send to device) Value to show while input is empty."
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button"
+ },
+ "scene2_privacy_html": {
+ "type": "string",
+ "description": "Information about how the form data is used."
+ },
+ "scene2_disclaimer_html": {
+ "type": "string",
+ "description": "(send to device) Html for disclaimer and link underneath input box."
+ },
+ "scene2_dismiss_button_text": {
+ "type": "string",
+ "description": "Label for the dismiss button when the sign-up form is expanded."
+ },
+ "scene2_icon": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_dark_theme": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing scene2 icon for screen readers",
+ "default": ""
+ },
+ "scene2_newsletter": {
+ "type": "string",
+ "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'."
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property."
+ },
+ "scene1_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ]
+ },
+ "scene1_button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "scene1_button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked"
+ },
+ "include_sms": {
+ "type": "boolean",
+ "description": "(send to device) Allow users to send an SMS message with the form?"
+ },
+ "message_id_sms": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the SMS message to be sent."
+ },
+ "message_id_email": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/."
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene1_text", "scene2_text", "scene1_button_label"],
+ "dependencies": {
+ "scene1_button_color": ["scene1_button_label"],
+ "scene1_button_background_color": ["scene1_button_label"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss
new file mode 100644
index 0000000000..286366c12b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss
@@ -0,0 +1,176 @@
+.SubmitFormSnippet {
+ flex-direction: column;
+ flex: 1 1 100%;
+ width: 100%;
+
+ .disclaimerText {
+ margin: 5px 0 0;
+ font-size: 12px;
+ color: var(--newtab-text-secondary-color);
+ }
+
+ p {
+ margin: 0;
+ }
+
+ &.send_to_device_snippet {
+ text-align: center;
+
+ .message {
+ font-size: 16px;
+ margin-bottom: 20px;
+ }
+
+ .scene2Title {
+ font-size: 24px;
+ display: block;
+ }
+ }
+
+ .ASRouterButton {
+ &.primary {
+ flex: 1 1 0;
+ }
+ }
+
+ .scene2Icon {
+ width: 100%;
+ margin-bottom: 20px;
+
+ img {
+ width: 98px;
+ display: inline-block;
+ }
+ }
+
+ .scene2Title {
+ font-size: inherit;
+ margin: 0 0 10px;
+ font-weight: bold;
+ display: inline;
+ }
+
+ form {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .message {
+ font-size: 14px;
+ align-self: stretch;
+ flex: 0 0 100%;
+ margin-bottom: 10px;
+ }
+
+ .privacyNotice {
+ font-size: 12px;
+ color: var(--newtab-text-secondary-color);
+ margin-top: 10px;
+ display: flex;
+ flex: 0 0 100%;
+ }
+
+ .innerWrapper {
+ // https://github.com/mozmeao/snippets/blob/2054899350590adcb3c0b0a341c782b0e2f81d0b/activity-stream/newsletter-subscribe.html#L46
+ max-width: 736px;
+ flex-wrap: wrap;
+ justify-items: center;
+ padding-top: 40px;
+ padding-bottom: 40px;
+ }
+
+ .footer {
+ width: 100%;
+ margin: 0 auto;
+ text-align: right;
+ background-color: var(--newtab-background-color);
+ padding: 10px 0;
+
+ .footer-content {
+ margin: 0 auto;
+ max-width: 768px;
+ width: 100%;
+ text-align: right;
+
+ [dir='rtl'] & {
+ text-align: left;
+ }
+ }
+ }
+
+ input {
+ &.mainInput {
+ border-radius: 2px;
+ background-color: var(--newtab-textbox-background-color);
+ border: $input-border;
+ padding: 0 8px;
+ height: 100%;
+ font-size: 14px;
+ width: 50%;
+
+ &.clean {
+ &:invalid,
+ &:required {
+ box-shadow: none;
+ }
+ }
+
+ &:focus {
+ border: $input-border-active;
+ box-shadow: var(--newtab-textbox-focus-boxshadow);
+ }
+ }
+ }
+
+ &.scene2Alt {
+ text-align: start;
+
+ .scene2Icon {
+ flex: 1;
+ margin-bottom: 0;
+ }
+
+ .message {
+ flex: 5;
+ margin-bottom: 0;
+
+ p {
+ margin-bottom: 10px;
+ }
+ }
+
+ .section-header {
+ width: 100%;
+
+ .icon {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
+ .section-title {
+ font-size: 13px;
+ }
+
+ .section-title a {
+ color: var(--newtab-section-header-text-color);
+ font-weight: inherit;
+ text-decoration: none;
+ }
+
+ .innerWrapper {
+ padding: 0 0 16px;
+ }
+ }
+}
+
+.submissionStatus {
+ text-align: center;
+ font-size: 14px;
+ padding: 20px 0;
+
+ .submitStatusTitle {
+ font-size: 20px;
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx
new file mode 100644
index 0000000000..57f8afa6f5
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx
@@ -0,0 +1,24 @@
+/* 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 { EOYSnippet } from "./EOYSnippet/EOYSnippet";
+import { FXASignupSnippet } from "./FXASignupSnippet/FXASignupSnippet";
+import { NewsletterSnippet } from "./NewsletterSnippet/NewsletterSnippet";
+import {
+ SendToDeviceSnippet,
+ SendToDeviceScene2Snippet,
+} from "./SendToDeviceSnippet/SendToDeviceSnippet";
+import { SimpleBelowSearchSnippet } from "./SimpleBelowSearchSnippet/SimpleBelowSearchSnippet";
+import { SimpleSnippet } from "./SimpleSnippet/SimpleSnippet";
+
+// Key names matching schema name of templates
+export const SnippetsTemplates = {
+ simple_snippet: SimpleSnippet,
+ newsletter_snippet: NewsletterSnippet,
+ fxa_signup_snippet: FXASignupSnippet,
+ send_to_device_snippet: SendToDeviceSnippet,
+ send_to_device_scene2_snippet: SendToDeviceScene2Snippet,
+ eoy_snippet: EOYSnippet,
+ simple_below_search_snippet: SimpleBelowSearchSnippet,
+};
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..622e5a2b7b
--- /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-link-primary-color);
+
+ &: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..e0d8e45cc0
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx
@@ -0,0 +1,1936 @@
+/* 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.jsm";
+import { ASRouterUtils } from "../../asrouter/asrouter-utils";
+import { connect } from "react-redux";
+import React from "react";
+import { SimpleHashRouter } from "./SimpleHashRouter";
+
+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.togglePersonalizationVersion = this.togglePersonalizationVersion.bind(
+ this
+ );
+ }
+
+ togglePersonalizationVersion() {
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.DISCOVERY_STREAM_PERSONALIZATION_VERSION_TOGGLE,
+ })
+ );
+ }
+
+ render() {
+ const {
+ lastUpdated,
+ version,
+ initialized,
+ } = this.props.state.Personalization;
+ return (
+ <React.Fragment>
+ <button className="button" onClick={this.togglePersonalizationVersion}>
+ {version === 1
+ ? "Enable V2 Personalization"
+ : "Enable V1 Personalization"}
+ </button>
+ <table>
+ <tbody>
+ <Row>
+ <td className="min">Personalization version</td>
+ <td>{version}</td>
+ </Row>
+ <Row>
+ <td className="min">Personalization Last Updated</td>
+ <td>{relativeTime(lastUpdated) || "(no data)"}</td>
+ </Row>
+ {version === 2 ? (
+ <Row>
+ <td className="min">Personalization V2 Initialized</td>
+ <td>{initialized ? "true" : "false"}</td>
+ </Row>
+ ) : null}
+ </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 personalized collapsible".split(
+ " "
+ );
+ const { config, lastUpdated, layout } = this.props.state.DiscoveryStream;
+ 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
+ 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.handleUpdateWNMessages = this.handleUpdateWNMessages.bind(this);
+ this.handleForceWNP = this.handleForceWNP.bind(this);
+ this.handleCloseWNP = this.handleCloseWNP.bind(this);
+ this.resetPanel = this.resetPanel.bind(this);
+ this.restoreWNMessageState = this.restoreWNMessageState.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",
+ WNMessages: [],
+ collapsedMessages: [],
+ modifiedMessages: [],
+ evaluationStatus: {},
+ stringTargetingParameters: null,
+ newStringTargetingParameters: null,
+ copiedToClipboard: false,
+ attributionParameters: {
+ source: "addons.mozilla.org",
+ medium: "referral",
+ campaign: "non-fx-button",
+ content: "iridium@particlecore.github.io",
+ 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),
+ ],
+ }));
+ }
+
+ resetAllJSON() {
+ let messageCheckboxes = document.querySelectorAll('input[type="checkbox"]');
+
+ for (const checkbox of messageCheckboxes) {
+ let trimmedId = checkbox.id.replace(" checkbox", "");
+
+ let message = this.state.messages.filter(msg => msg.id === trimmedId);
+ let msgId = message[0].id;
+
+ document.getElementById(`${msgId}-textarea`).value = JSON.stringify(
+ message[0],
+ null,
+ 2
+ );
+ }
+ this.setState({
+ WNMessages: [],
+ });
+ }
+
+ resetPanel() {
+ this.resetAllJSON();
+ this.handleCloseWNP();
+ }
+
+ handleOverride(id) {
+ return () =>
+ ASRouterUtils.overrideMessage(id).then(state => {
+ this.setStateFromParent(state);
+ this.props.notifyContent({
+ message: state.message,
+ });
+ });
+ }
+
+ async handleUpdateWNMessages() {
+ await this.restoreWNMessageState();
+ let messages = this.state.WNMessages;
+
+ for (const msg of messages) {
+ ASRouterUtils.modifyMessageJson(JSON.parse(msg));
+ }
+ }
+
+ handleForceWNP() {
+ ASRouterUtils.sendMessage({ type: "FORCE_WHATSNEW_PANEL" });
+ }
+
+ handleCloseWNP() {
+ ASRouterUtils.sendMessage({ type: "CLOSE_WHATSNEW_PANEL" });
+ }
+
+ 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.log(`Error parsing value of parameter ${name}`); // eslint-disable-line no-console
+ 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);
+
+ 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>
+ )}
+ <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>
+ );
+ }
+
+ restoreWNMessageState() {
+ // check the page for checked boxes, and reset the state of WNMessages based on that.
+ let tempState = [];
+ let messageCheckboxes = document.querySelectorAll('input[type="checkbox"]');
+ // put the JSON of all the checked checkboxes in the array
+ for (const checkbox of messageCheckboxes) {
+ let trimmedId = checkbox.id.replace(" checkbox", "");
+ let msg = document.getElementById(`${trimmedId}-textarea`).value;
+
+ if (checkbox.checked) {
+ tempState.push(msg);
+ }
+ }
+
+ this.setState({
+ WNMessages: tempState,
+ });
+ }
+
+ 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,
+ });
+ });
+ }
+
+ renderWNMessageItem(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="checkbox"
+ id={`${msg.id} checkbox`}
+ name={`${msg.id} checkbox`}
+ />
+ </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
+ );
+
+ 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.
+ </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>
+ );
+ }
+
+ renderWNMessages() {
+ if (!this.state.messages) {
+ return null;
+ }
+ const messagesToShow = this.state.messages.filter(
+ message => message.provider === "whats-new-panel" && message.content.body
+ );
+ return (
+ <table>
+ <tbody>
+ {messagesToShow.map(msg => this.renderWNMessageItem(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>
+ {/* eslint-disable-next-line prettier/prettier */}
+ Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+ <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.bucket})`;
+ } 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 'Iridium for Youtube'. 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 the addon GUID, 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="iridium@particlecore.github.io"
+ 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>;
+ }
+
+ renderWNPTests() {
+ if (!this.state.messages) {
+ return null;
+ }
+ let messagesToShow = this.state.messages.filter(
+ message => message.provider === "whats-new-panel"
+ );
+
+ return (
+ <div>
+ <p className="helpLink">
+ <span className="icon icon-small-spacer icon-info" />{" "}
+ <span>
+ To correctly render selected messages, click 'Open What's New
+ Panel', select the messages you want to see, and click 'Render
+ Selected Messages'.
+ <br />
+ <br />
+ To modify a message, select it, modify the JSON and click 'Render
+ Selected Messages' again to see your changes.
+ <br />
+ Click 'Reset Panel' to close the panel and reset all JSON to its
+ original state.
+ </span>
+ </p>
+ <div>
+ <button
+ className="ASRouterButton primary button"
+ onClick={this.handleForceWNP}
+ >
+ Open What's New Panel
+ </button>
+ <button
+ className="ASRouterButton secondary button"
+ onClick={this.handleUpdateWNMessages}
+ >
+ Render Selected Messages
+ </button>
+ <button
+ className="ASRouterButton secondary button"
+ onClick={this.resetPanel}
+ >
+ Reset Panel
+ </button>
+ <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.renderWNMessages()}
+ </div>
+ </div>
+ );
+ }
+
+ getSection() {
+ const [section] = this.props.location.routes;
+ switch (section) {
+ case "wnpanel":
+ return (
+ <React.Fragment>
+ <h2>What's New Panel</h2>
+ {this.renderWNPTests()}
+ </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-wnpanel">What's New Panel</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://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/docs/debugging-docs.md"
+ >
+ 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..0e12ec97e7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss
@@ -0,0 +1,273 @@
+.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://browser/skin/developer.svg');
+ padding: 15px;
+ }
+
+ &:dir(rtl) {
+ transform: scaleX(-1);
+ }
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ }
+
+ &.expanded {
+ background: $black-20;
+ }
+}
+
+.asrouter-admin {
+ position: fixed;
+ top: 0;
+ inset-inline-start: 0;
+ width: 100%;
+ background: var(--newtab-background-color);
+ height: 100%;
+ overflow-y: scroll;
+ $border-color: var(--newtab-border-secondary-color);
+ $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono',
+ 'Source Code Pro', monospace;
+ $sidebar-width: 240px;
+ 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-textbox-background-color);
+ }
+ }
+ }
+
+ 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: $grey-20-60;
+ 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-textbox-background-color);
+ }
+
+ td {
+ padding: 4px;
+
+ &[rowspan] {
+ border: 1px solid var(--newtab-textbox-background-color);
+ }
+ }
+ }
+ }
+
+ .sourceLabel {
+ background: var(--newtab-textbox-background-color);
+ padding: 2px 5px;
+ border-radius: 3px;
+
+ &.isDisabled {
+ background: $email-input-invalid;
+ color: $red-60;
+ }
+ }
+
+ .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-textbox-background-color);
+ margin: 0;
+ padding: 8px;
+ font-size: 12px;
+ max-width: 750px;
+ overflow: auto;
+ font-family: $monospace;
+ }
+
+ .errorState {
+ border: 1px solid $red-60;
+ }
+
+ .helpLink {
+ padding: 10px;
+ display: flex;
+ background: $black-10;
+ border-radius: 3px;
+
+ a {
+ text-decoration: underline;
+ }
+ }
+
+ .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/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..9bb1193159
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Base/Base.jsx
@@ -0,0 +1,294 @@
+/* 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.jsm";
+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";
+import { CSSTransition } from "react-transition-group";
+
+export const PrefsButton = ({ onClick, icon }) => (
+ <div className="prefs-button">
+ <button
+ className={`icon ${icon || "icon-settings"}`}
+ onClick={onClick}
+ data-l10n-id="newtab-settings-button"
+ />
+ </div>
+);
+
+export const PersonalizeButton = ({ onClick }) => (
+ <button
+ className="personalize-button"
+ onClick={onClick}
+ data-l10n-id="newtab-personalize-button-label"
+ />
+);
+
+// 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, customizeMenuVisible: 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.setState({ customizeMenuVisible: true });
+ this.props.dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" }));
+ }
+
+ closeCustomizationMenu() {
+ if (this.state.customizeMenuVisible) {
+ this.setState({ customizeMenuVisible: false });
+ 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 } = App;
+ const prefs = props.Prefs.values;
+
+ // Values from experiment data
+ const { prefsButtonIcon } = prefs.featureConfig || {};
+
+ 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 customizationMenuEnabled = prefs["customizationMenu.enabled"];
+ const newNewtabExperienceEnabled = prefs["newNewtabExperience.enabled"];
+ const canShowCustomizationMenu =
+ customizationMenuEnabled || newNewtabExperienceEnabled;
+ const showCustomizationMenu =
+ canShowCustomizationMenu && this.state.customizeMenuVisible;
+ const enabledSections = {
+ topSitesEnabled: prefs["feeds.topsites"],
+ pocketEnabled: prefs["feeds.section.topstories"],
+ snippetsEnabled: prefs["feeds.snippets"],
+ highlightsEnabled: prefs["feeds.section.highlights"],
+ showSponsoredTopSitesEnabled: prefs.showSponsoredTopSites,
+ showSponsoredPocketEnabled: prefs.showSponsored,
+ 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",
+ newNewtabExperienceEnabled && "newtab-experience",
+ ]
+ .filter(v => v)
+ .join(" ");
+
+ return (
+ <div>
+ {canShowCustomizationMenu ? (
+ <span>
+ <PersonalizeButton onClick={this.openCustomizationMenu} />
+ <CSSTransition
+ timeout={0}
+ classNames="customize-animate"
+ in={showCustomizationMenu}
+ appear={true}
+ >
+ <CustomizeMenu
+ onClose={this.closeCustomizationMenu}
+ openPreferences={this.openPreferences}
+ setPref={this.setPref}
+ enabledSections={enabledSections}
+ pocketRegion={pocketRegion}
+ mayHaveSponsoredTopSites={mayHaveSponsoredTopSites}
+ />
+ </CSSTransition>
+ </span>
+ ) : (
+ <PrefsButton onClick={this.openPreferences} icon={prefsButtonIcon} />
+ )}
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/}
+ <div className={outerClassName} onClick={this.closeCustomizationMenu}>
+ <main>
+ {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..a12e14cb95
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Base/_Base.scss
@@ -0,0 +1,179 @@
+.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-link-primary-color);
+ }
+}
+
+main {
+ margin: auto;
+ width: $wrapper-default-width;
+ // 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;
+
+ 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;
+ }
+
+}
+
+.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: 68px;
+
+ @media (min-width: $break-point-medium) {
+ width: 510px;
+ }
+
+ @media (min-width: $break-point-large) {
+ width: 746px;
+ }
+
+ @media (min-width: $break-point-widest) {
+ width: 986px;
+ }
+ }
+}
+
+.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);
+ }
+ }
+}
+
+@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 {
+ font-size: 12px;
+ font-weight: 600;
+ border: 0;
+ border-radius: 4px;
+ background-color: var(--newtab-background-button-color);
+ color: var(--newtab-background-button-text-color);
+ padding: 3px 10px;
+ min-height: 32px;
+ max-width: 117px;
+
+ &:hover {
+ background-color: var(--newtab-background-button-hover-color);
+ }
+
+ &:active {
+ background-color: var(--newtab-background-button-active-color);
+ }
+
+ &:focus-visible {
+ @include ds-focus-nte;
+ }
+}
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..3e2c5ace83
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Card/Card.jsx
@@ -0,0 +1,354 @@
+/* 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.jsm";
+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
+ await gImageLoading.get(imageUrl);
+
+ // 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..64dd5c0cf6
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Card/_Card.scss
@@ -0,0 +1,369 @@
+// Special styling for the New Tab Experience styles,
+// This is to be incorporated once the styles are made permanent
+.outer-wrapper.newtab-experience {
+ .card-outer {
+ border-radius: $border-radius-new;
+
+ &:is(:focus):not(.placeholder) {
+ @include ds-focus-nte;
+
+ transition: none;
+ }
+
+ &:hover {
+ box-shadow: none;
+ transition: none;
+ }
+
+ .card {
+ box-shadow: 0 3px 8px var(--newtab-card-first-shadow), 0 0 2px var(--newtab-card-second-shadow);
+ border-radius: $border-radius-new;
+ }
+
+ .card-preview-image-outer {
+ border-radius: $border-radius-new $border-radius-new 0 0;
+ }
+
+ // Temporary fix to have the context button focus blend in with other New Tab Experience context menu focus
+ .context-menu-button {
+ &:is(:active, :focus) {
+ outline: 0;
+ fill: var(--newtab-primary-action-background);
+ border: 1px solid var(--newtab-primary-action-background);
+ }
+ }
+
+ > a {
+ &:is(:focus) {
+ .card {
+ @include ds-focus-nte;
+ }
+ }
+ }
+ }
+}
+
+.outer-wrapper:not(.newtab-experience) {
+ .card-outer {
+ &:is(:hover, :focus, .active):not(.placeholder) {
+ @include fade-in-card;
+ }
+ }
+}
+
+.card-outer {
+ @include context-menu-button;
+ background: var(--newtab-card-background-color);
+ border-radius: $border-radius;
+ display: inline-block;
+ height: $card-height;
+ margin-inline-end: $base-gutter;
+ position: relative;
+ width: 100%;
+
+ &.placeholder {
+ background: transparent;
+
+ .card {
+ box-shadow: inset $inner-box-shadow;
+ }
+
+ .card-preview-image-outer,
+ .card-context {
+ display: none;
+ }
+ }
+
+ .card {
+ border-radius: $border-radius;
+ box-shadow: var(--newtab-card-shadow);
+ height: 100%;
+ }
+
+ > a {
+ color: inherit;
+ display: block;
+ height: 100%;
+ outline: none;
+ position: absolute;
+ width: 100%;
+
+ &:is(.active, :focus) {
+ .card {
+ @include fade-in-card;
+ }
+
+ .card-title {
+ color: var(--newtab-link-primary-color);
+ }
+ }
+ }
+
+ &:is(:hover, :focus, .active):not(.placeholder) {
+ @include context-menu-button-hover;
+ outline: none;
+
+ .card-title {
+ color: var(--newtab-link-primary-color);
+ }
+
+ .alternate ~ .card-host-name {
+ display: none;
+ }
+
+ .card-host-name.alternate {
+ display: block;
+ }
+ }
+
+ .card-preview-image-outer {
+ background-color: $grey-30;
+ border-radius: $border-radius $border-radius 0 0;
+ height: $card-preview-image-height;
+ overflow: hidden;
+ position: relative;
+
+ [lwt-newtab-brighttext] & {
+ background-color: $grey-60;
+ }
+
+ &::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; // sass-lint:disable-line no-disallowed-properties
+ 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-card-background-color);
+ border-radius: $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: ($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 - $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: ($container-size / 2) + 1 ($container-size / 2) + 1 0 0;
+ content: '';
+ position: absolute;
+ height: ($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..e52c2a8da9
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx
@@ -0,0 +1,342 @@
+/* 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.jsm";
+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";
+import { SectionMenu } from "content-src/components/SectionMenu/SectionMenu";
+import { SectionMenuOptions } from "content-src/lib/section-menu-options";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+export class _CollapsibleSection extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onBodyMount = this.onBodyMount.bind(this);
+ this.collapseOrExpandSection = this.collapseOrExpandSection.bind(this);
+ this.onHeaderClick = this.onHeaderClick.bind(this);
+ this.onKeyPress = this.onKeyPress.bind(this);
+ this.onTransitionEnd = this.onTransitionEnd.bind(this);
+ this.enableOrDisableAnimation = this.enableOrDisableAnimation.bind(this);
+ this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this);
+ this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this);
+ this.onMenuUpdate = this.onMenuUpdate.bind(this);
+ this.state = {
+ enableAnimation: true,
+ isAnimating: false,
+ menuButtonHover: false,
+ showContextMenu: false,
+ };
+ this.setContextMenuButtonRef = this.setContextMenuButtonRef.bind(this);
+ }
+
+ componentWillMount() {
+ this.props.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this.enableOrDisableAnimation
+ );
+ }
+
+ componentWillUpdate(nextProps) {
+ // Check if we're about to go from expanded to collapsed
+ if (!this.props.collapsed && nextProps.collapsed) {
+ // This next line forces a layout flush of the section body, which has a
+ // max-height style set, so that the upcoming collapse animation can
+ // animate from that height to the collapsed height. Without this, the
+ // update is coalesced and there's no animation from no-max-height to 0.
+ this.sectionBody.scrollHeight; // eslint-disable-line no-unused-expressions
+ }
+ }
+
+ setContextMenuButtonRef(element) {
+ this.contextMenuButtonRef = element;
+ }
+
+ componentDidMount() {
+ if (!this.props.Prefs.values["newNewtabExperience.enabled"]) {
+ this.contextMenuButtonRef.addEventListener(
+ "mouseenter",
+ this.onMenuButtonMouseEnter
+ );
+ this.contextMenuButtonRef.addEventListener(
+ "mouseleave",
+ this.onMenuButtonMouseLeave
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this.enableOrDisableAnimation
+ );
+
+ if (!this.props.Prefs.values["newNewtabExperience.enabled"]) {
+ this.contextMenuButtonRef.removeEventListener(
+ "mouseenter",
+ this.onMenuButtonMouseEnter
+ );
+ this.contextMenuButtonRef.removeEventListener(
+ "mouseleave",
+ this.onMenuButtonMouseLeave
+ );
+ }
+ }
+
+ enableOrDisableAnimation() {
+ // Only animate the collapse/expand for visible tabs.
+ const visible = this.props.document.visibilityState === VISIBLE;
+ if (this.state.enableAnimation !== visible) {
+ this.setState({ enableAnimation: visible });
+ }
+ }
+
+ onBodyMount(node) {
+ this.sectionBody = node;
+ }
+
+ collapseOrExpandSection() {
+ // If this.sectionBody is unset, it means that we're in some sort of error
+ // state, probably displaying the error fallback, so we won't be able to
+ // compute the height, and we don't want to persist the preference.
+ if (!this.sectionBody) {
+ return;
+ }
+
+ // Get the current height of the body so max-height transitions can work
+ this.setState({
+ isAnimating: true,
+ maxHeight: `${this._getSectionBodyHeight()}px`,
+ });
+ const { action } = SectionMenuOptions.CheckCollapsed(this.props);
+ this.props.dispatch(action);
+ }
+
+ onHeaderClick() {
+ // If the new new tab experience pref is turned on,
+ // sections should not be collapsible.
+ // If this.sectionBody is unset, it means that we're in some sort of error
+ // state, probably displaying the error fallback, so we won't be able to
+ // compute the height, and we don't want to persist the preference.
+ // If props.collapsed is undefined handler shouldn't do anything.
+ if (
+ this.props.Prefs.values["newNewtabExperience.enabled"] ||
+ !this.sectionBody ||
+ this.props.collapsed === undefined
+ ) {
+ return;
+ }
+
+ this.collapseOrExpandSection();
+ const { userEvent } = SectionMenuOptions.CheckCollapsed(this.props);
+ this.props.dispatch(
+ ac.UserEvent({
+ event: userEvent,
+ source: this.props.eventSource,
+ })
+ );
+ }
+
+ onKeyPress(event) {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ this.onHeaderClick();
+ }
+ }
+
+ _getSectionBodyHeight() {
+ const div = this.sectionBody;
+ if (div.style.display === "none") {
+ // If the div isn't displayed, we can't get it's height. So we display it
+ // to get the height (it doesn't show up because max-height is set to 0px
+ // in CSS). We don't undo this because we are about to expand the section.
+ div.style.display = "block";
+ }
+ return div.scrollHeight;
+ }
+
+ onTransitionEnd(event) {
+ // Only update the animating state for our own transition (not a child's)
+ if (event.target === event.currentTarget) {
+ this.setState({ isAnimating: false });
+ }
+ }
+
+ renderIcon() {
+ const { icon } = this.props;
+ if (icon && icon.startsWith("moz-extension://")) {
+ return (
+ <span
+ className="icon icon-small-spacer"
+ style={{ backgroundImage: `url('${icon}')` }}
+ />
+ );
+ }
+ return (
+ <span
+ className={`icon icon-small-spacer icon-${icon || "webextension"}`}
+ />
+ );
+ }
+
+ onMenuButtonMouseEnter() {
+ this.setState({ menuButtonHover: true });
+ }
+
+ onMenuButtonMouseLeave() {
+ this.setState({ menuButtonHover: false });
+ }
+
+ onMenuUpdate(showContextMenu) {
+ this.setState({ showContextMenu });
+ }
+
+ render() {
+ const isCollapsible = this.props.collapsed !== undefined;
+ const isNewNewtabExperienceEnabled = this.props.Prefs.values[
+ "newNewtabExperience.enabled"
+ ];
+
+ // If new new tab prefs are set to true, sections should not be
+ // collapsible. Expand and make the section visible, if it has been
+ // previously collapsed.
+ if (isNewNewtabExperienceEnabled && this.props.collapsed) {
+ this.collapseOrExpandSection();
+ }
+
+ const {
+ enableAnimation,
+ isAnimating,
+ maxHeight,
+ menuButtonHover,
+ showContextMenu,
+ } = this.state;
+ const {
+ id,
+ eventSource,
+ collapsed,
+ learnMore,
+ title,
+ extraMenuOptions,
+ showPrefName,
+ privacyNoticeURL,
+ dispatch,
+ isFixed,
+ isFirst,
+ isLast,
+ isWebExtension,
+ } = 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" };
+ }
+ return (
+ <section
+ className={`collapsible-section ${this.props.className}${
+ enableAnimation ? " animation-enabled" : ""
+ }${collapsed ? " collapsed" : ""}${active ? " active" : ""}`}
+ aria-expanded={!collapsed}
+ // 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" style={titleStyle}>
+ <span className="click-target-container">
+ {/* Click-targets that toggle a collapsible section should have an aria-expanded attribute; see bug 1553234 */}
+ <span
+ className="click-target"
+ role="button"
+ tabIndex="0"
+ onKeyPress={this.onKeyPress}
+ onClick={this.onHeaderClick}
+ >
+ {!isNewNewtabExperienceEnabled && this.renderIcon()}
+ <FluentOrText message={title} />
+ {!isNewNewtabExperienceEnabled && isCollapsible && (
+ <span
+ data-l10n-id={
+ collapsed
+ ? "newtab-section-expand-section-label"
+ : "newtab-section-collapse-section-label"
+ }
+ className={`collapsible-arrow icon ${
+ collapsed
+ ? "icon-arrowhead-forward-small"
+ : "icon-arrowhead-down-small"
+ }`}
+ />
+ )}
+ </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>
+ </span>
+ </h3>
+ {!isNewNewtabExperienceEnabled && (
+ <div>
+ <ContextMenuButton
+ tooltip="newtab-menu-section-tooltip"
+ onUpdate={this.onMenuUpdate}
+ refFunction={this.setContextMenuButtonRef}
+ >
+ <SectionMenu
+ id={id}
+ extraOptions={extraMenuOptions}
+ source={eventSource}
+ showPrefName={showPrefName}
+ privacyNoticeURL={privacyNoticeURL}
+ collapsed={collapsed}
+ isFixed={isFixed}
+ isFirst={isFirst}
+ isLast={isLast}
+ dispatch={dispatch}
+ isWebExtension={isWebExtension}
+ />
+ </ContextMenuButton>
+ </div>
+ )}
+ </div>
+ <ErrorBoundary className="section-body-fallback">
+ <div
+ className={`section-body${isAnimating ? " animating" : ""}`}
+ onTransitionEnd={this.onTransitionEnd}
+ 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..89aacaccfb
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss
@@ -0,0 +1,188 @@
+.outer-wrapper.newtab-experience {
+ .collapsible-section {
+ &[data-section-id='topsites'] {
+ .section-top-bar {
+ display: none;
+ }
+ }
+
+ .click-target-container {
+ .click-target {
+ span {
+ cursor: default;
+ font-weight: 600;
+ font-size: 17px;
+ color: var(--newtab-background-primary-text-color);
+ }
+ }
+ }
+ }
+}
+
+.outer-wrapper:not(.newtab-experience) {
+ .collapsible-section {
+ .section-title {
+ span {
+ vertical-align: middle;
+ }
+ }
+ }
+}
+
+.collapsible-section {
+ padding: $section-vertical-padding $section-horizontal-padding;
+ transition-delay: 100ms;
+ transition-duration: 100ms;
+ transition-property: background-color;
+
+ .section-title {
+ font-size: $section-title-font-size;
+ font-weight: bold;
+ margin: 0;
+
+ &.grey-title,
+ span {
+ color: var(--newtab-section-header-text-color);
+ display: inline-block;
+ fill: var(--newtab-section-header-text-color);
+ }
+
+ &.grey-title {
+ vertical-align: middle;
+ }
+
+ .click-target-container {
+ // 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;
+
+ .click-target {
+ white-space: nowrap;
+ cursor: pointer;
+ }
+ }
+
+ .collapsible-arrow {
+ margin-inline-start: 8px;
+ margin-top: -1px;
+ }
+ }
+
+ .section-top-bar {
+ min-height: 19px;
+ margin-bottom: 13px;
+ position: relative;
+
+ .context-menu-button {
+ background: url('chrome://global/skin/icons/more.svg') no-repeat right center;
+ border: 0;
+ cursor: pointer;
+ fill: var(--newtab-section-header-text-color);
+ height: 100%;
+ inset-inline-end: 0;
+ opacity: 0;
+ position: absolute;
+ top: 0;
+ transition-duration: 200ms;
+ transition-property: opacity;
+ width: $context-menu-button-size;
+
+ &:is(:active, :focus, :hover) {
+ fill: var(--newtab-section-header-text-color);
+ opacity: 1;
+ }
+ }
+
+ .context-menu {
+ top: 16px;
+ }
+
+ @media (max-width: $break-point-widest + $card-width * 1.5) {
+ @include context-menu-open-left;
+ }
+ }
+
+ &:hover,
+ &.active {
+ .section-top-bar {
+ .context-menu-button {
+ opacity: 1;
+ }
+ }
+ }
+
+ &.active {
+ background: var(--newtab-element-hover-color);
+ border-radius: 4px;
+
+ .section-top-bar {
+ .context-menu-button {
+ fill: var(--newtab-section-active-contextmenu-color);
+ }
+ }
+ }
+
+ .learn-more-link {
+ font-size: 11px;
+ margin-inline-start: 12px;
+
+ a {
+ color: var(--newtab-link-secondary-color);
+ }
+ }
+
+ .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;
+ }
+ }
+
+ &.animation-enabled {
+ .section-title {
+ .collapsible-arrow {
+ transition: transform 0.5s $photon-easing;
+ }
+ }
+
+ .section-body {
+ transition: max-height 0.5s $photon-easing;
+ }
+ }
+
+ &.collapsed {
+ .section-body {
+ max-height: 0;
+ overflow: hidden;
+ }
+ }
+
+ // 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..169632c2c5
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx
@@ -0,0 +1,174 @@
+/* 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.jsm";
+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..b2e80b8645
--- /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.jsm";
+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..1bdb41dbb7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss
@@ -0,0 +1,68 @@
+.confirmation-dialog {
+ .modal {
+ box-shadow: 0 2px 2px 0 $black-10;
+ 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-modal-color);
+ 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..f2f3fde03f
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx
@@ -0,0 +1,179 @@
+/* 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;
+ const isNewNewtabExperienceEnabled = this.props.Prefs.values[
+ "newNewtabExperience.enabled"
+ ];
+ 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}
+ >
+ {!isNewNewtabExperienceEnabled && option.icon && (
+ <span className={`icon icon-spacer icon-${option.icon}`} />
+ )}
+ <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..bf6e602de7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss
@@ -0,0 +1,55 @@
+.context-menu {
+ background: var(--newtab-contextmenu-background-color);
+ 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: ($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-hover-color);
+ }
+
+ &:active {
+ background: var(--newtab-element-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..b6c8b43f51
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx
@@ -0,0 +1,277 @@
+/* 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.jsm";
+
+export class ContentSection extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onPreferenceSelect = this.onPreferenceSelect.bind(this);
+ }
+
+ 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");
+ 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);
+ }
+
+ render() {
+ const {
+ topSitesEnabled,
+ pocketEnabled,
+ highlightsEnabled,
+ snippetsEnabled,
+ showSponsoredTopSitesEnabled,
+ showSponsoredPocketEnabled,
+ topSitesRowsCount,
+ } = this.props.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 ${
+ topSitesEnabled ? "" : "shrink"
+ }`}
+ >
+ <div
+ className={`more-information ${
+ topSitesEnabled ? "expand" : "shrink"
+ }`}
+ >
+ <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>
+ {this.props.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>
+
+ {this.props.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"
+ />
+ {this.props.mayHaveSponsoredStories && (
+ <div
+ className={`more-info-pocket-wrapper ${
+ pocketEnabled ? "" : "shrink"
+ }`}
+ >
+ <div
+ className={`more-information ${
+ pocketEnabled ? "expand" : "shrink"
+ }`}
+ >
+ <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>
+ </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>
+
+ <div id="snippets-section" className="section">
+ <label className="switch">
+ <input
+ id="snippets-toggle"
+ checked={snippetsEnabled}
+ type="checkbox"
+ onChange={this.onPreferenceSelect}
+ preference="feeds.snippets"
+ aria-labelledby="custom-snippets-title"
+ aria-describedby="custom-snippets-subtitle"
+ eventSource="SNIPPETS"
+ />
+ <span className="slider" role="presentation"></span>
+ </label>
+ <div>
+ <h2 id="custom-snippets-title" className="title">
+ <label
+ htmlFor="snippets-toggle"
+ data-l10n-id="newtab-custom-snippets-title"
+ ></label>
+ </h2>
+ <p
+ id="custom-snippets-subtitle"
+ className="subtitle"
+ data-l10n-id="newtab-custom-snippets-subtitle"
+ />
+ </div>
+ </div>
+
+ <span className="divider" role="separator"></span>
+
+ <div>
+ <button
+ id="settings-link"
+ className="external-link"
+ onClick={this.props.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..8e08efef2b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx
@@ -0,0 +1,38 @@
+/* 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 { ThemesSection } from "content-src/components/CustomizeMenu/ThemesSection/ThemesSection";
+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";
+
+export class _CustomizeMenu extends React.PureComponent {
+ render() {
+ return (
+ <div className="customize-menu">
+ <button
+ onClick={this.props.onClose}
+ className="close-button"
+ data-l10n-id="newtab-custom-close-button"
+ />
+ <ThemesSection />
+ <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}
+ dispatch={this.props.dispatch}
+ />
+ </div>
+ );
+ }
+}
+
+export const CustomizeMenu = connect(state => ({
+ DiscoveryStream: state.DiscoveryStream,
+}))(_CustomizeMenu);
diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/ThemesSection/ThemesSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/ThemesSection/ThemesSection.jsx
new file mode 100644
index 0000000000..e220b93b00
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CustomizeMenu/ThemesSection/ThemesSection.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 ThemesSection extends React.PureComponent {
+ render() {
+ return <div />;
+ }
+}
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..d4dfd37a9c
--- /dev/null
+++ b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss
@@ -0,0 +1,297 @@
+
+.customize-menu {
+ color: var(--customize-menu-primary-text-color);
+ background-color: var(--customize-menu-background);
+ width: 432px;
+ height: 100%;
+ position: fixed;
+ inset-block: 0;
+ inset-inline-end: 0;
+ z-index: 1001;
+ padding: 16px;
+ transition: transform 250ms $customize-menu-slide-bezier;
+ overflow: auto;
+ transform: translateX(435px);
+ visibility: hidden;
+ cursor: default;
+
+ &:dir(rtl) {
+ transform: translateX(-435px);
+ }
+
+ &.customize-animate-enter-done {
+ transform: translateX(0);
+ }
+
+ &.customize-animate-enter-done,
+ &.customize-animate-enter-active,
+ &.customize-animate-enter {
+ box-shadow: 0 0 64px var(--customize-menu-first-shadow), 0 0 24px var(--customize-menu-second-shadow);
+ transition: transform 250ms $customize-menu-slide-bezier, visibility 1ms;
+ visibility: visible;
+ }
+
+ &.customize-animate-exit-active,
+ &.customize-animate-exit {
+ box-shadow: 0 0 64px var(--customize-menu-first-shadow), 0 0 24px var(--customize-menu-second-shadow);
+ transition: transform 250ms $customize-menu-slide-bezier, visibility 1ms 250ms;
+ visibility: visible;
+ }
+
+ &.customize-animate-exit-done {
+ transform: translateX(435px);
+ transition: transform 250ms $customize-menu-slide-bezier, visibility 1ms 250ms;
+
+ &:dir(rtl) {
+ transform: translateX(-435px);
+ }
+ }
+
+ .close-button {
+ margin-inline-start: auto;
+ margin-bottom: 28px;
+ white-space: nowrap;
+ display: block;
+ background-color: var(--customize-menu-secondary-action-background);
+ padding: 8px 10px;
+ border: $customize-menu-border-tint;
+ border-radius: 4px;
+ color: var(--customize-menu-primary-text-color);
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .close-button:hover {
+ background-color: var(--customize-menu-secondary-action-background-hover);
+ }
+
+ .close-button:hover:active {
+ background-color: var(--customize-menu-secondary-action-background-active);
+ }
+}
+
+.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-primary-action-background-off);
+ }
+
+ .sponsored-checkbox:checked {
+ -moz-context-properties: fill;
+ fill: var(--customize-menu-check-fill);
+ background: url('chrome://global/skin/icons/check.svg') center no-repeat;
+ background-color: var(--newtab-primary-action-background);
+ background-size: 10px;
+ }
+
+ .sponsored-checkbox:active + .checkmark {
+ fill: $newtab-card-tint;
+ }
+
+ .selector {
+ color: var(--customize-menu-primary-text-color);
+ width: 118px;
+ display: block;
+ border: 1px solid var(--customize-menu-line-color);
+ border-radius: 4px;
+ appearance: none;
+ padding-block: 7px;
+ padding-inline: 10px 13px;
+ margin-inline-start: 2px;
+ -moz-context-properties: fill;
+ fill: var(--customize-menu-primary-text-color);
+ background: url('#{$image-path}glyph-arrowhead-down-12.svg') right no-repeat;
+ background-size: 8px;
+ background-origin: content-box;
+ background-color: var(--customize-menu-background);
+
+ &: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-primary-action-background-off);
+
+ &::before {
+ position: absolute;
+ content: '';
+ height: 8px;
+ width: 8px;
+ inset-inline-start: 3px;
+ bottom: 3px;
+ background-color: var(--customize-menu-primary-action-text);
+ transition: transform 250ms;
+ border-radius: 50%;
+ outline: $customize-menu-border-tint;
+ -moz-outline-radius: 11px;
+ }
+ }
+
+ .switch input:focus-visible + .slider {
+ border: 1px solid var(--newtab-focus-border-selected);
+ outline: 0;
+ box-shadow: 0 0 0 2px var(--newtab-focus-outline);
+ }
+
+ .switch input:not(:checked):focus-visible + .slider {
+ border: 1px solid var(--newtab-focus-border);
+ }
+
+ 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;
+ transition: max-height 250ms $customize-menu-expand-bezier;
+
+ &.shrink {
+ max-height: 0;
+ }
+
+ .more-information {
+ padding-top: 12px;
+ position: relative;
+ transition: top 250ms $customize-menu-expand-bezier;
+ }
+
+ .more-information.expand {
+ top: 0;
+ }
+ }
+
+ .more-info-top-wrapper {
+ max-height: 78px;
+
+ .more-information {
+ top: -77px;
+ }
+
+ .check-wrapper {
+ margin-top: 10px;
+ }
+ }
+
+ .more-info-pocket-wrapper {
+ max-height: 35px;
+
+ .more-information {
+ top: -35px;
+ }
+ }
+ }
+
+ .divider {
+ border-top: 1px var(--customize-menu-seperator-line-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(--customize-menu-primary-text-color);
+ background: url('chrome://global/skin/icons/settings.svg') left no-repeat;
+ background-size: 16px;
+ padding-inline-start: 21px;
+ margin-bottom: 20px;
+
+ &:dir(rtl) {
+ background-position-x: right;
+ }
+ }
+
+ .external-link:hover {
+ text-decoration: underline;
+ }
+}
+
+.home-section .section .sponsored-checkbox:focus-visible,
+.selector:focus-visible,
+.external-link:focus-visible,
+.close-button:focus-visible {
+ border: 1px solid var(--newtab-focus-border);
+ outline: 0;
+ box-shadow: 0 0 0 2px var(--newtab-focus-outline);
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
new file mode 100644
index 0000000000..590223a981
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx
@@ -0,0 +1,393 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid";
+import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
+import { connect } from "react-redux";
+import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage";
+import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal";
+import { DSSignup } from "content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup";
+import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo";
+import { Hero } from "content-src/components/DiscoveryStreamComponents/Hero/Hero";
+import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights";
+import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
+import { List } from "content-src/components/DiscoveryStreamComponents/List/List";
+import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation";
+import React from "react";
+import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle";
+import { selectLayoutRender } from "content-src/lib/selectLayoutRender";
+import { TopSites } from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites";
+
+const ALLOWED_CSS_URL_PREFIXES = [
+ "chrome://",
+ "resource://",
+ "https://img-getpocket.cdn.mozilla.net/",
+];
+const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR";
+
+/**
+ * Validate a CSS declaration. The values are assumed to be normalized by CSSOM.
+ */
+export function isAllowedCSS(property, value) {
+ // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are
+ // exposed but their values aren't resulting in getting nothing. Fortunately,
+ // we don't care about validating the values of the current set of properties.
+ if (value === undefined) {
+ return true;
+ }
+
+ // Make sure all urls are of the allowed protocols/prefixes
+ const urls = value.match(/url\("[^"]+"\)/g);
+ return (
+ !urls ||
+ urls.every(url =>
+ ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix))
+ )
+ );
+}
+
+export class _DiscoveryStreamBase extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onStyleMount = this.onStyleMount.bind(this);
+ }
+
+ onStyleMount(style) {
+ // Unmounting style gets rid of old styles, so nothing else to do
+ if (!style) {
+ return;
+ }
+
+ const { sheet } = style;
+ const styles = JSON.parse(style.dataset.styles);
+ styles.forEach((row, rowIndex) => {
+ row.forEach((component, componentIndex) => {
+ // Nothing to do without optional styles overrides
+ if (!component) {
+ return;
+ }
+
+ Object.entries(component).forEach(([selectors, declarations]) => {
+ // Start with a dummy rule to validate declarations and selectors
+ sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`);
+ const [rule] = sheet.cssRules;
+
+ // Validate declarations and remove any offenders. CSSOM silently
+ // discards invalid entries, so here we apply extra restrictions.
+ rule.style = declarations;
+ [...rule.style].forEach(property => {
+ const value = rule.style[property];
+ if (!isAllowedCSS(property, value)) {
+ console.error(`Bad CSS declaration ${property}: ${value}`); // eslint-disable-line no-console
+ rule.style.removeProperty(property);
+ }
+ });
+
+ // Set the actual desired selectors scoped to the component
+ const prefix = `.ds-layout > .ds-column:nth-child(${rowIndex +
+ 1}) .ds-column-grid > :nth-child(${componentIndex + 1})`;
+ // NB: Splitting on "," doesn't work with strings with commas, but
+ // we're okay with not supporting those selectors
+ rule.selectorText = selectors
+ .split(",")
+ .map(
+ selector =>
+ prefix +
+ // Assume :pseudo-classes are for component instead of descendant
+ (selector[0] === ":" ? "" : " ") +
+ selector
+ )
+ .join(",");
+
+ // CSSOM silently ignores bad selectors, so we'll be noisy instead
+ if (rule.selectorText === DUMMY_CSS_SELECTOR) {
+ console.error(`Bad CSS selector ${selectors}`); // eslint-disable-line no-console
+ }
+ });
+ });
+ });
+ }
+
+ renderComponent(component, embedWidth) {
+ const ENGAGEMENT_LABEL_ENABLED = this.props.Prefs.values[
+ `discoverystream.engagementLabelEnabled`
+ ];
+
+ switch (component.type) {
+ case "Highlights":
+ return <Highlights />;
+ case "TopSites":
+ let promoAlignment;
+ if (
+ component.spocs &&
+ component.spocs.positions &&
+ component.spocs.positions.length
+ ) {
+ promoAlignment =
+ component.spocs.positions[0].index === 0 ? "left" : "right";
+ }
+ return (
+ <TopSites
+ header={component.header}
+ data={component.data}
+ promoAlignment={promoAlignment}
+ />
+ );
+ case "TextPromo":
+ return (
+ <DSTextPromo
+ dispatch={this.props.dispatch}
+ type={component.type}
+ data={component.data}
+ />
+ );
+ case "Signup":
+ return (
+ <DSSignup
+ dispatch={this.props.dispatch}
+ type={component.type}
+ data={component.data}
+ />
+ );
+ case "Message":
+ return (
+ <DSMessage
+ title={component.header && component.header.title}
+ subtitle={component.header && component.header.subtitle}
+ link_text={component.header && component.header.link_text}
+ link_url={component.header && component.header.link_url}
+ icon={component.header && component.header.icon}
+ />
+ );
+ case "SectionTitle":
+ return <SectionTitle header={component.header} />;
+ case "Navigation":
+ return (
+ <Navigation
+ dispatch={this.props.dispatch}
+ links={component.properties.links}
+ alignment={component.properties.alignment}
+ display_variant={component.properties.display_variant}
+ explore_topics={component.properties.explore_topics}
+ header={component.header}
+ />
+ );
+ case "CollectionCardGrid":
+ const { DiscoveryStream } = this.props;
+ return (
+ <CollectionCardGrid
+ data={component.data}
+ feed={component.feed}
+ spocs={DiscoveryStream.spocs}
+ placement={component.placement}
+ border={component.properties.border}
+ type={component.type}
+ items={component.properties.items}
+ cta_variant={component.cta_variant}
+ display_engagement_labels={ENGAGEMENT_LABEL_ENABLED}
+ dismissible={this.props.DiscoveryStream.isCollectionDismissible}
+ dispatch={this.props.dispatch}
+ />
+ );
+ case "CardGrid":
+ return (
+ <CardGrid
+ enable_video_playheads={
+ !!component.properties.enable_video_playheads
+ }
+ title={component.header && component.header.title}
+ display_variant={component.properties.display_variant}
+ data={component.data}
+ feed={component.feed}
+ border={component.properties.border}
+ type={component.type}
+ dispatch={this.props.dispatch}
+ items={component.properties.items}
+ cta_variant={component.cta_variant}
+ display_engagement_labels={ENGAGEMENT_LABEL_ENABLED}
+ />
+ );
+ case "Hero":
+ return (
+ <Hero
+ subComponentType={embedWidth >= 9 ? `cards` : `list`}
+ feed={component.feed}
+ title={component.header && component.header.title}
+ data={component.data}
+ border={component.properties.border}
+ type={component.type}
+ dispatch={this.props.dispatch}
+ items={component.properties.items}
+ />
+ );
+ case "HorizontalRule":
+ return <HorizontalRule />;
+ case "List":
+ return (
+ <List
+ data={component.data}
+ feed={component.feed}
+ fullWidth={component.properties.full_width}
+ hasBorders={component.properties.border === "border"}
+ hasImages={component.properties.has_images}
+ hasNumbers={component.properties.has_numbers}
+ items={component.properties.items}
+ type={component.type}
+ header={component.header}
+ />
+ );
+ default:
+ return <div>{component.type}</div>;
+ }
+ }
+
+ renderStyles(styles) {
+ // Use json string as both the key and styles to render so React knows when
+ // to unmount and mount a new instance for new styles.
+ const json = JSON.stringify(styles);
+ return <style key={json} data-styles={json} ref={this.onStyleMount} />;
+ }
+
+ render() {
+ // Select layout render data by adding spocs and position to recommendations
+ const { layoutRender } = selectLayoutRender({
+ state: this.props.DiscoveryStream,
+ prefs: this.props.Prefs.values,
+ locale: this.props.locale,
+ });
+ const { config } = this.props.DiscoveryStream;
+
+ // Allow rendering without extracting special components
+ if (!config.collapsible) {
+ return this.renderLayout(layoutRender);
+ }
+
+ // Find the first component of a type and remove it from layout
+ const extractComponent = type => {
+ for (const [rowIndex, row] of Object.entries(layoutRender)) {
+ for (const [index, component] of Object.entries(row.components)) {
+ if (component.type === type) {
+ // Remove the row if it was the only component or the single item
+ if (row.components.length === 1) {
+ layoutRender.splice(rowIndex, 1);
+ } else {
+ row.components.splice(index, 1);
+ }
+ return component;
+ }
+ }
+ }
+ return null;
+ };
+
+ // Get "topstories" Section state for default values
+ const topStories = this.props.Sections.find(s => s.id === "topstories");
+
+ if (!topStories) {
+ return null;
+ }
+
+ // Extract TopSites to render before the rest and Message to use for header
+ const topSites = extractComponent("TopSites");
+ const sponsoredCollection = extractComponent("CollectionCardGrid");
+ const message = extractComponent("Message") || {
+ header: {
+ link_text: topStories.learnMore.link.message,
+ link_url: topStories.learnMore.link.href,
+ title: topStories.title,
+ },
+ };
+
+ // Render a DS-style TopSites then the rest if any in a collapsible section
+ return (
+ <React.Fragment>
+ {this.props.DiscoveryStream.isPrivacyInfoModalVisible && (
+ <DSPrivacyModal dispatch={this.props.dispatch} />
+ )}
+ {topSites &&
+ this.renderLayout([
+ {
+ width: 12,
+ components: [topSites],
+ },
+ ])}
+ {sponsoredCollection &&
+ this.renderLayout([
+ {
+ width: 12,
+ components: [sponsoredCollection],
+ },
+ ])}
+ {!!layoutRender.length && (
+ <CollapsibleSection
+ className="ds-layout"
+ collapsed={topStories.pref.collapsed}
+ dispatch={this.props.dispatch}
+ icon={topStories.icon}
+ id={topStories.id}
+ isFixed={true}
+ learnMore={{
+ link: {
+ href: message.header.link_url,
+ message: message.header.link_text,
+ },
+ }}
+ privacyNoticeURL={topStories.privacyNoticeURL}
+ showPrefName={topStories.pref.feed}
+ title={message.header.title}
+ eventSource="CARDGRID"
+ >
+ {this.renderLayout(layoutRender)}
+ </CollapsibleSection>
+ )}
+ {this.renderLayout([
+ {
+ width: 12,
+ components: [{ type: "Highlights" }],
+ },
+ ])}
+ </React.Fragment>
+ );
+ }
+
+ renderLayout(layoutRender) {
+ const styles = [];
+ return (
+ <div className="discovery-stream ds-layout">
+ {layoutRender.map((row, rowIndex) => (
+ <div
+ key={`row-${rowIndex}`}
+ className={`ds-column ds-column-${row.width}`}
+ >
+ <div className="ds-column-grid">
+ {row.components.map((component, componentIndex) => {
+ if (!component) {
+ return null;
+ }
+ styles[rowIndex] = [
+ ...(styles[rowIndex] || []),
+ component.styles,
+ ];
+ return (
+ <div key={`component-${componentIndex}`}>
+ {this.renderComponent(component, row.width)}
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ ))}
+ {this.renderStyles(styles)}
+ </div>
+ );
+ }
+}
+
+export const DiscoveryStreamBase = connect(state => ({
+ DiscoveryStream: state.DiscoveryStream,
+ Prefs: state.Prefs,
+ Sections: state.Sections,
+ document: global.document,
+}))(_DiscoveryStreamBase);
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..7626ebbd45
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss
@@ -0,0 +1,70 @@
+$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 {
+ @include dark-theme-only {
+ color: $grey-30;
+ }
+
+ color: $grey-50;
+ font-size: 13px;
+ 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-link-primary-color);
+ font-weight: normal;
+
+ &:is(:focus, :hover) {
+ text-decoration: underline;
+ }
+ }
+ }
+}
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..6734d8d00c
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
@@ -0,0 +1,109 @@
+/* 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 { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
+import React from "react";
+
+export class CardGrid extends React.PureComponent {
+ renderCards() {
+ const recs = this.props.data.recommendations.slice(0, this.props.items);
+ const cards = [];
+
+ for (let index = 0; index < this.props.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}
+ 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}
+ engagement={rec.engagement}
+ display_engagement_labels={this.props.display_engagement_labels}
+ cta={rec.cta}
+ cta_variant={this.props.cta_variant}
+ is_video={this.props.enable_video_playheads && rec.is_video}
+ is_collection={this.props.is_collection}
+ />
+ )
+ );
+ }
+
+ // Used for CSS overrides to default styling (eg: "hero")
+ const variantClass = this.props.display_variant
+ ? `ds-card-grid-${this.props.display_variant}`
+ : ``;
+
+ return (
+ <div
+ className={`ds-card-grid ds-card-grid-${this.props.border} ${variantClass}`}
+ >
+ {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 = {
+ border: `border`,
+ items: 4, // Number of stories to display
+ enable_video_playheads: false,
+};
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..3eed3a70b5
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
@@ -0,0 +1,164 @@
+$col4-header-line-height: 20;
+$col4-header-font-size: 14;
+
+// Special styling for the New Tab Experience styles,
+// This is to be incorporated once the styles are made permanent
+.outer-wrapper.newtab-experience {
+ .ds-card-grid {
+ &.ds-card-grid-border {
+ .ds-card:not(.placeholder) {
+ border-radius: $border-radius-new;
+ box-shadow: 0 3px 8px var(--newtab-card-first-shadow), 0 0 2px var(--newtab-card-second-shadow);
+
+ .img-wrapper .img img {
+ border-radius: $border-radius-new $border-radius-new 0 0;
+ }
+ }
+ }
+
+ .ds-card-link:focus {
+ @include ds-focus-nte;
+ transition: none;
+ border-radius: $border-radius-new;
+ }
+ }
+}
+
+.ds-card-grid {
+ display: grid;
+ grid-gap: 24px;
+
+ .ds-card {
+ @include dark-theme-only {
+ background: none;
+ }
+
+ background: $white;
+ border-radius: 4px;
+ }
+
+ .ds-column-12 &.ds-card-grid-hero {
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(12, 1fr);
+
+ // "hero"
+ .ds-card:nth-child(1) {
+ grid-column-start: 1;
+ grid-column-end: span 6;
+ grid-row-start: 1;
+ grid-row-end: span 2;
+
+ .excerpt {
+ -webkit-line-clamp: 4;
+ }
+ }
+
+ .ds-card:nth-child(2),
+ .ds-card:nth-child(4) {
+ grid-column-start: 7;
+ grid-column-end: span 3;
+ }
+
+ .ds-card:nth-child(3),
+ .ds-card:nth-child(5) {
+ grid-column-start: 10;
+ grid-column-end: span 3;
+ }
+
+ // "small" cards
+ .ds-card:nth-child(n+2):nth-child(-n+5) {
+ .excerpt {
+ display: none;
+ }
+
+ .meta {
+ padding: 8px;
+
+ .title {
+ font-size: 13px;
+ line-height: 19px;
+ }
+
+ .story-sponsored-label {
+ -webkit-line-clamp: none;
+ line-height: 19px;
+ }
+ }
+ }
+
+ .ds-card:nth-child(n+6) {
+ grid-column-start: auto;
+ grid-column-end: span 4;
+ }
+ }
+
+ @media (min-width: $break-point-widest) {
+ // "small" cards
+ .ds-card:nth-child(n+2):nth-child(-n+5) {
+ min-height: 222px;
+ }
+ }
+ }
+
+ &.ds-card-grid-border {
+ .ds-card:not(.placeholder) {
+ @include dark-theme-only {
+ box-shadow: 0 1px 4px $shadow-10;
+ background: $grey-70;
+ }
+
+ box-shadow: 0 1px 4px 0 $grey-90-10;
+
+ .img-wrapper .img img {
+ border-radius: 4px 4px 0 0;
+ }
+ }
+ }
+
+ &.ds-card-grid-no-border {
+ .ds-card {
+ background: none;
+
+ .meta {
+ padding: 12px 0;
+ }
+ }
+ }
+
+ // "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;
+ }
+}
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..fa7b2b7b4f
--- /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.jsm";
+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.UserEvent({
+ event: userEvent,
+ source,
+ action_position: pos,
+ })
+ );
+ if (impression) {
+ this.props.dispatch(impression);
+ }
+ }
+ }
+
+ render() {
+ const { data, dismissible } = 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
+ title={title}
+ context={sponsoredByMessage}
+ data={recsData}
+ feed={feed}
+ border={this.props.border}
+ 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..14667be1bd
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss
@@ -0,0 +1,46 @@
+.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 {
+ @include dark-theme-only {
+ color: $grey-30;
+ }
+
+ color: $grey-90;
+ font-weight: 600;
+ font-size: 17px;
+ line-height: 24px;
+ }
+
+ .ds-context {
+ @include dark-theme-only {
+ color: $grey-40;
+ }
+
+ color: $grey-50;
+ 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..b94d29bb6d
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.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 } from "common/Actions.jsm";
+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 } from "../DSContextFooter/DSContextFooter.jsx";
+import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
+import { connect } from "react-redux";
+
+// Default Meta that displays CTA as link if cta_variant in layout is set as "link"
+export const DefaultMeta = ({
+ display_engagement_labels,
+ source,
+ title,
+ excerpt,
+ context,
+ context_type,
+ cta,
+ engagement,
+ cta_variant,
+ sponsor,
+ sponsored_by_override,
+}) => (
+ <div className="meta">
+ <div className="info-wrap">
+ <p className="source clamp">{source}</p>
+ <header className="title clamp">{title}</header>
+ {excerpt && <p className="excerpt clamp">{excerpt}</p>}
+ {cta_variant === "link" && cta && (
+ <div role="link" className="cta-link icon icon-arrow" tabIndex="0">
+ {cta}
+ </div>
+ )}
+ </div>
+ <DSContextFooter
+ context_type={context_type}
+ context={context}
+ sponsor={sponsor}
+ sponsored_by_override={sponsored_by_override}
+ display_engagement_labels={display_engagement_labels}
+ engagement={engagement}
+ />
+ </div>
+);
+
+export const CTAButtonMeta = ({
+ display_engagement_labels,
+ source,
+ title,
+ excerpt,
+ context,
+ context_type,
+ cta,
+ engagement,
+ sponsor,
+ sponsored_by_override,
+}) => (
+ <div className="meta">
+ <div className="info-wrap">
+ <p className="source clamp">
+ {context && (
+ <FluentOrText
+ message={{
+ id: `newtab-label-sponsored`,
+ values: { sponsorOrSource: sponsor ? sponsor : source },
+ }}
+ />
+ )}
+
+ {!context && (sponsor ? sponsor : source)}
+ </p>
+ <header className="title clamp">{title}</header>
+ {excerpt && <p className="excerpt clamp">{excerpt}</p>}
+ </div>
+ {context && cta && <button className="button cta-button">{cta}</button>}
+ {!context && (
+ <DSContextFooter
+ context_type={context_type}
+ context={context}
+ sponsor={sponsor}
+ sponsored_by_override={sponsored_by_override}
+ display_engagement_labels={display_engagement_labels}
+ engagement={engagement}
+ />
+ )}
+ </div>
+);
+
+export class _DSCard extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onLinkClick = this.onLinkClick.bind(this);
+ 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.UserEvent({
+ event: "CLICK",
+ source: this.props.is_video
+ ? "CARDGRID_VIDEO"
+ : 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.is_video
+ ? "CARDGRID_VIDEO"
+ : this.props.type.toUpperCase(),
+ click: 0,
+ tiles: [
+ {
+ id: this.props.id,
+ pos: this.props.pos,
+ ...(this.props.shim && this.props.shim.click
+ ? { shim: this.props.shim.click }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ 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 isButtonCTA = this.props.cta_variant === "button";
+ const baseClass = `ds-card ${this.props.is_video ? `video-card` : ``}`;
+
+ return (
+ <div className={baseClass}>
+ <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}
+ />
+ {this.props.is_video && (
+ <div className="playhead">
+ <span>Video Content</span>
+ </div>
+ )}
+ </div>
+ {isButtonCTA ? (
+ <CTAButtonMeta
+ display_engagement_labels={this.props.display_engagement_labels}
+ source={this.props.source}
+ title={this.props.title}
+ excerpt={this.props.excerpt}
+ context={this.props.context}
+ context_type={this.props.context_type}
+ engagement={this.props.engagement}
+ cta={this.props.cta}
+ sponsor={this.props.sponsor}
+ sponsored_by_override={this.props.sponsored_by_override}
+ />
+ ) : (
+ <DefaultMeta
+ display_engagement_labels={this.props.display_engagement_labels}
+ source={this.props.source}
+ title={this.props.title}
+ excerpt={this.props.excerpt}
+ context={this.props.context}
+ engagement={this.props.engagement}
+ context_type={this.props.context_type}
+ cta={this.props.cta}
+ cta_variant={this.props.cta_variant}
+ sponsor={this.props.sponsor}
+ sponsored_by_override={this.props.sponsored_by_override}
+ />
+ )}
+ <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.is_video ? "CARDGRID_VIDEO" : this.props.type}
+ />
+ </SafeAnchor>
+ <DSLinkMenu
+ id={this.props.id}
+ index={this.props.pos}
+ dispatch={this.props.dispatch}
+ url={this.props.url}
+ title={this.props.title}
+ source={this.props.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}
+ />
+ </div>
+ );
+ }
+}
+
+_DSCard.defaultProps = {
+ windowObj: window, // Added to support unit tests
+};
+
+export const DSCard = connect(state => ({
+ App: state.App,
+}))(_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..8564fa4f67
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
@@ -0,0 +1,313 @@
+// Type sizes
+$header-font-size: 17;
+$header-line-height: 24;
+$excerpt-font-size: 14;
+$excerpt-line-height: 20;
+
+.outer-wrapper:not(.newtab-experience) {
+ .ds-card {
+ .ds-card-link {
+ &:hover {
+ @include ds-fade-in($grey-30);
+
+ @include dark-theme-only {
+ @include ds-fade-in($grey-60);
+ }
+ }
+
+ &:focus {
+ @include ds-fade-in;
+
+ @include dark-theme-only {
+ @include ds-fade-in($blue-40-40);
+ }
+ }
+
+ &:active {
+ @include ds-fade-in($grey-30);
+
+ @include dark-theme-only {
+ @include ds-fade-in($grey-60);
+ }
+ }
+ }
+ }
+}
+
+.ds-card {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+
+ .playhead {
+ background: $blue-60 url('chrome://activity-stream/content/data/content/assets/glyph-playhead.svg') no-repeat 12px center;
+ border-radius: 20px;
+ bottom: -16px;
+ color: $white-0;
+ display: flex;
+ flex-direction: column;
+ height: 40px;
+ justify-content: center;
+ left: 16px;
+ min-width: 40px;
+ padding: 0 0 0 40px;
+ position: absolute;
+ transition: padding 100ms ease-in-out 0ms, color 100ms linear 100ms;
+
+ &:hover {
+ color: $white-100;
+ padding: 0 20px 0 40px;
+ }
+
+ span {
+ display: none;
+ }
+
+ &:hover span {
+ display: inline;
+ font-style: normal;
+ font-weight: 600;
+ font-size: 13px;
+ }
+ }
+
+ &.placeholder {
+ background: transparent;
+ box-shadow: inset $inner-box-shadow;
+ border-radius: 4px;
+ min-height: 300px;
+ }
+
+ .img-wrapper {
+ width: 100%;
+ position: relative;
+ }
+
+ .img {
+ height: 0;
+ padding-top: 50%; // 2:1 aspect ratio
+
+ img {
+ border-radius: 4px;
+ box-shadow: inset 0 0 0 0.5px $black-15;
+ }
+ }
+
+ .ds-card-link {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+
+ &:hover {
+ header {
+ @include dark-theme-only {
+ color: $blue-40;
+ }
+
+ color: $blue-60;
+ }
+ }
+
+ &:focus {
+ header {
+ @include dark-theme-only {
+ color: $blue-40;
+ }
+
+ color: $blue-60;
+ }
+ }
+
+ &:active {
+ header {
+ @include dark-theme-only {
+ color: $blue-50;
+ }
+
+ color: $blue-70;
+ }
+ }
+ }
+
+ &.video-card .meta {
+ margin-top: 4px;
+ }
+
+ .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 {
+ @include dark-theme-only {
+ color: $grey-40;
+ }
+
+ -webkit-line-clamp: 1;
+ margin-bottom: 2px;
+ font-size: 13px;
+ color: $grey-50;
+ }
+
+ .cta-button {
+ @include dark-theme-only {
+ color: $grey-10;
+ background: $grey-90-70;
+ }
+
+ width: 100%;
+ margin: 12px 0 4px;
+ box-shadow: none;
+ border-radius: 4px;
+ height: 32px;
+ font-size: 14px;
+ font-weight: 600;
+ padding: 5px 8px 7px;
+ border: 0;
+ color: $grey-90;
+ background: $grey-90-10;
+
+ &:focus {
+ @include dark-theme-only {
+ background: $grey-90-70;
+ box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50;
+ }
+
+ background: $grey-90-10;
+ box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50;
+ }
+
+ &:hover {
+ @include dark-theme-only {
+ background: $grey-90-50;
+ }
+
+ background: $grey-90-20;
+ }
+
+ &:active {
+ @include dark-theme-only {
+ background: $grey-90-70;
+ }
+
+ background: $grey-90-30;
+ }
+ }
+
+ .cta-link {
+ @include dark-theme-only {
+ color: $blue-40;
+ fill: $blue-40;
+ }
+
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 24px;
+ height: 24px;
+ width: auto;
+ background-size: auto;
+ background-position: right 1.5px;
+ padding-right: 9px;
+ color: $blue-60;
+ fill: $blue-60;
+
+ &:focus {
+ @include dark-theme-only {
+ box-shadow: 0 0 0 1px $grey-80, 0 0 0 4px $blue-50-50;
+ }
+
+ box-shadow: 0 0 0 1px $white, 0 0 0 4px $blue-50-50;
+ border-radius: 4px;
+ outline: 0;
+ }
+
+ &:active {
+ @include dark-theme-only {
+ color: $blue-50;
+ fill: $blue-50;
+ box-shadow: none;
+ }
+
+ color: $blue-70;
+ fill: $blue-70;
+ box-shadow: none;
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ header {
+ @include dark-theme-only {
+ color: $grey-10;
+ }
+
+ line-height: $header-line-height * 1px;
+ font-size: $header-font-size * 1px;
+ color: $grey-90;
+ }
+
+ p {
+ @include dark-theme-only {
+ color: $grey-10;
+ }
+
+ font-size: $excerpt-font-size * 1px;
+ line-height: $excerpt-line-height * 1px;
+ color: $grey-90;
+ margin: 0;
+ }
+}
+
+// Special styling for the New Tab Experience styles,
+// This is to be incorporated once the styles are made permanent
+.outer-wrapper.newtab-experience {
+ .ds-card {
+ // Temporary fix to have the context button focus blend in with other New Tab Experience context menu focus
+ .context-menu-button {
+ &:is(:active, :focus) {
+ outline: 0;
+ fill: var(--newtab-primary-action-background);
+ border: 1px solid var(--newtab-primary-action-background);
+ }
+ }
+
+ .ds-card-link {
+ &:focus {
+ @include ds-focus-nte;
+
+ transition: none;
+
+ header {
+ @include dark-theme-only {
+ color: $blue-40;
+ }
+
+ color: $blue-60;
+ }
+ }
+ }
+ }
+}
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..0da08d3664
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx
@@ -0,0 +1,85 @@
+/* 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 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 }) => {
+ const classList = "story-sponsored-label 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() {
+ // display_engagement_labels is based on pref `browser.newtabpage.activity-stream.discoverystream.engagementLabelEnabled`
+ const {
+ context,
+ context_type,
+ engagement,
+ display_engagement_labels,
+ sponsor,
+ sponsored_by_override,
+ } = this.props;
+ const { icon, fluentID } = cardContextTypes[context_type] || {};
+
+ return (
+ <div className="story-footer">
+ {SponsorLabel({ sponsored_by_override, sponsor, context })}
+ <TransitionGroup component={null}>
+ {!context &&
+ (context_type || (display_engagement_labels && engagement)) && (
+ <CSSTransition
+ key={fluentID}
+ timeout={ANIMATION_DURATION}
+ classNames="story-animate"
+ >
+ {engagement && !context_type ? (
+ <div className="story-view-count">{engagement}</div>
+ ) : (
+ <StatusMessage icon={icon} fluentID={fluentID} />
+ )}
+ </CSSTransition>
+ )}
+ </TransitionGroup>
+ </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..4c4aa7b93e
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss
@@ -0,0 +1,109 @@
+$status-green: #058B00;
+$status-dark-green: #7C6;
+
+.story-footer {
+ color: var(--newtab-text-secondary-color);
+ inset-inline-start: 0;
+ margin-top: 12px;
+ position: relative;
+
+ .story-sponsored-label,
+ .story-view-count,
+ .status-message {
+ @include dark-theme-only {
+ color: $grey-40;
+ }
+
+ -webkit-line-clamp: 1;
+ font-size: 13px;
+ line-height: 24px;
+ color: $grey-50;
+ }
+
+ .status-message {
+ display: flex;
+ align-items: center;
+ height: 24px;
+
+ .story-badge-icon {
+ @include dark-theme-only {
+ fill: $grey-40;
+ }
+
+ fill: $grey-50;
+ height: 16px;
+ margin-inline-end: 6px;
+
+ &.icon-bookmark-removed {
+ background-image: url('#{$image-path}icon-removed-bookmark.svg');
+ }
+ }
+
+ .story-context-label {
+ @include dark-theme-only {
+ color: $grey-40;
+ }
+
+ color: $grey-50;
+ 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 {
+ @include dark-theme-only {
+ animation: dark-color 3s ease-out 0.3s;
+ }
+
+ animation: color 3s ease-out 0.3s;
+
+ @keyframes color {
+ 0% {
+ color: $status-green;
+ fill: $status-green;
+ }
+
+ 100% {
+ color: $grey-50;
+ fill: $grey-50;
+ }
+ }
+
+ @keyframes dark-color {
+ 0% {
+ color: $status-dark-green;
+ fill: $status-dark-green;
+ }
+
+ 100% {
+ color: $grey-40;
+ fill: $grey-40;
+ }
+ }
+ }
+}
+
+.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..1d28be53a9
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
@@ -0,0 +1,68 @@
+.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 {
+ @include dark-theme-only {
+ fill: $grey-20;
+ }
+
+ fill: $grey-50;
+ }
+
+ &:hover {
+ @include dark-theme-only {
+ background: $grey-90-50;
+
+ .icon {
+ fill: $grey-10;
+ }
+ }
+
+ background: $grey-90-20;
+
+ .icon {
+ fill: $grey-80;
+ }
+ }
+
+ &:active {
+ @include dark-theme-only {
+ background: $grey-90-70;
+ }
+
+ background: $grey-90-30;
+ }
+
+ &:focus {
+ @include dark-theme-only {
+ box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50;
+ }
+
+ box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50;
+ }
+ }
+}
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..2f3822b825
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx
@@ -0,0 +1,97 @@
+/* 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.jsm";
+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..bc66424a5a
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss
@@ -0,0 +1,87 @@
+.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-feed-button-background);
+ color: var(--newtab-feed-button-text);
+ 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($grey-30);
+
+ @include dark-theme-only {
+ @include ds-fade-in($grey-60);
+ }
+ }
+ }
+
+ &::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-feed-button-spinner);
+ }
+
+ &.waiting {
+ cursor: initial;
+ background: var(--newtab-feed-button-background-faded);
+ color: var(--newtab-feed-button-text-faded);
+ 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..919a110fbc
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
@@ -0,0 +1,157 @@
+/* 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 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.state.nonOptimizedImageFailed) {
+ img = (
+ <img
+ loading="lazy"
+ alt={this.props.alt_text}
+ crossOrigin="anonymous"
+ onLoad={this.onLoad}
+ onError={this.onNonOptimizedImageError}
+ src={this.props.source}
+ />
+ );
+ } else {
+ // Remove the img element if both sources fail. Render a placeholder instead.
+ 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..03063c0cf8
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
@@ -0,0 +1,23 @@
+.ds-image {
+ display: block;
+ position: relative;
+ opacity: 0;
+
+ &.use-transition {
+ transition: opacity 0.8s;
+ }
+
+ &.loaded {
+ opacity: 1;
+ }
+
+ img,
+ .broken-image {
+ background-color: var(--newtab-card-placeholder-color);
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
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..469857f6e3
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx
@@ -0,0 +1,90 @@
+/* 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 React from "react";
+
+export class DSLinkMenu extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onMenuUpdate = this.onMenuUpdate.bind(this);
+ this.onMenuShow = this.onMenuShow.bind(this);
+ this.contextMenuButtonRef = React.createRef();
+ }
+
+ onMenuUpdate(showContextMenu) {
+ if (!showContextMenu) {
+ const dsLinkMenuHostDiv = this.contextMenuButtonRef.current.parentElement;
+ dsLinkMenuHostDiv.parentElement.classList.remove("active", "last-item");
+ }
+ }
+
+ nextAnimationFrame() {
+ return new Promise(resolve =>
+ this.props.windowObj.requestAnimationFrame(resolve)
+ );
+ }
+
+ async onMenuShow() {
+ const dsLinkMenuHostDiv = this.contextMenuButtonRef.current.parentElement;
+ // Wait for next frame before computing scrollMaxX to allow fluent menu strings to be visible
+ await this.nextAnimationFrame();
+ if (this.props.windowObj.scrollMaxX > 0) {
+ dsLinkMenuHostDiv.parentElement.classList.add("last-item");
+ }
+ dsLinkMenuHostDiv.parentElement.classList.add("active");
+ }
+
+ render() {
+ const { index, dispatch } = this.props;
+ const TOP_STORIES_CONTEXT_MENU_OPTIONS = [
+ "CheckBookmarkOrArchive",
+ "CheckSavedToPocket",
+ "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>
+ <ContextMenuButton
+ refFunction={this.contextMenuButtonRef}
+ tooltip={"newtab-menu-content-tooltip"}
+ tooltipArgs={{ title }}
+ onUpdate={this.onMenuUpdate}
+ >
+ <LinkMenu
+ dispatch={dispatch}
+ index={index}
+ source={type.toUpperCase()}
+ onShow={this.onMenuShow}
+ options={TOP_STORIES_CONTEXT_MENU_OPTIONS}
+ shouldSendImpressionStats={true}
+ 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>
+ );
+ }
+}
+
+DSLinkMenu.defaultProps = {
+ windowObj: window, // Added to support unit tests
+};
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..6e05e4ef9f
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss
@@ -0,0 +1,33 @@
+.ds-hero-item,
+.ds-list-item,
+.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;
+
+ &.ds-card-grid-border {
+ @include fade-in-card;
+ }
+ }
+}
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..41b4c3863b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss
@@ -0,0 +1,45 @@
+.ds-message {
+ margin: 8px 0 0;
+
+ .title {
+ display: flex;
+ align-items: center;
+
+ .glyph {
+ @include dark-theme-only {
+ fill: $grey-30;
+ }
+
+ width: 16px;
+ height: 16px;
+ margin: 0 6px 0 0;
+ -moz-context-properties: fill;
+ fill: $grey-50;
+ background-position: center center;
+ background-size: 16px;
+ background-repeat: no-repeat;
+ }
+
+ .title-text {
+ @include dark-theme-only {
+ color: $grey-30;
+ }
+
+ line-height: 20px;
+ font-size: 13px;
+ color: $grey-50;
+ 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..56bb4cc580
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
@@ -0,0 +1,69 @@
+/* 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.jsm";
+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.UserEvent({
+ 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..d7dceac6e5
--- /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: $blue-60;
+ width: max-content;
+
+ &:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ }
+
+ &::before {
+ -moz-context-properties: fill;
+ fill: $blue-60;
+ 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://activity-stream/content/data/content/assets/glyph-info-16.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..5ae2df57c2
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx
@@ -0,0 +1,167 @@
+/* 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.jsm";
+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.UserEvent({
+ 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}
+ 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..758076f168
--- /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: #{$grey-40};
+ 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..a0040ac650
--- /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.jsm";
+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.UserEvent({
+ 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.UserEvent({
+ 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..6dea98d802
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
@@ -0,0 +1,119 @@
+.ds-dismiss-ds-text-promo {
+ max-width: 744px;
+ margin: auto;
+ overflow: hidden;
+
+ &.hovering {
+ @include dark-theme-only {
+ background: $grey-90-30;
+ }
+
+ background: $grey-90-10;
+ }
+
+ .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 {
+ @include dark-theme-only {
+ color: $grey-10;
+ }
+
+ margin: 0;
+ font-weight: 600;
+ font-size: 15px;
+ }
+
+ .subtitle {
+ @include dark-theme-only {
+ color: $grey-40;
+ }
+
+ font-size: 13px;
+ margin: 0;
+ color: $grey-50;
+ }
+}
+
+.ds-chevron-link {
+ color: $blue-60;
+ display: inline-block;
+ outline: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ @include dark-theme-only {
+ color: $blue-50;
+ }
+
+ color: $blue-70;
+
+ &::after {
+ @include dark-theme-only {
+ background-color: $blue-50;
+ }
+
+ background-color: $blue-70;
+ }
+ }
+
+ &:focus {
+ @include dark-theme-only {
+ box-shadow: 0 0 0 2px $grey-80, 0 0 0 5px $blue-50-50;
+ }
+
+ box-shadow: 0 0 0 2px $white, 0 0 0 5px $blue-50-50;
+ border-radius: 2px;
+ }
+
+ &::after {
+ @include dark-theme-only {
+ background-color: $blue-40;
+ }
+
+ content: ' ';
+ mask: url('chrome://activity-stream/content/data/content/assets/glyph-caret-right.svg') 0 -8px no-repeat;
+ background-color: $blue-60;
+ 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/Hero/Hero.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
new file mode 100644
index 0000000000..eaa9ed20c5
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx
@@ -0,0 +1,207 @@
+/* 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 { actionCreators as ac } from "common/Actions.jsm";
+import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
+import { DSImage } from "../DSImage/DSImage.jsx";
+import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import { List } from "../List/List.jsx";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { DSContextFooter } from "../DSContextFooter/DSContextFooter.jsx";
+
+export class Hero extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onLinkClick = this.onLinkClick.bind(this);
+ }
+
+ onLinkClick(event) {
+ if (this.props.dispatch) {
+ this.props.dispatch(
+ ac.UserEvent({
+ event: "CLICK",
+ source: this.props.type.toUpperCase(),
+ action_position: this.heroRec.pos,
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source: this.props.type.toUpperCase(),
+ click: 0,
+ tiles: [
+ {
+ id: this.heroRec.id,
+ pos: this.heroRec.pos,
+ ...(this.heroRec.shim && this.heroRec.shim.click
+ ? { shim: this.heroRec.shim.click }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ renderHero() {
+ let [heroRec, ...otherRecs] = this.props.data.recommendations.slice(
+ 0,
+ this.props.items
+ );
+ this.heroRec = heroRec;
+
+ const cards = [];
+ for (let index = 0; index < this.props.items - 1; index++) {
+ const rec = otherRecs[index];
+ cards.push(
+ !rec || rec.placeholder ? (
+ <PlaceholderDSCard key={`dscard-${index}`} />
+ ) : (
+ <DSCard
+ flightId={rec.flight_id}
+ key={`dscard-${rec.id}`}
+ image_src={rec.image_src}
+ raw_image_src={rec.raw_image_src}
+ title={rec.title}
+ url={rec.url}
+ id={rec.id}
+ shim={rec.shim}
+ pos={rec.pos}
+ type={this.props.type}
+ dispatch={this.props.dispatch}
+ context={rec.context}
+ context_type={rec.context_type}
+ source={rec.domain}
+ pocket_id={rec.pocket_id}
+ bookmarkGuid={rec.bookmarkGuid}
+ engagement={rec.engagement}
+ />
+ )
+ );
+ }
+
+ let heroCard = null;
+
+ if (!heroRec || heroRec.placeholder) {
+ heroCard = <PlaceholderDSCard />;
+ } else {
+ heroCard = (
+ <div className="ds-hero-item" key={`dscard-${heroRec.id}`}>
+ <SafeAnchor
+ className="wrapper"
+ dispatch={this.props.dispatch}
+ onLinkClick={this.onLinkClick}
+ url={heroRec.url}
+ >
+ <div className="img-wrapper">
+ <DSImage
+ extraClassNames="img"
+ source={heroRec.image_src}
+ rawSource={heroRec.raw_image_src}
+ />
+ </div>
+ <div className="meta">
+ <div className="header-and-excerpt">
+ <p className="source clamp">{heroRec.domain}</p>
+ <header className="clamp">{heroRec.title}</header>
+ <p className="excerpt clamp">{heroRec.excerpt}</p>
+ </div>
+ <DSContextFooter
+ context={heroRec.context}
+ context_type={heroRec.context_type}
+ engagement={heroRec.engagement}
+ />
+ </div>
+ <ImpressionStats
+ flightId={heroRec.flight_id}
+ rows={[
+ {
+ id: heroRec.id,
+ pos: heroRec.pos,
+ ...(heroRec.shim && heroRec.shim.impression
+ ? { shim: heroRec.shim.impression }
+ : {}),
+ },
+ ]}
+ dispatch={this.props.dispatch}
+ source={this.props.type}
+ />
+ </SafeAnchor>
+ <DSLinkMenu
+ id={heroRec.id}
+ index={heroRec.pos}
+ dispatch={this.props.dispatch}
+ url={heroRec.url}
+ title={heroRec.title}
+ source={heroRec.domain}
+ type={this.props.type}
+ pocket_id={heroRec.pocket_id}
+ shim={heroRec.shim}
+ bookmarkGuid={heroRec.bookmarkGuid}
+ flightId={heroRec.flight_id}
+ />
+ </div>
+ );
+ }
+
+ let list = (
+ <List
+ recStartingPoint={1}
+ data={this.props.data}
+ feed={this.props.feed}
+ hasImages={true}
+ hasBorders={this.props.border === `border`}
+ items={this.props.items - 1}
+ type={`Hero`}
+ />
+ );
+
+ return (
+ <div className={`ds-hero ds-hero-${this.props.border}`}>
+ {heroCard}
+ <div className={`${this.props.subComponentType}`}>
+ {this.props.subComponentType === `cards` ? cards : list}
+ </div>
+ </div>
+ );
+ }
+
+ render() {
+ const { data } = this.props;
+
+ // Handle a render before feed has been fetched by displaying nothing
+ if (!data || !data.recommendations) {
+ return <div />;
+ }
+
+ // Handle the case where a user has dismissed all recommendations
+ const isEmpty = data.recommendations.length === 0;
+
+ return (
+ <div>
+ <div className="ds-header">{this.props.title}</div>
+ {isEmpty ? (
+ <div className="ds-hero empty">
+ <DSEmptyState
+ status={data.status}
+ dispatch={this.props.dispatch}
+ feed={this.props.feed}
+ />
+ </div>
+ ) : (
+ this.renderHero()
+ )}
+ </div>
+ );
+ }
+}
+
+Hero.defaultProps = {
+ data: {},
+ border: `border`,
+ items: 1, // Number of stories to display
+};
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
new file mode 100644
index 0000000000..ed81e42946
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Hero/_Hero.scss
@@ -0,0 +1,282 @@
+$card-header-in-hero-font-size: 14;
+$card-header-in-hero-line-height: 20;
+
+.ds-hero {
+ position: relative;
+
+ header {
+ font-weight: 600;
+ }
+
+ p {
+ line-height: 1.538;
+ margin: 8px 0;
+ }
+
+ .excerpt {
+ @include limit-visible-lines(3, 24, 15);
+ @include dark-theme-only {
+ color: $grey-10;
+ }
+
+ color: $grey-90;
+ margin: 0 0 10px;
+ }
+
+ .ds-card:not(.placeholder) {
+ border: 0;
+ padding-bottom: 20px;
+
+ &:hover {
+ border: 0;
+ box-shadow: none;
+ border-radius: 0;
+ }
+
+ .meta {
+ padding: 0;
+ }
+
+ .img-wrapper {
+ margin: 0 0 12px;
+ }
+ }
+
+ .ds-card.placeholder {
+ margin-bottom: 20px;
+ padding-bottom: 20px;
+ min-height: 180px;
+ }
+
+ .img-wrapper {
+ margin: 0 0 12px;
+ }
+
+ .ds-hero-item {
+ position: relative;
+ }
+
+ // "1/3 width layout" (aka "Mobile First")
+ .wrapper {
+ @include ds-border-top;
+ @include dark-theme-only {
+ color: $grey-30;
+ }
+
+ color: $grey-50;
+ display: block;
+ margin: 12px 0 16px;
+ padding-top: 16px;
+ height: 100%;
+
+ &:focus {
+ @include ds-fade-in;
+ }
+
+ @at-root .ds-hero-no-border .ds-hero-item .wrapper {
+ border-top: 0;
+ border-bottom: 0;
+ padding: 0 0 8px;
+ }
+
+ &:hover .meta header {
+ @include dark-theme-only {
+ color: $blue-40;
+ }
+
+ color: $blue-60;
+ }
+
+ &:active .meta header {
+ @include dark-theme-only {
+ color: $blue-40;
+ }
+
+ color: $blue-70;
+ }
+
+ .img-wrapper {
+ width: 100%;
+ }
+
+ .img {
+ height: 0;
+ padding-top: 50%; // 2:1 aspect ratio
+
+ img {
+ border-radius: 4px;
+ box-shadow: inset 0 0 0 0.5px $black-15;
+ }
+ }
+
+ .meta {
+ display: block;
+ flex-direction: column;
+ justify-content: space-between;
+
+ .header-and-excerpt {
+ flex: 1;
+ }
+
+ header {
+ @include dark-theme-only {
+ color: $white;
+ }
+
+ @include limit-visible-lines(4, 28, 22);
+ color: $grey-90;
+ margin-bottom: 0;
+ }
+
+ .context,
+ .source {
+ margin: 0 0 4px;
+ }
+
+ .context {
+ @include dark-theme-only {
+ color: $teal-10;
+ }
+
+ color: $teal-70;
+ }
+
+ .source {
+ @include dark-theme-only {
+ color: $grey-40;
+ }
+
+ font-size: 13px;
+ color: $grey-50;
+ -webkit-line-clamp: 1;
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ // "2/3 width layout"
+ .ds-column-5 &,
+ .ds-column-6 &,
+ .ds-column-7 &,
+ .ds-column-8 & {
+ .wrapper {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-column-gap: 24px;
+
+ .img-wrapper {
+ margin: 0;
+ grid-column: 2;
+ grid-row: 1;
+ }
+
+ .meta {
+ grid-column: 1;
+ grid-row: 1;
+ display: flex;
+ }
+
+ .img {
+ height: 0;
+ padding-top: 100%; // 1:1 aspect ratio
+ }
+ }
+
+ .cards {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-column-gap: 24px;
+ grid-auto-rows: min-content;
+ }
+ }
+
+ // "Full width layout"
+ .ds-column-9 &,
+ .ds-column-10 &,
+ .ds-column-11 &,
+ .ds-column-12 & {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-column-gap: 24px;
+
+ &.ds-hero-border {
+ @include ds-border-top;
+ padding: 20px 0;
+
+ .ds-card:not(.placeholder):nth-child(-n+2) {
+ @include ds-border-bottom;
+ margin-bottom: 20px;
+ }
+ }
+
+ .wrapper {
+ border-top: 0;
+ border-bottom: 0;
+ margin: 0;
+ padding: 0 0 20px;
+ display: flex;
+ flex-direction: column;
+
+ .img-wrapper {
+ margin: 0;
+ }
+
+ .img {
+ margin-bottom: 12px;
+ height: 0;
+ padding-top: 50%; // 2:1 aspect ratio
+ }
+
+ .meta {
+ flex-grow: 1;
+ display: flex;
+ padding: 0 24px 0 0;
+
+ header {
+ @include limit-visible-lines(3, 28, 22);
+ }
+
+ .source {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .cards {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-column-gap: 24px;
+ grid-auto-rows: min-content;
+
+ .ds-card {
+ &:hover {
+ @include dark-theme-only {
+ background: none;
+
+ .title {
+ color: $blue-40;
+ }
+ }
+ }
+
+ &:active .title {
+ @include dark-theme-only {
+ color: $blue-50;
+ }
+ }
+
+ .title {
+ @include dark-theme-only {
+ color: $white;
+ }
+
+ @include limit-visible-lines(3, 20, 14);
+ }
+ }
+ }
+ }
+
+ &.empty {
+ grid-template-columns: auto;
+ }
+}
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..bf0a0557da
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss
@@ -0,0 +1,40 @@
+.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; // sass-lint:disable-line no-disallowed-properties
+ }
+
+ .card-title {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: $line-height;
+ max-height: $line-height;
+ }
+ }
+ }
+ }
+
+ .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/List/List.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
new file mode 100644
index 0000000000..057d507099
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/List.jsx
@@ -0,0 +1,221 @@
+/* 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.jsm";
+import { connect } from "react-redux";
+import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
+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 } from "../DSContextFooter/DSContextFooter.jsx";
+
+/**
+ * @note exported for testing only
+ */
+export class ListItem extends React.PureComponent {
+ // TODO performance: get feeds to send appropriately sized images rather
+ // than waiting longer and scaling down on client?
+ constructor(props) {
+ super(props);
+ this.onLinkClick = this.onLinkClick.bind(this);
+ }
+
+ onLinkClick(event) {
+ if (this.props.dispatch) {
+ this.props.dispatch(
+ ac.UserEvent({
+ 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,
+ tiles: [
+ {
+ id: this.props.id,
+ pos: this.props.pos,
+ ...(this.props.shim && this.props.shim.click
+ ? { shim: this.props.shim.click }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ render() {
+ return (
+ <li
+ className={`ds-list-item${
+ this.props.placeholder ? " placeholder" : ""
+ }`}
+ >
+ <SafeAnchor
+ className="ds-list-item-link"
+ dispatch={this.props.dispatch}
+ onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined}
+ url={this.props.url}
+ >
+ <div className="ds-list-item-text">
+ <p>
+ <span className="ds-list-item-info clamp">
+ {this.props.domain}
+ </span>
+ </p>
+ <div className="ds-list-item-body">
+ <div className="ds-list-item-title clamp">{this.props.title}</div>
+ {this.props.excerpt && (
+ <div className="ds-list-item-excerpt clamp">
+ {this.props.excerpt}
+ </div>
+ )}
+ </div>
+ <DSContextFooter
+ context={this.props.context}
+ context_type={this.props.context_type}
+ engagement={this.props.engagement}
+ />
+ </div>
+ <DSImage
+ extraClassNames="ds-list-image"
+ source={this.props.image_src}
+ rawSource={this.props.raw_image_src}
+ />
+ <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>
+ {!this.props.placeholder && (
+ <DSLinkMenu
+ id={this.props.id}
+ index={this.props.pos}
+ dispatch={this.props.dispatch}
+ url={this.props.url}
+ title={this.props.title}
+ source={this.props.source}
+ type={this.props.type}
+ pocket_id={this.props.pocket_id}
+ shim={this.props.shim}
+ bookmarkGuid={this.props.bookmarkGuid}
+ flightId={this.props.flightId}
+ />
+ )}
+ </li>
+ );
+ }
+}
+
+export const PlaceholderListItem = props => <ListItem placeholder={true} />;
+
+/**
+ * @note exported for testing only
+ */
+export function _List(props) {
+ const renderList = () => {
+ const recs = props.data.recommendations.slice(
+ props.recStartingPoint,
+ props.recStartingPoint + props.items
+ );
+ const recMarkup = [];
+
+ for (let index = 0; index < props.items; index++) {
+ const rec = recs[index];
+ recMarkup.push(
+ !rec || rec.placeholder ? (
+ <PlaceholderListItem key={`ds-list-item-${index}`} />
+ ) : (
+ <ListItem
+ key={`ds-list-item-${rec.id}`}
+ dispatch={props.dispatch}
+ flightId={rec.flight_id}
+ domain={rec.domain}
+ excerpt={rec.excerpt}
+ id={rec.id}
+ shim={rec.shim}
+ image_src={rec.image_src}
+ raw_image_src={rec.raw_image_src}
+ pos={rec.pos}
+ title={rec.title}
+ context={rec.context}
+ context_type={rec.context_type}
+ type={props.type}
+ url={rec.url}
+ pocket_id={rec.pocket_id}
+ bookmarkGuid={rec.bookmarkGuid}
+ engagement={rec.engagement}
+ />
+ )
+ );
+ }
+
+ const listStyles = [
+ "ds-list",
+ props.fullWidth ? "ds-list-full-width" : "",
+ props.hasBorders ? "ds-list-borders" : "",
+ props.hasImages ? "ds-list-images" : "",
+ props.hasNumbers ? "ds-list-numbers" : "",
+ ];
+
+ return <ul className={listStyles.join(" ")}>{recMarkup}</ul>;
+ };
+
+ const { data } = props;
+ if (!data || !data.recommendations) {
+ return null;
+ }
+
+ // Handle the case where a user has dismissed all recommendations
+ const isEmpty = data.recommendations.length === 0;
+
+ return (
+ <div>
+ {props.header && props.header.title ? (
+ <div className="ds-header">{props.header.title}</div>
+ ) : null}
+ {isEmpty ? (
+ <div className="ds-list empty">
+ <DSEmptyState
+ status={data.status}
+ dispatch={props.dispatch}
+ feed={props.feed}
+ />
+ </div>
+ ) : (
+ renderList()
+ )}
+ </div>
+ );
+}
+
+_List.defaultProps = {
+ recStartingPoint: 0, // Index of recommendations to start displaying from
+ fullWidth: false, // Display items taking up the whole column
+ hasBorders: false, // Display lines separating each item
+ hasImages: false, // Display images for each item
+ hasNumbers: false, // Display numbers for each item
+ items: 6, // Number of stories to display. TODO: get from endpoint
+};
+
+export const List = connect(state => ({
+ DiscoveryStream: state.DiscoveryStream,
+}))(_List);
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
new file mode 100644
index 0000000000..6841ed4a46
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/List/_List.scss
@@ -0,0 +1,269 @@
+// Type sizes
+$bordered-spacing: 16px;
+$item-font-size: 14;
+$item-image-size: 80px;
+$item-line-height: 20;
+
+// XXX this is gross, and attaches the bottom-border to the item above.
+// Ideally, we'd attach the top-border to the item that needs it.
+// Unfortunately the border needs to go _above_ the row gap as currently
+// set up, which means that some refactoring will be required to do this.
+@mixin bottom-border-except-last-grid-row($columns) {
+ .ds-list-item:not(.placeholder):not(:nth-last-child(-n+#{$columns})) {
+ @include ds-border-bottom;
+ margin-bottom: -1px; // cancel out the pixel we used for the border
+ padding-bottom: $bordered-spacing;
+ }
+}
+
+@mixin set-item-sizes($font-size, $line-height, $image-size) {
+ .ds-list-item {
+ // XXX see if we really want absolute units, maybe hoist somewhere central?
+ font-size: $font-size * 1px;
+ line-height: $line-height * 1px;
+ position: relative;
+ }
+
+ .ds-list-item-title {
+ @include limit-visible-lines(3, $line-height, $font-size);
+ }
+
+ .ds-list-image {
+ min-width: $image-size;
+ width: $image-size;
+ }
+}
+
+.ds-list {
+ display: grid;
+ grid-row-gap: 24px;
+ grid-column-gap: 24px;
+
+ // reset some stuff from <ul>. Should maybe be hoisted when we have better
+ // regression detection?
+ padding-inline-start: 0;
+
+ &:not(.ds-list-full-width) {
+ @include set-item-sizes($item-font-size, $item-line-height, $item-image-size);
+
+ // "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(3, 1fr);
+ }
+
+ &.empty {
+ grid-template-columns: auto;
+ }
+
+ .ds-list-item-excerpt {
+ display: none;
+ }
+ }
+
+ &:not(.ds-list-images) {
+ .ds-list-image {
+ display: none;
+ }
+ }
+
+ a {
+ @include dark-theme-only {
+ color: $grey-10;
+ }
+
+ color: $grey-90;
+ }
+}
+
+.ds-list-item-link:focus {
+ @include ds-fade-in;
+}
+
+.ds-list-numbers {
+ $counter-whitespace: ($item-line-height - $item-font-size) * 1px;
+ $counter-size: 32px;
+ $counter-padded-size: $counter-size + $counter-whitespace * 1.5;
+
+ .ds-list-item {
+ counter-increment: list;
+ }
+
+ .ds-list-item:not(.placeholder) > .ds-list-item-link {
+ padding-inline-start: $counter-padded-size;
+
+ &::before {
+ @include dark-theme-only {
+ background-color: $teal-70;
+ }
+
+ background-color: $pocket-teal;
+ border-radius: $counter-size;
+ color: $white;
+ content: counter(list);
+ font-size: 17px;
+ height: $counter-size;
+ line-height: $counter-size;
+ margin-inline-start: -$counter-padded-size;
+ margin-top: $counter-whitespace / 2;
+ position: absolute;
+ text-align: center;
+ width: $counter-size;
+ }
+
+ &:hover::before {
+ @include dark-theme-only {
+ background-color: $blue-40;
+ }
+
+ background-color: $blue-40;
+ }
+
+ &:active::before {
+ @include dark-theme-only {
+ background-color: $blue-60;
+ }
+
+ background-color: $blue-70;
+ }
+ }
+}
+
+.ds-list-borders {
+ @include ds-border-top;
+ grid-row-gap: $bordered-spacing;
+ padding-top: $bordered-spacing;
+
+ &.ds-list-full-width,
+ .ds-column-1 &,
+ .ds-column-2 &,
+ .ds-column-3 &,
+ .ds-column-4 & {
+ @include bottom-border-except-last-grid-row(1);
+ }
+
+ &:not(.ds-list-full-width) {
+ // "2/3 width layout"
+ .ds-column-5 &,
+ .ds-column-6 &,
+ .ds-column-7 &,
+ .ds-column-8 & {
+ @include bottom-border-except-last-grid-row(2);
+ }
+
+ // "Full width layout"
+ .ds-column-9 &,
+ .ds-column-10 &,
+ .ds-column-11 &,
+ .ds-column-12 & {
+ @include bottom-border-except-last-grid-row(3);
+ }
+ }
+}
+
+.ds-list-full-width {
+ @include set-item-sizes(17, 24, $item-image-size * 2);
+}
+
+.ds-list-item {
+ // reset some stuff from <li>. Should maybe be hoisted when we have better
+ // regression detection?
+ display: block;
+ text-align: start;
+
+ &.placeholder {
+ background: transparent;
+ min-height: $item-image-size;
+ box-shadow: inset $inner-box-shadow;
+ border-radius: 4px;
+
+ .ds-list-item-link {
+ cursor: default;
+ }
+
+ .ds-list-image {
+ opacity: 0;
+ }
+ }
+
+ .ds-list-item-link {
+ mix-blend-mode: normal;
+
+ display: flex;
+ justify-content: space-between;
+ height: 100%;
+ }
+
+ .ds-list-item-excerpt {
+ @include limit-visible-lines(2, $item-line-height, $item-font-size);
+ @include dark-theme-only {
+ color: $grey-10-80;
+ }
+ color: $grey-50;
+ margin: 4px 0 8px;
+ }
+
+ p {
+ font-size: $item-font-size * 1px;
+ line-height: $item-line-height * 1px;
+ margin: 0;
+ }
+
+ .ds-list-item-info {
+ @include limit-visible-lines(1, $item-line-height, $item-font-size);
+ @include dark-theme-only {
+ color: $grey-40;
+ }
+
+ color: $grey-50;
+ font-size: 13px;
+ }
+
+ .ds-list-item-title {
+ font-weight: 600;
+ margin-bottom: 4px;
+ }
+
+ .ds-list-item-body {
+ flex: 1;
+ }
+
+ .ds-list-item-text {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ }
+
+ .ds-list-image {
+ height: $item-image-size;
+ margin-inline-start: $item-font-size * 1px;
+ min-height: $item-image-size;
+
+ img {
+ border-radius: 4px;
+ box-shadow: inset 0 0 0 0.5px $black-15;
+ }
+ }
+
+ &:hover {
+ .ds-list-item-title {
+ color: $blue-40;
+ }
+ }
+
+ &:active {
+ .ds-list-item-title {
+ color: $blue-70;
+ }
+ }
+}
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..45445bf889
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx
@@ -0,0 +1,73 @@
+/* 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.jsm";
+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.UserEvent({
+ 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() {
+ const links = this.props.links || [];
+ const alignment = this.props.alignment || "centered";
+ const header = this.props.header || {};
+ return (
+ <div className={`ds-navigation ds-navigation-${alignment}`}>
+ {header.title ? (
+ <FluentOrText message={header.title}>
+ <span className="ds-navigation-header" />
+ </FluentOrText>
+ ) : null}
+ <ul>
+ {links &&
+ links.map(t => (
+ <li key={t.name}>
+ <Topic
+ url={t.url}
+ name={t.name}
+ dispatch={this.props.dispatch}
+ />
+ </li>
+ ))}
+ </ul>
+ </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..c48b36281b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss
@@ -0,0 +1,64 @@
+.outer-wrapper.newtab-experience {
+ .ds-navigation {
+ color: var(--newtab-background-primary-text-color);
+ }
+}
+
+.ds-navigation {
+ @include dark-theme-only {
+ color: $grey-30;
+ }
+
+ color: $grey-50;
+ padding: 4px 0;
+ font-weight: 600;
+ line-height: 22px;
+ font-size: 11.5px;
+
+ @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: underline;
+ }
+
+ &:active {
+ color: $blue-70;
+ }
+ }
+ }
+
+ .ds-navigation-header {
+ padding-inline-end: 6px;
+ }
+}
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..5a65a50bb8
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx
@@ -0,0 +1,62 @@
+/* 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.jsm";
+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..317bb29466
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss
@@ -0,0 +1,26 @@
+.ds-section-title {
+ text-align: center;
+ margin-top: 24px;
+
+ .title {
+ @include dark-theme-only {
+ color: $white;
+ }
+
+ line-height: 48px;
+ font-size: 36px;
+ font-weight: 300;
+ color: $grey-90;
+ }
+
+ .subtitle {
+ @include dark-theme-only {
+ color: $grey-30;
+ }
+
+ line-height: 24px;
+ font-size: 14px;
+ color: $grey-50;
+ margin-top: 4px;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx
new file mode 100644
index 0000000000..bcc388698f
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/TopSites.jsx
@@ -0,0 +1,157 @@
+/* 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 { TopSites as OldTopSites } from "content-src/components/TopSites/TopSites";
+import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.jsm";
+import React from "react";
+
+export class _TopSites extends React.PureComponent {
+ // Find a SPOC that doesn't already exist in User's TopSites
+ getFirstAvailableSpoc(topSites, data) {
+ const { spocs } = data;
+ if (!spocs || spocs.length === 0) {
+ return null;
+ }
+
+ const userTopSites = new Set(
+ topSites.map(topSite => topSite && topSite.url)
+ );
+
+ // We "clean urls" with http in TopSiteForm.jsx
+ // Spoc domains are in the format 'sponsorname.com'
+ return spocs.find(
+ spoc =>
+ !userTopSites.has(spoc.url) &&
+ !userTopSites.has(`http://${spoc.domain}`) &&
+ !userTopSites.has(`https://${spoc.domain}`) &&
+ !userTopSites.has(`http://www.${spoc.domain}`) &&
+ !userTopSites.has(`https://www.${spoc.domain}`)
+ );
+ }
+
+ // Find the first empty or unpinned index we can place the SPOC in.
+ // Return -1 if no available index and we should push it at the end.
+ getFirstAvailableIndex(topSites, promoAlignment) {
+ if (promoAlignment === "left") {
+ return topSites.findIndex(topSite => !topSite || !topSite.isPinned);
+ }
+
+ // The row isn't full so we can push it to the end of the row.
+ if (topSites.length < TOP_SITES_MAX_SITES_PER_ROW) {
+ return -1;
+ }
+
+ // If the row is full, we can check the row first for unpinned topsites to replace.
+ // Else we can check after the row. This behavior is how unpinned topsites move while drag and drop.
+ let endOfRow = TOP_SITES_MAX_SITES_PER_ROW - 1;
+ for (let i = endOfRow; i >= 0; i--) {
+ if (!topSites[i] || !topSites[i].isPinned) {
+ return i;
+ }
+ }
+
+ for (let i = endOfRow + 1; i < topSites.length; i++) {
+ if (!topSites[i] || !topSites[i].isPinned) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ insertSpocContent(TopSites, data, promoAlignment) {
+ if (
+ !TopSites.rows ||
+ TopSites.rows.length === 0 ||
+ !data.spocs ||
+ data.spocs.length === 0
+ ) {
+ return null;
+ }
+
+ let topSites = [...TopSites.rows];
+ const topSiteSpoc = this.getFirstAvailableSpoc(topSites, data);
+
+ if (!topSiteSpoc) {
+ return null;
+ }
+
+ const link = {
+ customScreenshotURL: topSiteSpoc.image_src,
+ type: "SPOC",
+ label: topSiteSpoc.sponsor,
+ title: topSiteSpoc.sponsor,
+ url: topSiteSpoc.url,
+ flightId: topSiteSpoc.flight_id,
+ id: topSiteSpoc.id,
+ guid: topSiteSpoc.id,
+ shim: topSiteSpoc.shim,
+ // For now we are assuming position based on intended position.
+ // Actual position can shift based on other content.
+ // We also hard code left and right to be 0 and 7.
+ // We send the intended postion in the ping.
+ pos: promoAlignment === "left" ? 0 : 7,
+ };
+
+ const firstAvailableIndex = this.getFirstAvailableIndex(
+ topSites,
+ promoAlignment
+ );
+
+ if (firstAvailableIndex === -1) {
+ topSites.push(link);
+ } else {
+ // Normal insertion will not work since pinned topsites are in their correct index already
+ // Similar logic is done to handle drag and drop with pinned topsites in TopSite.jsx
+
+ let shiftedTopSite = topSites[firstAvailableIndex];
+ let index = firstAvailableIndex + 1;
+
+ // Shift unpinned topsites to the right by finding the next unpinned topsite to replace
+ while (shiftedTopSite) {
+ if (index === topSites.length) {
+ topSites.push(shiftedTopSite);
+ shiftedTopSite = null;
+ } else if (topSites[index] && topSites[index].isPinned) {
+ index += 1;
+ } else {
+ const nextTopSite = topSites[index];
+ topSites[index] = shiftedTopSite;
+ shiftedTopSite = nextTopSite;
+ index += 1;
+ }
+ }
+
+ topSites[firstAvailableIndex] = link;
+ }
+
+ return { ...TopSites, rows: topSites };
+ }
+
+ render() {
+ const { header = {}, data, promoAlignment, TopSites } = this.props;
+
+ const TopSitesWithSpoc =
+ TopSites && data && promoAlignment
+ ? this.insertSpocContent(TopSites, data, promoAlignment)
+ : null;
+
+ return (
+ <div
+ className={`ds-top-sites ${TopSitesWithSpoc ? "top-sites-spoc" : ""}`}
+ >
+ <OldTopSites
+ isFixed={true}
+ title={header.title}
+ TopSitesWithSpoc={TopSitesWithSpoc}
+ />
+ </div>
+ );
+ }
+}
+
+export const TopSites = connect(state => ({ TopSites: state.TopSites }))(
+ _TopSites
+);
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..b40ce5bb7b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss
@@ -0,0 +1,119 @@
+// ds topsites wraps the original topsites, with a few css changes.
+.outer-wrapper:not(.newtab-experience) {
+ .ds-top-sites {
+ // This is the override layer.
+ .top-sites {
+ .top-site-outer {
+ padding: 0 12px;
+
+ .top-site-inner > a:is(.active, :focus) .tile {
+ @include ds-fade-in;
+
+ @include dark-theme-only {
+ @include ds-fade-in($blue-40-40);
+ }
+ }
+
+ .top-site-inner > a:is(:hover) .tile {
+
+ @include ds-fade-in($grey-30);
+
+ @include dark-theme-only {
+ @include ds-fade-in($grey-60);
+ }
+ }
+ }
+
+ .top-sites-list {
+ margin: 0 -12px;
+ }
+ }
+ }
+}
+
+.outer-wrapper.newtab-experience {
+ .ds-top-sites {
+ // This is the override layer.
+ .top-sites {
+ .top-site-outer {
+ .top-site-inner > a:is(.active, :focus) .tile {
+ @include ds-focus-nte;
+ }
+
+ .top-site-inner > a:is(:hover) .top-site-inner {
+
+ @include ds-fade-in($grey-30);
+
+ @include dark-theme-only {
+ @include ds-fade-in($grey-60);
+ }
+ }
+ }
+
+ .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/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx
new file mode 100644
index 0000000000..cc0082bdfe
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.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/. */
+
+import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
+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 },
+ })
+ );
+ }
+
+ if (this._needsImpressionStats(cards)) {
+ props.dispatch(
+ ac.DiscoveryStreamImpressionStats({
+ source: props.source.toUpperCase(),
+ tiles: cards.map(link => ({
+ id: link.id,
+ pos: link.pos,
+ ...(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..8607be7de4
--- /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-conditional-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-conditional-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..f8ba0786e9
--- /dev/null
+++ b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx
@@ -0,0 +1,108 @@
+/* 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.jsm";
+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,
+ } = 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 } = 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 (userEvent) {
+ const userEventData = Object.assign(
+ {
+ event: userEvent,
+ source,
+ action_position: index,
+ },
+ siteInfo
+ );
+ props.dispatch(ac.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..bfc441afee
--- /dev/null
+++ b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss
@@ -0,0 +1,22 @@
+.more-recommendations {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ line-height: 1.230769231; // (16 / 13) -> 16px computed
+
+ &::after {
+ background: url('#{$image-path}topic-show-more-12.svg') no-repeat center center;
+ content: '';
+ -moz-context-properties: fill;
+ display: inline-block;
+ fill: var(--newtab-link-secondary-color);
+ height: 16px;
+ margin-inline-start: 5px;
+ vertical-align: top;
+ width: 12px;
+ }
+
+ &:dir(rtl)::after {
+ transform: scaleX(-1);
+ }
+}
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..3a55da9112
--- /dev/null
+++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss
@@ -0,0 +1,39 @@
+.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: $blue-60;
+ 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: 1.230769231; // (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..62a0dcb787
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Search/Search.jsx
@@ -0,0 +1,208 @@
+/* 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.jsm";
+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.HIDE_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 usset the search icon and reacts to
+ // changes to default engine to keep everything in sync.
+ this._handoffSearchController = new ContentSearchHandoffUIController();
+ }
+ }
+
+ 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.hide && "search-hidden",
+ this.props.fakeFocus && "fake-focus",
+ ]
+ .filter(v => v)
+ .join(" ");
+
+ const isNewNewtabExperienceEnabled = this.props.Prefs.values[
+ "newNewtabExperience.enabled"
+ ];
+
+ 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={
+ isNewNewtabExperienceEnabled
+ ? "newtab-search-box-input"
+ : "newtab-search-box-search-the-web-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"
+ data-l10n-id={
+ isNewNewtabExperienceEnabled
+ ? "newtab-search-box-input"
+ : "newtab-search-box-search-the-web-input"
+ }
+ ref={this.onSearchHandoffButtonMount}
+ onClick={this.onSearchHandoffClick}
+ tabIndex="-1"
+ >
+ <div
+ className="fake-textbox"
+ data-l10n-id={
+ isNewNewtabExperienceEnabled
+ ? "newtab-search-box-text"
+ : "newtab-search-box-search-the-web-text"
+ }
+ />
+ <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..87bfdb0427
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Search/_Search.scss
@@ -0,0 +1,516 @@
+$search-height: 48px;
+$search-height-new: 52px;
+$search-icon-size: 24px;
+$search-icon-padding: 12px;
+$search-icon-width: 2 * $search-icon-padding + $search-icon-size -2;
+$search-button-width: 48px;
+$glyph-forward: url('chrome://browser/skin/forward.svg');
+
+.outer-wrapper.newtab-experience {
+ $search-icon-padding: 16px;
+ $search-icon-width: 2 * $search-icon-padding + $search-icon-size -4;
+
+ &.visible-logo {
+ .logo-and-wordmark {
+ .wordmark {
+ fill: var(--newtab-wordmark-color);
+ }
+ }
+ }
+
+ .search-wrapper {
+ padding-bottom: 38px;
+
+ .search-inner-wrapper {
+ min-height: $search-height-new;
+ width: $searchbar-width-small-new;
+
+ @media (min-width: $break-point-medium) {
+ width: $searchbar-width-medium-new;
+ }
+
+ @media (min-width: $break-point-large) {
+ width: $searchbar-width-large-new;
+ }
+
+ @media (min-width: $break-point-widest) {
+ width: $searchbar-width-largest-new;
+ }
+ }
+
+ .search-button {
+ &:focus {
+ outline: 0;
+ box-shadow: 0 0 0 2px var(--newtab-focus-outline);
+ border: 1px solid var(--newtab-focus-border);
+ border-radius: 0 $border-radius-new $border-radius-new 0;
+ }
+ }
+
+ input:focus {
+ outline: 0;
+ border: 1px solid var(--newtab-focus-border);
+ box-shadow: 0 0 0 2px var(--newtab-focus-outline);
+ }
+
+ &.fake-focus {
+ .search-handoff-button {
+ border: 1px solid var(--newtab-focus-border);
+ box-shadow: 0 0 0 2px var(--newtab-focus-outline);
+ }
+ }
+
+ .search-handoff-button,
+ input {
+ background: var(--newtab-textbox-background-color) 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;
+ box-shadow: 0 3px 8px var(--newtab-search-first-shadow), 0 0 2px var(--newtab-search-second-shadow);
+ border: 1px solid transparent;
+ border-radius: 8px;
+ color: var(--newtab-search-text-color);
+ font-weight: 500;
+ font-size: 15px;
+ }
+
+ .search-handoff-button {
+ padding-inline-end: 15px;
+
+ .fake-caret {
+ top: 18px;
+ inset-inline-start: $search-icon-width;
+
+ &:dir(rtl) {
+ background-position-x: right $search-icon-padding;
+ }
+ }
+ }
+ }
+}
+
+.search-wrapper {
+ padding: 34px 0 64px;
+
+ .only-search & {
+ padding: 0 0 64px;
+ }
+
+ .logo-and-wordmark {
+ $logo-size: 82px;
+ $wordmark-size: 134px;
+
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ margin-bottom: 48px;
+
+ .logo {
+ 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');
+ }
+ display: inline-block;
+ height: $logo-size;
+ width: $logo-size;
+ }
+
+ .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-search-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;
+ margin: 0 auto;
+ position: relative;
+ width: $searchbar-width-small;
+
+ .ds-outer-wrapper-breakpoint-override & {
+ width: 216px;
+ }
+
+ @media (min-width: $break-point-medium) {
+ width: $searchbar-width-medium;
+
+ .ds-outer-wrapper-breakpoint-override & {
+ width: 460px;
+ }
+ }
+
+ @media (min-width: $break-point-large) {
+ width: $searchbar-width-large;
+
+ .ds-outer-wrapper-breakpoint-override & {
+ width: 696px;
+ }
+ }
+ }
+
+ input {
+ background: var(--newtab-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center no-repeat;
+ background-size: $search-icon-size;
+ border: solid 1px var(--newtab-search-border-color);
+ box-shadow: $shadow-secondary, 0 0 0 1px $black-15;
+ font-size: 15px;
+ -moz-context-properties: fill;
+ fill: var(--newtab-search-icon-color);
+ padding: 0;
+ padding-inline-end: $search-button-width;
+ padding-inline-start: $search-icon-width;
+ width: 100%;
+
+ &:dir(rtl) {
+ background-position-x: right $search-icon-padding;
+ }
+ }
+
+ &:hover input {
+ box-shadow: $shadow-secondary, 0 0 0 1px $black-25;
+ }
+
+ .search-inner-wrapper:active input,
+ input:focus {
+ border: $input-border-active;
+ box-shadow: var(--newtab-textbox-focus-boxshadow);
+ }
+
+ .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-search-icon-color);
+ height: 100%;
+ inset-inline-end: 0;
+ position: absolute;
+ width: $search-button-width;
+
+ &:focus,
+ &:hover {
+ background-color: $grey-90-10;
+ cursor: pointer;
+ }
+
+ &:active {
+ background-color: $grey-90-20;
+ }
+
+ &:dir(rtl) {
+ transform: scaleX(-1);
+ }
+ }
+}
+
+.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-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center no-repeat;
+ background-size: $search-icon-size;
+ border: solid 1px var(--newtab-search-border-color);
+ 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;
+ transition: opacity 500ms;
+ width: 100%;
+
+ &:dir(rtl) {
+ background-position-x: right $search-icon-padding;
+ }
+
+ &:hover {
+ box-shadow: $shadow-secondary, 0 0 0 1px $black-25;
+ }
+
+ .fake-focus & {
+ border: $input-border-active;
+ box-shadow: var(--newtab-textbox-focus-boxshadow);
+
+ .fake-caret {
+ display: block;
+ }
+ }
+
+ .search-hidden & {
+ opacity: 0;
+ visibility: hidden;
+ }
+
+ .fake-editable:focus {
+ outline: none;
+ caret-color: transparent;
+ }
+
+ .fake-editable {
+ color: transparent;
+ height: 100%;
+ opacity: 0;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 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: 146px;
+ }
+
+ &.visible-logo {
+ main {
+ padding-top: 276px;
+ }
+ }
+
+ .search-wrapper {
+ $search-height: 35px;
+ $search-icon-size: 16px;
+ $search-icon-padding: 16px;
+
+ border-bottom: solid 1px var(--newtab-border-secondary-color);
+ padding: 30px 0;
+ $search-header-bar-height: 95px;
+ background-color: var(--newtab-search-header-background-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;
+ }
+ }
+
+ &.newtab-experience {
+ main {
+ padding-top: 124px;
+ }
+
+ &.visible-logo {
+ main {
+ padding-top: 254px;
+ }
+ }
+
+ .search-wrapper {
+ $search-height: 45px;
+ $search-icon-size: 24px;
+ $search-icon-padding: 16px;
+
+ border-bottom: solid 1px var(--newtab-seperator-line-color);
+ padding: 27px 0;
+
+ .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 {
+ 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 {
+ background-color: var(--newtab-search-dropdown-color);
+ border: 0;
+ box-shadow: $context-menu-shadow;
+ transform: translateY($textbox-shadow-size);
+
+ .contentSearchHeader {
+ background-color: var(--newtab-search-dropdown-header-color);
+ color: var(--newtab-text-secondary-color);
+ }
+
+ .contentSearchHeader,
+ .contentSearchSettingsButton {
+ border-color: var(--newtab-border-secondary-color);
+ }
+
+ .contentSearchSuggestionsList {
+ border: 0;
+ }
+
+ .contentSearchOneOffsTable {
+ background-color: var(--newtab-search-dropdown-header-color);
+ border-top: solid 1px var(--newtab-border-secondary-color);
+ }
+
+ .contentSearchSearchWithHeaderSearchText {
+ color: var(--newtab-text-primary-color);
+ }
+
+ .contentSearchSuggestionsContainer {
+ background-color: var(--newtab-search-dropdown-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-icon-secondary-color);
+ }
+ }
+ }
+
+ .contentSearchOneOffsTable {
+ .contentSearchSuggestionsContainer {
+ background-color: var(--newtab-search-dropdown-header-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-secondary-color) $border-offset, var(--newtab-border-secondary-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/SectionMenu/SectionMenu.jsx b/browser/components/newtab/content-src/components/SectionMenu/SectionMenu.jsx
new file mode 100644
index 0000000000..25e44ad060
--- /dev/null
+++ b/browser/components/newtab/content-src/components/SectionMenu/SectionMenu.jsx
@@ -0,0 +1,122 @@
+/* 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.jsm";
+import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu";
+import React from "react";
+import { connect } from "react-redux";
+import { SectionMenuOptions } from "content-src/lib/section-menu-options";
+
+const DEFAULT_SECTION_MENU_OPTIONS = [
+ "MoveUp",
+ "MoveDown",
+ "Separator",
+ "RemoveSection",
+ "CheckCollapsed",
+ "Separator",
+ "ManageSection",
+];
+const WEBEXT_SECTION_MENU_OPTIONS = [
+ "MoveUp",
+ "MoveDown",
+ "Separator",
+ "CheckCollapsed",
+ "Separator",
+ "ManageWebExtension",
+];
+
+export class _SectionMenu extends React.PureComponent {
+ handleAddWhileCollapsed() {
+ const { action, userEvent } = SectionMenuOptions.ExpandSection(this.props);
+ this.props.dispatch(action);
+ if (userEvent) {
+ this.props.dispatch(
+ ac.UserEvent({
+ event: userEvent,
+ source: this.props.source,
+ })
+ );
+ }
+ }
+
+ getOptions() {
+ const { props } = this;
+
+ const propOptions = props.isWebExtension
+ ? [...WEBEXT_SECTION_MENU_OPTIONS]
+ : [...DEFAULT_SECTION_MENU_OPTIONS];
+
+ // Remove Collapse/Expand related option if the `newNewtabExperience.enabled`
+ // pref is set to true.
+ if (props.Prefs.values["newNewtabExperience.enabled"]) {
+ if (props.isWebExtension) {
+ propOptions.splice(2, 2);
+ } else {
+ propOptions.splice(4, 1);
+ }
+ }
+
+ // Remove the move related options if the section is fixed
+ if (props.isFixed) {
+ propOptions.splice(propOptions.indexOf("MoveUp"), 3);
+ }
+ // Prepend custom options and a separator
+ if (props.extraOptions) {
+ propOptions.splice(0, 0, ...props.extraOptions, "Separator");
+ }
+ // Insert privacy notice before the last option ("ManageSection")
+ if (props.privacyNoticeURL) {
+ propOptions.splice(-1, 0, "PrivacyNotice");
+ }
+
+ const options = propOptions
+ .map(o => SectionMenuOptions[o](props))
+ .map(option => {
+ const { action, id, type, userEvent } = option;
+ if (!type && id) {
+ option.onClick = () => {
+ const hasAddEvent =
+ userEvent === "MENU_ADD_TOPSITE" ||
+ userEvent === "MENU_ADD_SEARCH";
+
+ if (props.collapsed && hasAddEvent) {
+ this.handleAddWhileCollapsed();
+ }
+
+ props.dispatch(action);
+ if (userEvent) {
+ props.dispatch(
+ ac.UserEvent({
+ event: userEvent,
+ source: props.source,
+ })
+ );
+ }
+ };
+ }
+ 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}
+ options={this.getOptions()}
+ keyboardAccess={this.props.keyboardAccess}
+ />
+ );
+ }
+}
+
+export const SectionMenu = connect(state => ({
+ Prefs: state.Prefs,
+}))(_SectionMenu);
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..cc00e93b94
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Sections/Sections.jsx
@@ -0,0 +1,390 @@
+/* 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.jsm";
+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,
+ icon,
+ 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}
+ icon={icon}
+ 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">
+ {emptyState.icon &&
+ emptyState.icon.startsWith("moz-extension://") ? (
+ <span
+ className="empty-state-icon icon"
+ style={{ "background-image": `url('${emptyState.icon}')` }}
+ />
+ ) : (
+ <span
+ className={`empty-state-icon icon icon-${emptyState.icon}`}
+ />
+ )}
+ <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..ba6e2681e0
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Sections/_Sections.scss
@@ -0,0 +1,135 @@
+.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-icon {
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 50px 50px;
+ -moz-context-properties: fill;
+ display: block;
+ fill: var(--newtab-icon-secondary-color);
+ height: 50px;
+ margin: 0 auto;
+ width: 50px;
+ }
+
+ .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-section-navigation-text-color);
+ font-size: 12px;
+ line-height: 1.6;
+ margin-top: $topic-margin-top;
+ display: flex;
+ justify-content: space-between;
+
+ a {
+ color: var(--newtab-link-secondary-color);
+ 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..540660d254
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx
@@ -0,0 +1,189 @@
+/* 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.jsm";
+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..cb57f11f4a
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx
@@ -0,0 +1,823 @@
+/* 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.jsm";
+import {
+ MIN_CORNER_FAVICON_SIZE,
+ MIN_RICH_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.jsm";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+const SPOC_TYPE = "SPOC";
+
+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 || !this.props.link.sponsored_position) &&
+ 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":
+ this.dragged = true;
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("text/topsite-index", this.props.index);
+ event.target.blur();
+ 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, newNewtabExperienceEnabled } = this.props;
+
+ const { tippyTopIcon, faviconSize } = link;
+ let imageClassName;
+ let imageStyle;
+ let showSmallFavicon = false;
+ let smallFaviconStyle;
+ let smallFaviconFallback;
+ let hasScreenshotImage =
+ this.state.screenshotImage && this.state.screenshotImage.url;
+ let selectedColor;
+
+ if (defaultStyle) {
+ // force no styles (letter fallback) even if the link has imagery
+ smallFaviconFallback = false;
+ if (newNewtabExperienceEnabled) {
+ 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
+
+ // TopSite spoc experiment only
+ const spocImgURL =
+ link.type === SPOC_TYPE ? link.customScreenshotURL : "";
+
+ imageClassName = "top-site-icon rich-icon";
+ imageStyle = {
+ backgroundColor: link.backgroundColor,
+ backgroundImage: hasScreenshotImage
+ ? `url(${this.state.screenshotImage.url})`
+ : `url(${spocImgURL})`,
+ };
+ } else if (tippyTopIcon || 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 {
+ // styles and class names for top sites with screenshot + small icon in top left corner
+ imageClassName = `screenshot${hasScreenshotImage ? " active" : ""}`;
+ imageStyle = {
+ backgroundImage: hasScreenshotImage
+ ? `url(${this.state.screenshotImage.url})`
+ : "none",
+ };
+ // only show a favicon in top left if it's greater than 16x16
+ if (faviconSize >= MIN_CORNER_FAVICON_SIZE) {
+ showSmallFavicon = true;
+ smallFaviconStyle = { backgroundImage: `url(${link.favicon})` };
+ } else if (newNewtabExperienceEnabled) {
+ selectedColor = this.generateColor();
+ imageClassName = "";
+ } else if (hasScreenshotImage) {
+ // Don't show a small favicon if there is no screenshot, because that
+ // would result in two fallback icons
+ showSmallFavicon = true;
+ smallFaviconFallback = true;
+ }
+ }
+
+ return {
+ showSmallFavicon,
+ smallFaviconFallback,
+ smallFaviconStyle,
+ imageStyle,
+ imageClassName,
+ selectedColor,
+ };
+ }
+
+ render() {
+ const {
+ children,
+ className,
+ isDraggable,
+ link,
+ onClick,
+ title,
+ newNewtabExperienceEnabled,
+ } = this.props;
+ const topSiteOuterClassName = `top-site-outer${
+ className ? ` ${className}` : ""
+ }${link.isDragged ? " dragged" : ""}${
+ link.searchTopSite ? " search-shortcut" : ""
+ }`;
+ const [letterFallback] = title;
+ const {
+ showSmallFavicon,
+ smallFaviconFallback,
+ smallFaviconStyle,
+ imageStyle,
+ imageClassName,
+ selectedColor,
+ } = this.calculateStyle();
+
+ let draggableProps = {};
+ if (isDraggable) {
+ draggableProps = {
+ onClick: this.onDragEvent,
+ onDragEnd: this.onDragEvent,
+ onDragStart: this.onDragEvent,
+ onMouseDown: this.onDragEvent,
+ };
+ }
+
+ 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}
+ >
+ {(newNewtabExperienceEnabled && (
+ <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={smallFaviconFallback && letterFallback}
+ style={smallFaviconStyle}
+ />
+ )}
+ </div>
+ {link.searchTopSite && (
+ <div className="top-site-icon search-topsite" />
+ )}
+ </div>
+ )) || (
+ <div
+ className="tile"
+ aria-hidden={true}
+ data-fallback={letterFallback}
+ >
+ <div className={imageClassName} style={imageStyle} />
+ {link.searchTopSite && (
+ <div className="top-site-icon search-topsite" />
+ )}
+ {showSmallFavicon && (
+ <div
+ className="top-site-icon default-icon"
+ data-fallback={smallFaviconFallback && letterFallback}
+ style={smallFaviconStyle}
+ />
+ )}
+ </div>
+ )}
+ <div
+ className={`title${link.isPinned ? " has-icon pinned" : ""}${
+ link.type === SPOC_TYPE || link.sponsored_position
+ ? " sponsored"
+ : ""
+ }`}
+ >
+ {(newNewtabExperienceEnabled && (
+ <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>
+ {link.isPinned && <div className="icon icon-pin-small" />}
+ <span dir="auto">{title || <br />}</span>
+ <span
+ className="sponsored-label"
+ data-l10n-id="newtab-topsite-sponsored"
+ />
+ </div>
+ )}
+ </div>
+ </a>
+ {children}
+ {link.type === SPOC_TYPE ? (
+ <ImpressionStats
+ flightId={link.flightId}
+ rows={[
+ {
+ id: link.id,
+ pos: link.pos,
+ shim: link.shim && link.shim.impression,
+ },
+ ]}
+ dispatch={this.props.dispatch}
+ source={TOP_SITES_SOURCE}
+ />
+ ) : null}
+ </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 (
+ this.props.link.type === SPOC_TYPE ||
+ this.props.link.sponsored_position
+ ) {
+ 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 },
+ }),
+ })
+ );
+
+ // Fire off a spoc specific impression.
+ if (this.props.link.type === SPOC_TYPE) {
+ 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,
+ },
+ ],
+ })
+ );
+ }
+ 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="true"
+ 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 || site.sponsored_position) ? site : null
+ );
+ const unpinned = topSites.filter(
+ site => site && !site.isPinned && !site.sponsored_position
+ );
+ 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 (preview[nextIndex] && preview[nextIndex].sponsored_position) {
+ 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,
+ newNewtabExperienceEnabled: props.newNewtabExperienceEnabled,
+ };
+ // 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";
+ }
+ topSitesUI.push(
+ !link ? (
+ <TopSitePlaceholder {...slotProps} {...commonProps} />
+ ) : (
+ <TopSite
+ link={link}
+ activeIndex={this.state.activeIndex}
+ onActivate={this.onActivate}
+ {...slotProps}
+ {...commonProps}
+ colors={props.colors}
+ />
+ )
+ );
+ }
+ return (
+ <ul
+ className={`top-sites-list${
+ this.state.draggedSite ? " dnd-active" : ""
+ }`}
+ >
+ {topSitesUI}
+ </ul>
+ );
+ }
+}
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..258f749486
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx
@@ -0,0 +1,330 @@
+/* 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.jsm";
+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;
+
+ // When the newNewtabExperience is enabled by default, use only shortcut ids.
+ const addTopsitesHeaderL10nId =
+ this.props.newNewtabExperienceEnabled ||
+ this.props.customizationMenuEnabled
+ ? "newtab-topsites-add-shortcut-header"
+ : "newtab-topsites-add-topsites-header";
+ const editTopsitesHeaderL10nId =
+ this.props.newNewtabExperienceEnabled ||
+ this.props.customizationMenuEnabled
+ ? "newtab-topsites-edit-shortcut-header"
+ : "newtab-topsites-edit-topsites-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"
+ />
+ <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}
+ newNewtabExperienceEnabled={this.props.newNewtabExperienceEnabled}
+ />
+ </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..6ec0271122
--- /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.shouldFocus}
+ 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/TopSites.jsx b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx
new file mode 100644
index 0000000000..2c9419cb42
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx
@@ -0,0 +1,241 @@
+/* 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.jsm";
+import {
+ MIN_CORNER_FAVICON_SIZE,
+ 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.jsm";
+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 && link.faviconSize >= MIN_CORNER_FAVICON_SIZE) {
+ return "screenshot_with_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_with_icon: 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 newNewtabExperienceEnabled =
+ props.Prefs.values["newNewtabExperience.enabled"];
+ const customizationMenuEnabled =
+ props.Prefs.values["customizationMenu.enabled"];
+ const colors = props.Prefs.values["newNewtabExperience.colors"];
+
+ if (props.Prefs.values["improvesearch.topSiteSearchShortcuts"]) {
+ extraMenuOptions.push("AddSearchShortcut");
+ }
+
+ const canShowCustomizationMenu =
+ newNewtabExperienceEnabled || customizationMenuEnabled;
+ const hideTitle =
+ props.Prefs.values.hideTopSitesTitle || canShowCustomizationMenu;
+
+ // `collapsed` should be sent to CollapsibleSection as undefined if
+ // `props.TopSites.pref` is not set to true.
+ let collapsed;
+ if (props.TopSites.pref) {
+ collapsed = canShowCustomizationMenu
+ ? false
+ : props.TopSites.pref.collapsed;
+ }
+
+ return (
+ <ComponentPerfTimer
+ id="topsites"
+ initialized={props.TopSites.initialized}
+ dispatch={props.dispatch}
+ >
+ <CollapsibleSection
+ className="top-sites"
+ icon="topsites"
+ id="topsites"
+ title={props.title || { id: "newtab-section-header-topsites" }}
+ hideTitle={hideTitle}
+ extraMenuOptions={extraMenuOptions}
+ showPrefName="feeds.topsites"
+ eventSource={TOP_SITES_SOURCE}
+ collapsed={collapsed}
+ isFixed={props.isFixed}
+ isFirst={props.isFirst}
+ isLast={props.isLast}
+ dispatch={props.dispatch}
+ >
+ <TopSiteList
+ TopSites={props.TopSites}
+ TopSitesRows={props.TopSitesRows}
+ dispatch={props.dispatch}
+ topSiteIconType={topSiteIconType}
+ newNewtabExperienceEnabled={newNewtabExperienceEnabled}
+ 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}
+ newNewtabExperienceEnabled={newNewtabExperienceEnabled}
+ customizationMenuEnabled={customizationMenuEnabled}
+ />
+ </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) => ({
+ // For SPOC Experiment only, take TopSites from DiscoveryStream TopSites that takes in SPOC Data
+ TopSites: props.TopSitesWithSpoc || 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..7058557d88
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js
@@ -0,0 +1,43 @@
+/* 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 = [
+ "PinTopSite",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "ShowPrivacyInfo",
+];
+export const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = [
+ "PinTopSite",
+ "Separator",
+ "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 in the top left corner with a screenshot
+export const MIN_CORNER_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..b4b2615613
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss
@@ -0,0 +1,803 @@
+$top-sites-size: $grid-unit;
+$top-sites-size-nte: $grid-unit-small;
+$top-sites-border-radius: 4px;
+$nt-experience-top-sites-border-radius: 8px;
+$top-sites-vertical-space: 8px;
+$screenshot-size: cover;
+$rich-icon-size: 96px;
+$default-icon-wrapper-size: 42px;
+$nt-experience-default-icon-wrapper-size: 32px;
+$search-icon-wrapper-size: 42px;
+$default-icon-size: 32px;
+$default-icon-offset: 6px;
+$half-base-gutter: $base-gutter / 2;
+$hover-transition-duration: 150ms;
+$letter-fallback-color: $white;
+
+.top-sites-list {
+ list-style: none;
+ margin: 0 (-$half-base-gutter);
+ padding: 0;
+
+ // 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 {
+ padding: 0 $half-base-gutter;
+ display: inline-block;
+
+ // container for context menu
+ .top-site-inner {
+ position: relative;
+
+ > a {
+ color: inherit;
+ display: block;
+ outline: none;
+ }
+ }
+
+ .tile { // sass-lint:disable-block property-sort-order
+ border-radius: $top-sites-border-radius;
+ box-shadow: inset $inner-box-shadow, var(--newtab-card-shadow);
+ cursor: pointer;
+ position: relative;
+
+ // For letter fallback
+ align-items: center;
+ color: var(--newtab-text-secondary-color);
+ display: flex;
+ font-size: 32px;
+ font-weight: 200;
+ justify-content: center;
+ text-transform: uppercase; // sass-lint:disable-line no-disallowed-properties
+
+ .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; // sass-lint:disable-line no-disallowed-properties
+ 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-topsites-background-color);
+ background-position: center center;
+ background-repeat: no-repeat;
+ border-radius: $top-sites-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('#{$image-path}glyph-search-16.svg');
+ background-size: 26px;
+ background-color: $blue-60;
+ border-radius: $default-icon-wrapper-size;
+ -moz-context-properties: fill;
+ fill: $white;
+ box-shadow: var(--newtab-card-shadow);
+ transition-duration: $hover-transition-duration;
+ transition-property: background-size, bottom, inset-inline-end, height, width;
+ height: $search-icon-wrapper-size;
+ width: $search-icon-wrapper-size;
+ bottom: -$default-icon-offset;
+ inset-inline-end: -$default-icon-offset;
+ }
+
+ .title {
+ color: var(--newtab-topsites-label-color);
+ font: message-box;
+ padding-top: 4px;
+ text-align: center;
+ position: relative;
+
+ .icon {
+ fill: var(--newtab-icon-tertiary-color);
+ }
+
+ span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .sponsored-label {
+ color: $grey-50;
+ 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('#{$image-path}glyph-edit-16.svg');
+ }
+
+ &.placeholder {
+ .screenshot {
+ display: none;
+ }
+ }
+
+ &.dragged {
+ .tile {
+ background: $grey-20;
+ box-shadow: none;
+
+ *,
+ &::before {
+ display: none;
+ }
+ }
+
+ .title {
+ visibility: hidden;
+ }
+ }
+}
+
+.edit-topsites-wrapper {
+ .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: $blue-50;
+ 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-textbox-background-color);
+ 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: $red-60;
+ border-radius: 2px;
+ color: $white;
+ inset-inline-start: 3px;
+ padding: 5px 12px;
+ position: absolute;
+ top: 44px;
+ z-index: 1;
+
+ // tooltip caret
+ &::before {
+ background: $red-60;
+ 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; // sass-lint:disable-line no-url-domains
+ content: '';
+ height: 21px;
+ left: $checkbox-offset;
+ position: absolute;
+ top: $checkbox-offset;
+ width: 21px;
+ -moz-context-properties: fill;
+ fill: var(--newtab-link-primary-color);
+ z-index: 2;
+
+ [dir='rtl'] & {
+ left: auto;
+ right: $checkbox-offset;
+ }
+ }
+
+ // when selected, highlight the tile
+ [type='checkbox']:checked + label {
+ .tile {
+ box-shadow: 0 0 0 2px var(--newtab-link-primary-color);
+ }
+ }
+
+ // 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-link-primary-color);
+ }
+}
+
+.outer-wrapper {
+ // Special styling for when we are using the old new tab styling,
+ // this is to be removed once the changes are made permanent
+ &:not(.newtab-experience) {
+ .top-site-outer {
+ @include context-menu-button;
+
+ .tile {
+ height: $top-sites-size;
+ width: $top-sites-size;
+ transition: box-shadow $hover-transition-duration;
+ }
+
+ .top-site-icon {
+ box-shadow: var(--newtab-topsites-icon-shadow);
+ }
+
+ .title {
+ width: $top-sites-size;
+
+ &.has-icon {
+ span {
+ padding: 0 13px;
+ }
+ }
+
+ .icon {
+ inset-inline-start: 0;
+ position: absolute;
+ top: 0.5em;
+ }
+ }
+
+ // container for context menu
+ .top-site-inner {
+ position: relative;
+
+ > a {
+ color: inherit;
+ display: block;
+ outline: none;
+
+ &:is(.active, :focus) {
+ .tile {
+ @include fade-in;
+ }
+ }
+ }
+ }
+
+ .screenshot {
+ background-color: $white;
+ background-position: top left;
+ background-size: $screenshot-size;
+ border-radius: $top-sites-border-radius;
+ box-shadow: inset $inner-box-shadow;
+ height: 100%;
+ opacity: 0;
+ position: absolute;
+ top: 0;
+ left: 0;
+ transition: opacity 1s;
+ width: 100%;
+
+ &.active {
+ opacity: 1;
+ }
+ }
+
+ .default-icon,
+ .search-topsite {
+ bottom: -$default-icon-offset;
+ inset-inline-end: -$default-icon-offset;
+ }
+
+ &:hover .search-topsite {
+ $hover-icon-wrapper-size: $default-icon-wrapper-size + 4;
+ $hover-icon-offset: -$default-icon-offset - 3;
+
+ background-size: 28px;
+ border-radius: $hover-icon-wrapper-size;
+ bottom: $hover-icon-offset;
+ height: $hover-icon-wrapper-size;
+ inset-inline-end: $hover-icon-offset;
+ width: $hover-icon-wrapper-size;
+ }
+
+ &.placeholder {
+ .tile {
+ box-shadow: inset $inner-box-shadow;
+ }
+ }
+ }
+
+ .top-sites-list {
+ &:not(.dnd-active) {
+ .top-site-outer:is(.active, :focus, :hover) {
+ .tile {
+ @include fade-in;
+ }
+
+ @include context-menu-button-hover;
+ }
+ }
+
+ li {
+ margin: 0 0 $top-sites-vertical-space;
+ }
+ }
+ }
+
+ // Special styling for the New Tab Experience styles,
+ // This is to be incorporated once the styles are made permanent
+ &.newtab-experience {
+ .top-site-outer {
+ @include context-menu-button-newtab-experience;
+ width: 120px;
+ padding-block: 20px 4px;
+ border-radius: 8px;
+
+ .edit-button {
+ background-image: url('#{$image-path}glyph-edit-16.svg');
+ }
+
+ .tile {
+ border-radius: $nt-experience-top-sites-border-radius;
+ box-shadow: $inner-box-shadow-nte, $tile-shadow-second;
+ background-color: var(--newtab-topsites-background-color);
+ justify-content: center;
+ margin: 0 auto;
+ height: $top-sites-size-nte;
+ width: $top-sites-size-nte;
+ }
+
+ .title {
+ color: var(--newtab-background-primary-text-color);
+ padding-top: 8px;
+ font-size: 12px;
+
+ .icon {
+ margin-inline-end: 2px;
+ fill: var(--newtab-background-primary-text-color);
+ }
+
+ .sponsored-label {
+ font-size: 12px;
+ }
+ }
+
+ .default-icon,
+ .search-topsite {
+ height: $nt-experience-default-icon-wrapper-size;
+ width: $nt-experience-default-icon-wrapper-size;
+ }
+
+ .search-topsite {
+ background-size: 16px;
+ height: 32px;
+ width: 32px;
+ }
+
+ &:hover .search-topsite {
+ $hover-icon-wrapper-size: $search-icon-wrapper-size + 4;
+ }
+
+ &.placeholder {
+ .tile {
+ box-shadow: $inner-box-shadow;
+ }
+ }
+ }
+
+ .top-sites-list {
+ // 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-nte) {
+ :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-nte) {
+ :nth-child(6n) {
+ @include context-menu-open-left;
+ }
+ }
+ @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width-nte) {
+ :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-nte) {
+ :nth-child(8n) {
+ @include context-menu-open-left;
+ }
+ }
+ @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width-nte) {
+ :nth-child(8n+7) {
+ @include context-menu-open-left;
+ }
+ }
+ }
+
+ &:not(.dnd-active) {
+ .top-site-outer:is(.active, :focus, :hover) {
+ @include nt-experience-context-menu-button-hover;
+ background: var(--newtab-topsites-outer-card-hover);
+ }
+ }
+ }
+
+}
+
+//used for tooltips below form element
+@keyframes fade-up-tt {
+ 0% {
+ opacity: 0;
+ transform: translateY(15px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
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..0a2159beee
--- /dev/null
+++ b/browser/components/newtab/content-src/components/Topics/_Topics.scss
@@ -0,0 +1,23 @@
+.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;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/lib/aboutwelcome-utils.js b/browser/components/newtab/content-src/lib/aboutwelcome-utils.js
new file mode 100644
index 0000000000..54efde5e52
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/aboutwelcome-utils.js
@@ -0,0 +1,235 @@
+/* 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 AboutWelcomeUtils = {
+ handleUserAction(action) {
+ window.AWSendToParent("SPECIAL_ACTION", action);
+ },
+ sendImpressionTelemetry(messageId, context) {
+ window.AWSendEventTelemetry({
+ event: "IMPRESSION",
+ event_context: context,
+ message_id: messageId,
+ });
+ },
+ sendActionTelemetry(messageId, elementId) {
+ const ping = {
+ event: "CLICK_BUTTON",
+ event_context: {
+ source: elementId,
+ page: "about:welcome",
+ },
+ message_id: messageId,
+ };
+ window.AWSendEventTelemetry(ping);
+ },
+ async fetchFlowParams(metricsFlowUri) {
+ let flowParams;
+ try {
+ const response = await fetch(metricsFlowUri, {
+ credentials: "omit",
+ });
+ if (response.status === 200) {
+ const { deviceId, flowId, flowBeginTime } = await response.json();
+ flowParams = { deviceId, flowId, flowBeginTime };
+ } else {
+ console.error("Non-200 response", response); // eslint-disable-line no-console
+ }
+ } catch (e) {
+ flowParams = null;
+ }
+ return flowParams;
+ },
+ sendEvent(type, detail) {
+ document.dispatchEvent(
+ new CustomEvent(`AWPage:${type}`, {
+ bubbles: true,
+ detail,
+ })
+ );
+ },
+ hasDarkMode() {
+ return document.body.hasAttribute("lwt-newtab-brighttext");
+ },
+};
+
+export const DEFAULT_RTAMO_CONTENT = {
+ template: "return_to_amo",
+ content: {
+ header: { string_id: "onboarding-welcome-header" },
+ subtitle: { string_id: "return-to-amo-subtitle" },
+ text: {
+ string_id: "return-to-amo-addon-title",
+ },
+ primary_button: {
+ label: { string_id: "return-to-amo-add-extension-label" },
+ action: {
+ type: "INSTALL_ADDON_FROM_URL",
+ data: { url: null, telemetrySource: "rtamo" },
+ },
+ },
+ startButton: {
+ label: {
+ string_id: "onboarding-not-now-button-label",
+ },
+ message_id: "RTAMO_START_BROWSING_BUTTON",
+ action: {
+ type: "OPEN_AWESOME_BAR",
+ },
+ },
+ },
+};
+
+export const DEFAULT_WELCOME_CONTENT = {
+ template: "multistage",
+ screens: [
+ {
+ id: "AW_GET_STARTED",
+ order: 0,
+ content: {
+ zap: true,
+ title: {
+ string_id: "onboarding-multistage-welcome-header",
+ },
+ subtitle: { string_id: "onboarding-multistage-welcome-subtitle" },
+ primary_button: {
+ label: {
+ string_id: "onboarding-multistage-welcome-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ text: {
+ string_id: "onboarding-multistage-welcome-secondary-button-text",
+ },
+ label: {
+ string_id: "onboarding-multistage-welcome-secondary-button-label",
+ },
+ position: "top",
+ action: {
+ type: "SHOW_FIREFOX_ACCOUNTS",
+ addFlowParams: true,
+ data: {
+ entrypoint: "activity-stream-firstrun",
+ },
+ },
+ },
+ },
+ },
+ {
+ id: "AW_IMPORT_SETTINGS",
+ order: 1,
+ content: {
+ zap: true,
+ help_text: {
+ text: { string_id: "onboarding-import-sites-disclaimer" },
+ },
+ title: { string_id: "onboarding-multistage-import-header" },
+ subtitle: { string_id: "onboarding-multistage-import-subtitle" },
+ tiles: {
+ type: "topsites",
+ showTitles: true,
+ },
+ primary_button: {
+ label: {
+ string_id: "onboarding-multistage-import-primary-button-label",
+ },
+ action: {
+ type: "SHOW_MIGRATION_WIZARD",
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "onboarding-multistage-import-secondary-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ },
+ },
+ {
+ id: "AW_CHOOSE_THEME",
+ order: 2,
+ content: {
+ zap: true,
+ title: { string_id: "onboarding-multistage-theme-header" },
+ subtitle: { string_id: "onboarding-multistage-theme-subtitle" },
+ tiles: {
+ type: "theme",
+ action: {
+ theme: "<event>",
+ },
+ data: [
+ {
+ theme: "automatic",
+ label: {
+ string_id: "onboarding-multistage-theme-label-automatic",
+ },
+ tooltip: {
+ string_id: "onboarding-multistage-theme-tooltip-automatic-2",
+ },
+ description: {
+ string_id:
+ "onboarding-multistage-theme-description-automatic-2",
+ },
+ },
+ {
+ theme: "light",
+ label: { string_id: "onboarding-multistage-theme-label-light" },
+ tooltip: {
+ string_id: "onboarding-multistage-theme-tooltip-light-2",
+ },
+ description: {
+ string_id: "onboarding-multistage-theme-description-light",
+ },
+ },
+ {
+ theme: "dark",
+ label: { string_id: "onboarding-multistage-theme-label-dark" },
+ tooltip: {
+ string_id: "onboarding-multistage-theme-tooltip-dark-2",
+ },
+ description: {
+ string_id: "onboarding-multistage-theme-description-dark",
+ },
+ },
+ {
+ theme: "alpenglow",
+ label: {
+ string_id: "onboarding-multistage-theme-label-alpenglow",
+ },
+ tooltip: {
+ string_id: "onboarding-multistage-theme-tooltip-alpenglow-2",
+ },
+ description: {
+ string_id: "onboarding-multistage-theme-description-alpenglow",
+ },
+ },
+ ],
+ },
+ primary_button: {
+ label: {
+ string_id: "onboarding-multistage-theme-primary-button-label",
+ },
+ action: {
+ navigate: true,
+ },
+ },
+ secondary_button: {
+ label: {
+ string_id: "onboarding-multistage-theme-secondary-button-label",
+ },
+ action: {
+ theme: "automatic",
+ navigate: true,
+ },
+ },
+ },
+ },
+ ],
+};
diff --git a/browser/components/newtab/content-src/lib/constants.js b/browser/components/newtab/content-src/lib/constants.js
new file mode 100644
index 0000000000..bd3da63682
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/constants.js
@@ -0,0 +1,32 @@
+/* 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 IS_NEWTAB =
+ global.document && global.document.documentURI === "about:newtab";
+export const NEWTAB_DARK_THEME = {
+ ntp_background: {
+ r: 42,
+ g: 42,
+ b: 46,
+ a: 1,
+ },
+ ntp_text: {
+ r: 249,
+ g: 249,
+ b: 250,
+ a: 1,
+ },
+ sidebar: {
+ r: 56,
+ g: 56,
+ b: 61,
+ a: 1,
+ },
+ sidebar_text: {
+ r: 249,
+ g: 249,
+ b: 250,
+ a: 1,
+ },
+};
diff --git a/browser/components/newtab/content-src/lib/detect-user-session-start.js b/browser/components/newtab/content-src/lib/detect-user-session-start.js
new file mode 100644
index 0000000000..ecc57dba72
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/detect-user-session-start.js
@@ -0,0 +1,78 @@
+/* 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.jsm";
+import { perfService as perfSvc } from "content-src/lib/perf-service";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+export class DetectUserSessionStart {
+ constructor(store, options = {}) {
+ this._store = store;
+ // Overrides for testing
+ this.document = options.document || global.document;
+ this._perfService = options.perfService || perfSvc;
+ this._onVisibilityChange = this._onVisibilityChange.bind(this);
+ }
+
+ /**
+ * sendEventOrAddListener - Notify immediately if the page is already visible,
+ * or else set up a listener for when visibility changes.
+ * This is needed for accurate session tracking for telemetry,
+ * because tabs are pre-loaded.
+ */
+ sendEventOrAddListener() {
+ if (this.document.visibilityState === VISIBLE) {
+ // If the document is already visible, to the user, send a notification
+ // immediately that a session has started.
+ this._sendEvent();
+ } else {
+ // If the document is not visible, listen for when it does become visible.
+ this.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ /**
+ * _sendEvent - Sends a message to the main process to indicate the current
+ * tab is now visible to the user, includes the
+ * visibility_event_rcvd_ts time in ms from the UNIX epoch.
+ */
+ _sendEvent() {
+ this._perfService.mark("visibility_event_rcvd_ts");
+
+ try {
+ let visibility_event_rcvd_ts = this._perfService.getMostRecentAbsMarkStartByName(
+ "visibility_event_rcvd_ts"
+ );
+
+ this._store.dispatch(
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: { visibility_event_rcvd_ts },
+ })
+ );
+ } catch (ex) {
+ // If this failed, it's likely because the `privacy.resistFingerprinting`
+ // pref is true. We should at least not blow up.
+ }
+ }
+
+ /**
+ * _onVisibilityChange - If the visibility has changed to visible, sends a notification
+ * and removes the event listener. This should only be called once per tab.
+ */
+ _onVisibilityChange() {
+ if (this.document.visibilityState === VISIBLE) {
+ this._sendEvent();
+ this.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/lib/init-store.js b/browser/components/newtab/content-src/lib/init-store.js
new file mode 100644
index 0000000000..c0931ab5d8
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/init-store.js
@@ -0,0 +1,175 @@
+/* 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/. */
+
+/* eslint-env mozilla/frame-script */
+
+import {
+ actionCreators as ac,
+ actionTypes as at,
+ actionUtils as au,
+} from "common/Actions.jsm";
+import { applyMiddleware, combineReducers, createStore } from "redux";
+
+export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE";
+export const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain";
+export const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent";
+export const EARLY_QUEUED_ACTIONS = [at.SAVE_SESSION_PERF_DATA];
+
+/**
+ * A higher-order function which returns a reducer that, on MERGE_STORE action,
+ * will return the action.data object merged into the previous state.
+ *
+ * For all other actions, it merely calls mainReducer.
+ *
+ * Because we want this to merge the entire state object, it's written as a
+ * higher order function which takes the main reducer (itself often a call to
+ * combineReducers) as a parameter.
+ *
+ * @param {function} mainReducer reducer to call if action != MERGE_STORE_ACTION
+ * @return {function} a reducer that, on MERGE_STORE_ACTION action,
+ * will return the action.data object merged
+ * into the previous state, and the result
+ * of calling mainReducer otherwise.
+ */
+function mergeStateReducer(mainReducer) {
+ return (prevState, action) => {
+ if (action.type === MERGE_STORE_ACTION) {
+ return { ...prevState, ...action.data };
+ }
+
+ return mainReducer(prevState, action);
+ };
+}
+
+/**
+ * messageMiddleware - Middleware that looks for SentToMain type actions, and sends them if necessary
+ */
+const messageMiddleware = store => next => action => {
+ const skipLocal = action.meta && action.meta.skipLocal;
+ if (au.isSendToMain(action)) {
+ RPMSendAsyncMessage(OUTGOING_MESSAGE_NAME, action);
+ }
+ if (!skipLocal) {
+ next(action);
+ }
+};
+
+export const rehydrationMiddleware = ({ getState }) => {
+ // NB: The parameter here is MiddlewareAPI which looks like a Store and shares
+ // the same getState, so attached properties are accessible from the store.
+ getState.didRehydrate = false;
+ getState.didRequestInitialState = false;
+ return next => action => {
+ if (getState.didRehydrate || window.__FROM_STARTUP_CACHE__) {
+ // Startup messages can be safely ignored by the about:home document
+ // stored in the startup cache.
+ if (
+ window.__FROM_STARTUP_CACHE__ &&
+ action.meta &&
+ action.meta.isStartup
+ ) {
+ return null;
+ }
+ return next(action);
+ }
+
+ const isMergeStoreAction = action.type === MERGE_STORE_ACTION;
+ const isRehydrationRequest = action.type === at.NEW_TAB_STATE_REQUEST;
+
+ if (isRehydrationRequest) {
+ getState.didRequestInitialState = true;
+ return next(action);
+ }
+
+ if (isMergeStoreAction) {
+ getState.didRehydrate = true;
+ return next(action);
+ }
+
+ // If init happened after our request was made, we need to re-request
+ if (getState.didRequestInitialState && action.type === at.INIT) {
+ return next(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST }));
+ }
+
+ if (
+ au.isBroadcastToContent(action) ||
+ au.isSendToOneContent(action) ||
+ au.isSendToPreloaded(action)
+ ) {
+ // Note that actions received before didRehydrate will not be dispatched
+ // because this could negatively affect preloading and the the state
+ // will be replaced by rehydration anyway.
+ return null;
+ }
+
+ return next(action);
+ };
+};
+
+/**
+ * This middleware queues up all the EARLY_QUEUED_ACTIONS until it receives
+ * the first action from main. This is useful for those actions for main which
+ * require higher reliability, i.e. the action will not be lost in the case
+ * that it gets sent before the main is ready to receive it. Conversely, any
+ * actions allowed early are accepted to be ignorable or re-sendable.
+ */
+export const queueEarlyMessageMiddleware = ({ getState }) => {
+ // NB: The parameter here is MiddlewareAPI which looks like a Store and shares
+ // the same getState, so attached properties are accessible from the store.
+ getState.earlyActionQueue = [];
+ getState.receivedFromMain = false;
+ return next => action => {
+ if (getState.receivedFromMain) {
+ next(action);
+ } else if (au.isFromMain(action)) {
+ next(action);
+ getState.receivedFromMain = true;
+ // Sending out all the early actions as main is ready now
+ getState.earlyActionQueue.forEach(next);
+ getState.earlyActionQueue.length = 0;
+ } else if (EARLY_QUEUED_ACTIONS.includes(action.type)) {
+ getState.earlyActionQueue.push(action);
+ } else {
+ // Let any other type of action go through
+ next(action);
+ }
+ };
+};
+
+/**
+ * initStore - Create a store and listen for incoming actions
+ *
+ * @param {object} reducers An object containing Redux reducers
+ * @param {object} intialState (optional) The initial state of the store, if desired
+ * @return {object} A redux store
+ */
+export function initStore(reducers, initialState) {
+ const store = createStore(
+ mergeStateReducer(combineReducers(reducers)),
+ initialState,
+ global.RPMAddMessageListener &&
+ applyMiddleware(
+ queueEarlyMessageMiddleware,
+ rehydrationMiddleware,
+ messageMiddleware
+ )
+ );
+
+ if (global.RPMAddMessageListener) {
+ global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => {
+ try {
+ store.dispatch(msg.data);
+ } catch (ex) {
+ console.error("Content msg:", msg, "Dispatch error: ", ex); // eslint-disable-line no-console
+ dump(
+ `Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${
+ ex.stack
+ }`
+ );
+ }
+ });
+ }
+
+ return store;
+}
diff --git a/browser/components/newtab/content-src/lib/link-menu-options.js b/browser/components/newtab/content-src/lib/link-menu-options.js
new file mode 100644
index 0000000000..cb45c0df2d
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/link-menu-options.js
@@ -0,0 +1,276 @@
+/* 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.jsm";
+
+const _OpenInPrivateWindow = site => ({
+ id: "newtab-menu-open-new-private-window",
+ icon: "new-window-private",
+ action: ac.OnlyToMain({
+ type: at.OPEN_PRIVATE_WINDOW,
+ data: { url: site.url, referrer: site.referrer },
+ }),
+ userEvent: "OPEN_PRIVATE_WINDOW",
+});
+
+/**
+ * List of functions that return items that can be included as menu options in a
+ * LinkMenu. All functions take the site as the first parameter, and optionally
+ * the index of the site.
+ */
+export const LinkMenuOptions = {
+ Separator: () => ({ type: "separator" }),
+ EmptyItem: () => ({ type: "empty" }),
+ ShowPrivacyInfo: site => ({
+ id: "newtab-menu-show-privacy-info",
+ icon: "info",
+ action: {
+ type: at.SHOW_PRIVACY_INFO,
+ },
+ userEvent: "SHOW_PRIVACY_INFO",
+ }),
+ AboutSponsored: site => ({
+ id: "newtab-menu-show-privacy-info",
+ icon: "info",
+ action: ac.AlsoToMain({
+ type: at.ABOUT_SPONSORED_TOP_SITES,
+ }),
+ userEvent: "TOPSITE_SPONSOR_INFO",
+ }),
+ RemoveBookmark: site => ({
+ id: "newtab-menu-remove-bookmark",
+ icon: "bookmark-added",
+ action: ac.AlsoToMain({
+ type: at.DELETE_BOOKMARK_BY_ID,
+ data: site.bookmarkGuid,
+ }),
+ userEvent: "BOOKMARK_DELETE",
+ }),
+ AddBookmark: site => ({
+ id: "newtab-menu-bookmark",
+ icon: "bookmark-hollow",
+ action: ac.AlsoToMain({
+ type: at.BOOKMARK_URL,
+ data: { url: site.url, title: site.title, type: site.type },
+ }),
+ userEvent: "BOOKMARK_ADD",
+ }),
+ OpenInNewWindow: site => ({
+ id: "newtab-menu-open-new-window",
+ icon: "new-window",
+ action: ac.AlsoToMain({
+ type: at.OPEN_NEW_WINDOW,
+ data: {
+ referrer: site.referrer,
+ typedBonus: site.typedBonus,
+ url: site.url,
+ },
+ }),
+ userEvent: "OPEN_NEW_WINDOW",
+ }),
+ // This blocks the url for regular stories,
+ // but also sends a message to DiscoveryStream with flight_id.
+ // If DiscoveryStream sees this message for a flight_id
+ // it also blocks it on the flight_id.
+ BlockUrl: (site, index, eventSource) => {
+ return LinkMenuOptions.BlockUrls([site], index, eventSource);
+ },
+ // Same as BlockUrl, cept can work on an array of sites.
+ BlockUrls: (tiles, pos, eventSource) => ({
+ id: "newtab-menu-dismiss",
+ icon: "dismiss",
+ action: ac.AlsoToMain({
+ type: at.BLOCK_URL,
+ data: tiles.map(site => ({
+ url: site.original_url || site.open_url || site.url,
+ // pocket_id is only for pocket stories being in highlights, and then dismissed.
+ pocket_id: site.pocket_id,
+ ...(site.flight_id ? { flight_id: site.flight_id } : {}),
+ })),
+ }),
+ impression: ac.ImpressionStats({
+ source: eventSource,
+ block: 0,
+ tiles: tiles.map((site, index) => ({
+ id: site.guid,
+ pos: pos + index,
+ ...(site.shim && site.shim.delete ? { shim: site.shim.delete } : {}),
+ })),
+ }),
+ userEvent: "BLOCK",
+ }),
+
+ // This is an option for web extentions which will result in remove items from
+ // memory and notify the web extenion, rather than using the built-in block list.
+ WebExtDismiss: (site, index, eventSource) => ({
+ id: "menu_action_webext_dismiss",
+ string_id: "newtab-menu-dismiss",
+ icon: "dismiss",
+ action: ac.WebExtEvent(at.WEBEXT_DISMISS, {
+ source: eventSource,
+ url: site.url,
+ action_position: index,
+ }),
+ }),
+ DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({
+ id: "newtab-menu-delete-history",
+ icon: "delete",
+ action: {
+ type: at.DIALOG_OPEN,
+ data: {
+ onConfirm: [
+ ac.AlsoToMain({
+ type: at.DELETE_HISTORY_URL,
+ data: {
+ url: site.url,
+ pocket_id: site.pocket_id,
+ forceBlock: site.bookmarkGuid,
+ },
+ }),
+ ac.UserEvent(
+ Object.assign(
+ { event: "DELETE", source: eventSource, action_position: index },
+ siteInfo
+ )
+ ),
+ ],
+ eventSource,
+ body_string_id: [
+ "newtab-confirm-delete-history-p1",
+ "newtab-confirm-delete-history-p2",
+ ],
+ confirm_button_string_id: "newtab-topsites-delete-history-button",
+ cancel_button_string_id: "newtab-topsites-cancel-button",
+ icon: "modal-delete",
+ },
+ },
+ userEvent: "DIALOG_OPEN",
+ }),
+ ShowFile: site => ({
+ id: "newtab-menu-show-file",
+ icon: "search",
+ action: ac.OnlyToMain({
+ type: at.SHOW_DOWNLOAD_FILE,
+ data: { url: site.url },
+ }),
+ }),
+ OpenFile: site => ({
+ id: "newtab-menu-open-file",
+ icon: "open-file",
+ action: ac.OnlyToMain({
+ type: at.OPEN_DOWNLOAD_FILE,
+ data: { url: site.url },
+ }),
+ }),
+ CopyDownloadLink: site => ({
+ id: "newtab-menu-copy-download-link",
+ icon: "copy",
+ action: ac.OnlyToMain({
+ type: at.COPY_DOWNLOAD_LINK,
+ data: { url: site.url },
+ }),
+ }),
+ GoToDownloadPage: site => ({
+ id: "newtab-menu-go-to-download-page",
+ icon: "download",
+ action: ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: { url: site.referrer },
+ }),
+ disabled: !site.referrer,
+ }),
+ RemoveDownload: site => ({
+ id: "newtab-menu-remove-download",
+ icon: "delete",
+ action: ac.OnlyToMain({
+ type: at.REMOVE_DOWNLOAD_FILE,
+ data: { url: site.url },
+ }),
+ }),
+ PinTopSite: (site, index) => ({
+ id: "newtab-menu-pin",
+ icon: "pin",
+ action: ac.AlsoToMain({
+ type: at.TOP_SITES_PIN,
+ data: {
+ site,
+ index,
+ },
+ }),
+ userEvent: "PIN",
+ }),
+ UnpinTopSite: site => ({
+ id: "newtab-menu-unpin",
+ icon: "unpin",
+ action: ac.AlsoToMain({
+ type: at.TOP_SITES_UNPIN,
+ data: { site: { url: site.url } },
+ }),
+ userEvent: "UNPIN",
+ }),
+ SaveToPocket: (site, index, eventSource) => ({
+ id: "newtab-menu-save-to-pocket",
+ icon: "pocket-save",
+ action: ac.AlsoToMain({
+ type: at.SAVE_TO_POCKET,
+ data: { site: { url: site.url, title: site.title } },
+ }),
+ impression: ac.ImpressionStats({
+ source: eventSource,
+ pocket: 0,
+ tiles: [
+ {
+ id: site.guid,
+ pos: index,
+ ...(site.shim && site.shim.save ? { shim: site.shim.save } : {}),
+ },
+ ],
+ }),
+ userEvent: "SAVE_TO_POCKET",
+ }),
+ DeleteFromPocket: site => ({
+ id: "newtab-menu-delete-pocket",
+ icon: "pocket-delete",
+ action: ac.AlsoToMain({
+ type: at.DELETE_FROM_POCKET,
+ data: { pocket_id: site.pocket_id },
+ }),
+ userEvent: "DELETE_FROM_POCKET",
+ }),
+ ArchiveFromPocket: site => ({
+ id: "newtab-menu-archive-pocket",
+ icon: "pocket-archive",
+ action: ac.AlsoToMain({
+ type: at.ARCHIVE_FROM_POCKET,
+ data: { pocket_id: site.pocket_id },
+ }),
+ userEvent: "ARCHIVE_FROM_POCKET",
+ }),
+ EditTopSite: (site, index) => ({
+ id: "newtab-menu-edit-topsites",
+ icon: "edit",
+ action: {
+ type: at.TOP_SITES_EDIT,
+ data: { index },
+ },
+ }),
+ CheckBookmark: site =>
+ site.bookmarkGuid
+ ? LinkMenuOptions.RemoveBookmark(site)
+ : LinkMenuOptions.AddBookmark(site),
+ CheckPinTopSite: (site, index) =>
+ site.isPinned
+ ? LinkMenuOptions.UnpinTopSite(site)
+ : LinkMenuOptions.PinTopSite(site, index),
+ CheckSavedToPocket: (site, index) =>
+ site.pocket_id
+ ? LinkMenuOptions.DeleteFromPocket(site)
+ : LinkMenuOptions.SaveToPocket(site, index),
+ CheckBookmarkOrArchive: site =>
+ site.pocket_id
+ ? LinkMenuOptions.ArchiveFromPocket(site)
+ : LinkMenuOptions.CheckBookmark(site),
+ OpenInPrivateWindow: (site, index, eventSource, isEnabled) =>
+ isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem(),
+};
diff --git a/browser/components/newtab/content-src/lib/perf-service.js b/browser/components/newtab/content-src/lib/perf-service.js
new file mode 100644
index 0000000000..6ea99ce877
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/perf-service.js
@@ -0,0 +1,104 @@
+/* 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/. */
+
+"use strict";
+
+let usablePerfObj = window.performance;
+
+export function _PerfService(options) {
+ // For testing, so that we can use a fake Window.performance object with
+ // known state.
+ if (options && options.performanceObj) {
+ this._perf = options.performanceObj;
+ } else {
+ this._perf = usablePerfObj;
+ }
+}
+
+_PerfService.prototype = {
+ /**
+ * Calls the underlying mark() method on the appropriate Window.performance
+ * object to add a mark with the given name to the appropriate performance
+ * timeline.
+ *
+ * @param {String} name the name to give the current mark
+ * @return {void}
+ */
+ mark: function mark(str) {
+ this._perf.mark(str);
+ },
+
+ /**
+ * Calls the underlying getEntriesByName on the appropriate Window.performance
+ * object.
+ *
+ * @param {String} name
+ * @param {String} type eg "mark"
+ * @return {Array} Performance* objects
+ */
+ getEntriesByName: function getEntriesByName(name, type) {
+ return this._perf.getEntriesByName(name, type);
+ },
+
+ /**
+ * The timeOrigin property from the appropriate performance object.
+ * Used to ensure that timestamps from the add-on code and the content code
+ * are comparable.
+ *
+ * @note If this is called from a context without a window
+ * (eg a JSM in chrome), it will return the timeOrigin of the XUL hidden
+ * window, which appears to be the first created window (and thus
+ * timeOrigin) in the browser. Note also, however, there is also a private
+ * hidden window, presumably for private browsing, which appears to be
+ * created dynamically later. Exactly how/when that shows up needs to be
+ * investigated.
+ *
+ * @return {Number} A double of milliseconds with a precision of 0.5us.
+ */
+ get timeOrigin() {
+ return this._perf.timeOrigin;
+ },
+
+ /**
+ * Returns the "absolute" version of performance.now(), i.e. one that
+ * should ([bug 1401406](https://bugzilla.mozilla.org/show_bug.cgi?id=1401406)
+ * be comparable across both chrome and content.
+ *
+ * @return {Number}
+ */
+ absNow: function absNow() {
+ return this.timeOrigin + this._perf.now();
+ },
+
+ /**
+ * This returns the absolute startTime from the most recent performance.mark()
+ * with the given name.
+ *
+ * @param {String} name the name to lookup the start time for
+ *
+ * @return {Number} the returned start time, as a DOMHighResTimeStamp
+ *
+ * @throws {Error} "No Marks with the name ..." if none are available
+ *
+ * @note Always surround calls to this by try/catch. Otherwise your code
+ * may fail when the `privacy.resistFingerprinting` pref is true. When
+ * this pref is set, all attempts to get marks will likely fail, which will
+ * cause this method to throw.
+ *
+ * See [bug 1369303](https://bugzilla.mozilla.org/show_bug.cgi?id=1369303)
+ * for more info.
+ */
+ getMostRecentAbsMarkStartByName(name) {
+ let entries = this.getEntriesByName(name, "mark");
+
+ if (!entries.length) {
+ throw new Error(`No marks with the name ${name}`);
+ }
+
+ let mostRecentEntry = entries[entries.length - 1];
+ return this._perf.timeOrigin + mostRecentEntry.startTime;
+ },
+};
+
+export const perfService = new _PerfService();
diff --git a/browser/components/newtab/content-src/lib/screenshot-utils.js b/browser/components/newtab/content-src/lib/screenshot-utils.js
new file mode 100644
index 0000000000..7ea93f12ae
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/screenshot-utils.js
@@ -0,0 +1,61 @@
+/* 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/. */
+
+/**
+ * List of helper functions for screenshot-based images.
+ *
+ * There are two kinds of images:
+ * 1. Remote Image: This is the image from the main process and it refers to
+ * the image in the React props. This can either be an object with the `data`
+ * and `path` properties, if it is a blob, or a string, if it is a normal image.
+ * 2. Local Image: This is the image object in the content process and it refers
+ * to the image *object* in the React component's state. All local image
+ * objects have the `url` property, and an additional property `path`, if they
+ * are blobs.
+ */
+export const ScreenshotUtils = {
+ isBlob(isLocal, image) {
+ return !!(
+ image &&
+ image.path &&
+ ((!isLocal && image.data) || (isLocal && image.url))
+ );
+ },
+
+ // This should always be called with a remote image and not a local image.
+ createLocalImageObject(remoteImage) {
+ if (!remoteImage) {
+ return null;
+ }
+ if (this.isBlob(false, remoteImage)) {
+ return {
+ url: global.URL.createObjectURL(remoteImage.data),
+ path: remoteImage.path,
+ };
+ }
+ return { url: remoteImage };
+ },
+
+ // Revokes the object URL of the image if the local image is a blob.
+ // This should always be called with a local image and not a remote image.
+ maybeRevokeBlobObjectURL(localImage) {
+ if (this.isBlob(true, localImage)) {
+ global.URL.revokeObjectURL(localImage.url);
+ }
+ },
+
+ // Checks if remoteImage and localImage are the same.
+ isRemoteImageLocal(localImage, remoteImage) {
+ // Both remoteImage and localImage are present.
+ if (remoteImage && localImage) {
+ return this.isBlob(false, remoteImage)
+ ? localImage.path === remoteImage.path
+ : localImage.url === remoteImage;
+ }
+
+ // This will only handle the remaining three possible outcomes.
+ // (i.e. everything except when both image and localImage are present)
+ return !remoteImage && !localImage;
+ },
+};
diff --git a/browser/components/newtab/content-src/lib/section-menu-options.js b/browser/components/newtab/content-src/lib/section-menu-options.js
new file mode 100644
index 0000000000..9a3070a9f4
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/section-menu-options.js
@@ -0,0 +1,93 @@
+/* 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.jsm";
+
+/**
+ * List of functions that return items that can be included as menu options in a
+ * SectionMenu. All functions take the section as the only parameter.
+ */
+export const SectionMenuOptions = {
+ Separator: () => ({ type: "separator" }),
+ MoveUp: section => ({
+ id: "newtab-section-menu-move-up",
+ icon: "arrowhead-up",
+ action: ac.OnlyToMain({
+ type: at.SECTION_MOVE,
+ data: { id: section.id, direction: -1 },
+ }),
+ userEvent: "MENU_MOVE_UP",
+ disabled: !!section.isFirst,
+ }),
+ MoveDown: section => ({
+ id: "newtab-section-menu-move-down",
+ icon: "arrowhead-down",
+ action: ac.OnlyToMain({
+ type: at.SECTION_MOVE,
+ data: { id: section.id, direction: +1 },
+ }),
+ userEvent: "MENU_MOVE_DOWN",
+ disabled: !!section.isLast,
+ }),
+ RemoveSection: section => ({
+ id: "newtab-section-menu-remove-section",
+ icon: "dismiss",
+ action: ac.SetPref(section.showPrefName, false),
+ userEvent: "MENU_REMOVE",
+ }),
+ CollapseSection: section => ({
+ id: "newtab-section-menu-collapse-section",
+ icon: "minimize",
+ action: ac.OnlyToMain({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: section.id, value: { collapsed: true } },
+ }),
+ userEvent: "MENU_COLLAPSE",
+ }),
+ ExpandSection: section => ({
+ id: "newtab-section-menu-expand-section",
+ icon: "maximize",
+ action: ac.OnlyToMain({
+ type: at.UPDATE_SECTION_PREFS,
+ data: { id: section.id, value: { collapsed: false } },
+ }),
+ userEvent: "MENU_EXPAND",
+ }),
+ ManageSection: section => ({
+ id: "newtab-section-menu-manage-section",
+ icon: "settings",
+ action: ac.OnlyToMain({ type: at.SETTINGS_OPEN }),
+ userEvent: "MENU_MANAGE",
+ }),
+ ManageWebExtension: section => ({
+ id: "newtab-section-menu-manage-webext",
+ icon: "settings",
+ action: ac.OnlyToMain({ type: at.OPEN_WEBEXT_SETTINGS, data: section.id }),
+ }),
+ AddTopSite: section => ({
+ id: "newtab-section-menu-add-topsite",
+ icon: "add",
+ action: { type: at.TOP_SITES_EDIT, data: { index: -1 } },
+ userEvent: "MENU_ADD_TOPSITE",
+ }),
+ AddSearchShortcut: section => ({
+ id: "newtab-section-menu-add-search-engine",
+ icon: "search",
+ action: { type: at.TOP_SITES_OPEN_SEARCH_SHORTCUTS_MODAL },
+ userEvent: "MENU_ADD_SEARCH",
+ }),
+ PrivacyNotice: section => ({
+ id: "newtab-section-menu-privacy-notice",
+ icon: "info",
+ action: ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: { url: section.privacyNoticeURL },
+ }),
+ userEvent: "MENU_PRIVACY_NOTICE",
+ }),
+ CheckCollapsed: section =>
+ section.collapsed
+ ? SectionMenuOptions.ExpandSection(section)
+ : SectionMenuOptions.CollapseSection(section),
+};
diff --git a/browser/components/newtab/content-src/lib/selectLayoutRender.js b/browser/components/newtab/content-src/lib/selectLayoutRender.js
new file mode 100644
index 0000000000..f15fb777a5
--- /dev/null
+++ b/browser/components/newtab/content-src/lib/selectLayoutRender.js
@@ -0,0 +1,260 @@
+/* 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 selectLayoutRender = ({ state = {}, prefs = {}, locale = "" }) => {
+ const { layout, feeds, spocs } = state;
+ let spocIndexPlacementMap = {};
+
+ /* This function fills spoc positions on a per placement basis with available spocs.
+ * It does this by looping through each position for a placement and replacing a rec with a spoc.
+ * If it runs out of spocs or positions, it stops.
+ * If it sees the same placement again, it remembers the previous spoc index, and continues.
+ * If it sees a blocked spoc, it skips that position leaving in a regular story.
+ */
+ function fillSpocPositionsForPlacement(
+ data,
+ spocsConfig,
+ spocsData,
+ placementName
+ ) {
+ if (
+ !spocIndexPlacementMap[placementName] &&
+ spocIndexPlacementMap[placementName] !== 0
+ ) {
+ spocIndexPlacementMap[placementName] = 0;
+ }
+ const results = [...data];
+ for (let position of spocsConfig.positions) {
+ const spoc = spocsData[spocIndexPlacementMap[placementName]];
+ // If there are no spocs left, we can stop filling positions.
+ if (!spoc) {
+ break;
+ }
+
+ // A placement could be used in two sections.
+ // In these cases, we want to maintain the index of the previous section.
+ // If we didn't do this, it might duplicate spocs.
+ spocIndexPlacementMap[placementName]++;
+
+ // A spoc that's blocked is removed from the source for subsequent newtab loads.
+ // If we have a spoc in the source that's blocked, it means it was *just* blocked,
+ // and in this case, we skip this position, and show a regular spoc instead.
+ if (!spocs.blocked.includes(spoc.url)) {
+ results.splice(position.index, 0, spoc);
+ }
+ }
+
+ return results;
+ }
+
+ const positions = {};
+ const DS_COMPONENTS = [
+ "Message",
+ "TextPromo",
+ "SectionTitle",
+ "Signup",
+ "Navigation",
+ "CardGrid",
+ "CollectionCardGrid",
+ "Hero",
+ "HorizontalRule",
+ "List",
+ ];
+
+ const filterArray = [];
+
+ if (!prefs["feeds.topsites"]) {
+ filterArray.push("TopSites");
+ }
+
+ if (!locale.startsWith("en-")) {
+ filterArray.push("Navigation");
+ }
+
+ const pocketEnabled =
+ prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"];
+ if (!pocketEnabled) {
+ filterArray.push(...DS_COMPONENTS);
+ }
+
+ const placeholderComponent = component => {
+ if (!component.feed) {
+ // TODO we now need a placeholder for topsites and textPromo.
+ return {
+ ...component,
+ data: {
+ spocs: [],
+ },
+ };
+ }
+ const data = {
+ recommendations: [],
+ };
+
+ let items = 0;
+ if (component.properties && component.properties.items) {
+ items = component.properties.items;
+ }
+ for (let i = 0; i < items; i++) {
+ data.recommendations.push({ placeholder: true });
+ }
+
+ return { ...component, data };
+ };
+
+ // TODO update devtools to show placements
+ const handleSpocs = (data, component) => {
+ let result = [...data];
+ // Do we ever expect to possibly have a spoc.
+ if (
+ component.spocs &&
+ component.spocs.positions &&
+ component.spocs.positions.length
+ ) {
+ const placement = component.placement || {};
+ const placementName = placement.name || "spocs";
+ const spocsData = spocs.data[placementName];
+ // We expect a spoc, spocs are loaded, and the server returned spocs.
+ if (
+ spocs.loaded &&
+ spocsData &&
+ spocsData.items &&
+ spocsData.items.length
+ ) {
+ result = fillSpocPositionsForPlacement(
+ result,
+ component.spocs,
+ spocsData.items,
+ placementName
+ );
+ }
+ }
+ return result;
+ };
+
+ const handleComponent = component => {
+ if (
+ component.spocs &&
+ component.spocs.positions &&
+ component.spocs.positions.length
+ ) {
+ const placement = component.placement || {};
+ const placementName = placement.name || "spocs";
+ const spocsData = spocs.data[placementName];
+ if (
+ spocs.loaded &&
+ spocsData &&
+ spocsData.items &&
+ spocsData.items.length
+ ) {
+ return {
+ ...component,
+ data: {
+ spocs: spocsData.items
+ .filter(spoc => spoc && !spocs.blocked.includes(spoc.url))
+ .map((spoc, index) => ({
+ ...spoc,
+ pos: index,
+ })),
+ },
+ };
+ }
+ }
+ return {
+ ...component,
+ data: {
+ spocs: [],
+ },
+ };
+ };
+
+ const handleComponentWithFeed = component => {
+ positions[component.type] = positions[component.type] || 0;
+ let data = {
+ recommendations: [],
+ };
+
+ const feed = feeds.data[component.feed.url];
+ if (feed && feed.data) {
+ data = {
+ ...feed.data,
+ recommendations: [...(feed.data.recommendations || [])],
+ };
+ }
+
+ if (component && component.properties && component.properties.offset) {
+ data = {
+ ...data,
+ recommendations: data.recommendations.slice(
+ component.properties.offset
+ ),
+ };
+ }
+
+ data = {
+ ...data,
+ recommendations: handleSpocs(data.recommendations, component),
+ };
+
+ let items = 0;
+ if (component.properties && component.properties.items) {
+ items = Math.min(component.properties.items, data.recommendations.length);
+ }
+
+ // loop through a component items
+ // Store the items position sequentially for multiple components of the same type.
+ // Example: A second card grid starts pos offset from the last card grid.
+ for (let i = 0; i < items; i++) {
+ data.recommendations[i] = {
+ ...data.recommendations[i],
+ pos: positions[component.type]++,
+ };
+ }
+
+ return { ...component, data };
+ };
+
+ const renderLayout = () => {
+ const renderedLayoutArray = [];
+ for (const row of layout.filter(
+ r => r.components.filter(c => !filterArray.includes(c.type)).length
+ )) {
+ let components = [];
+ renderedLayoutArray.push({
+ ...row,
+ components,
+ });
+ for (const component of row.components.filter(
+ c => !filterArray.includes(c.type)
+ )) {
+ const spocsConfig = component.spocs;
+ if (spocsConfig || component.feed) {
+ // TODO make sure this still works for different loading cases.
+ if (
+ (component.feed && !feeds.data[component.feed.url]) ||
+ (spocsConfig &&
+ spocsConfig.positions &&
+ spocsConfig.positions.length &&
+ !spocs.loaded)
+ ) {
+ components.push(placeholderComponent(component));
+ return renderedLayoutArray;
+ }
+ if (component.feed) {
+ components.push(handleComponentWithFeed(component));
+ } else {
+ components.push(handleComponent(component));
+ }
+ } else {
+ components.push(component);
+ }
+ }
+ }
+ return renderedLayoutArray;
+ };
+
+ const layoutRender = renderLayout();
+
+ return { layoutRender };
+};
diff --git a/browser/components/newtab/content-src/styles/_OnboardingImages.scss b/browser/components/newtab/content-src/styles/_OnboardingImages.scss
new file mode 100644
index 0000000000..cd7fa48ad2
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/_OnboardingImages.scss
@@ -0,0 +1,71 @@
+// Used for Trailhead and about:welcome
+
+.onboardingMessageImage {
+ &.addons {
+ background-image: url('chrome://activity-stream/content/data/content/assets/illustration-addons@2x.png');
+ }
+
+ &.privatebrowsing {
+ background-image: url('chrome://activity-stream/content/data/content/assets/illustration-privatebrowsing@2x.png');
+ }
+
+ &.screenshots {
+ background-image: url('chrome://activity-stream/content/data/content/assets/illustration-screenshots@2x.png');
+ }
+
+ &.gift {
+ background-image: url('chrome://activity-stream/content/data/content/assets/illustration-gift@2x.png');
+ }
+
+ &.sync {
+ background-image: url('chrome://activity-stream/content/data/content/assets/illustration-sync@2x.png');
+ }
+
+ &.devices {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-devices.svg');
+ }
+
+ &.fbcont {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-fbcont.svg');
+ }
+
+ &.import {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-import.svg');
+ }
+
+ &.ffmonitor {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-ffmonitor.svg');
+ }
+
+ &.ffsend {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-ffsend.svg');
+ }
+
+ &.lockwise {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-lockwise.svg');
+ }
+
+ &.mobile {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-mobile.svg');
+ }
+
+ &.pledge {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-pledge.svg');
+ }
+
+ &.pocket {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-pocket.svg');
+ }
+
+ &.private {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-private.svg');
+ }
+
+ &.sendtab {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-sendtab.svg');
+ }
+
+ &.tracking {
+ background-image: url('chrome://activity-stream/content/data/content/assets/trailhead/card-illo-tracking.svg');
+ }
+}
diff --git a/browser/components/newtab/content-src/styles/_activity-stream.scss b/browser/components/newtab/content-src/styles/_activity-stream.scss
new file mode 100644
index 0000000000..702ef9bdd1
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/_activity-stream.scss
@@ -0,0 +1,179 @@
+@import './normalize';
+@import './variables';
+@import './theme';
+@import './icons';
+@import './mixins';
+
+html {
+ height: 100%;
+}
+
+body,
+#root { // sass-lint:disable-line no-ids
+ min-height: 100vh;
+}
+
+#root { // sass-lint:disable-line no-ids
+ position: relative;
+}
+
+body {
+ background-color: var(--newtab-background-color);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif;
+ font-size: 16px;
+}
+
+.no-scroll {
+ overflow: hidden;
+}
+
+h1,
+h2 {
+ font-weight: normal;
+}
+
+a {
+ text-decoration: none;
+}
+
+.inner-border {
+ border: $border-secondary;
+ border-radius: $border-radius;
+ height: 100%;
+ left: 0;
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ z-index: 100;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+.show-on-init {
+ opacity: 0;
+ transition: opacity 0.2s ease-in;
+
+ &.on {
+ animation: fadeIn 0.2s;
+ opacity: 1;
+ }
+}
+
+.actions {
+ border-top: $border-secondary;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ margin: 0;
+ padding: 15px 25px 0;
+}
+
+// Default button (grey)
+.button,
+.actions button {
+ background-color: var(--newtab-button-secondary-color);
+ border: $border-primary;
+ border-radius: 4px;
+ color: inherit;
+ cursor: pointer;
+ margin-bottom: 15px;
+ padding: 10px 30px;
+ white-space: nowrap;
+
+ &:hover:not(.dismiss),
+ &:focus:not(.dismiss) {
+ box-shadow: $shadow-primary;
+ transition: box-shadow 150ms;
+ }
+
+ &.dismiss {
+ background-color: transparent;
+ border: 0;
+ padding: 0;
+ text-decoration: underline;
+ }
+
+ // Blue button
+ &.primary,
+ &.done {
+ background-color: var(--newtab-button-primary-color);
+ border: solid 1px var(--newtab-button-primary-color);
+ color: $white;
+ margin-inline-start: auto;
+ }
+}
+
+input {
+ &[type='text'],
+ &[type='search'] {
+ border-radius: $border-radius;
+ }
+}
+
+// These styles are needed for -webkit-line-clamp to work correctly, so reuse
+// this class name while separately setting a clamp value via CSS or JS.
+.clamp {
+ -webkit-box-orient: vertical;
+ display: -webkit-box;
+ overflow: hidden;
+ word-break: break-word;
+}
+
+// Components
+@import '../components/A11yLinkButton/A11yLinkButton';
+@import '../components/Base/Base';
+@import '../components/ErrorBoundary/ErrorBoundary';
+@import '../components/TopSites/TopSites';
+@import '../components/Sections/Sections';
+@import '../components/Topics/Topics';
+@import '../components/Search/Search';
+@import '../components/ContextMenu/ContextMenu';
+@import '../components/ConfirmDialog/ConfirmDialog';
+@import '../components/CustomizeMenu/CustomizeMenu';
+@import '../components/Card/Card';
+@import '../components/CollapsibleSection/CollapsibleSection';
+@import '../components/ASRouterAdmin/ASRouterAdmin';
+@import '../components/PocketLoggedInCta/PocketLoggedInCta';
+@import '../components/MoreRecommendations/MoreRecommendations';
+@import '../components/DiscoveryStreamBase/DiscoveryStreamBase';
+
+// Discovery Stream Components
+@import '../components/DiscoveryStreamComponents/CardGrid/CardGrid';
+@import '../components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid';
+@import '../components/DiscoveryStreamComponents/Hero/Hero';
+@import '../components/DiscoveryStreamComponents/Highlights/Highlights';
+@import '../components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule';
+@import '../components/DiscoveryStreamComponents/List/List';
+@import '../components/DiscoveryStreamComponents/Navigation/Navigation';
+@import '../components/DiscoveryStreamComponents/SectionTitle/SectionTitle';
+@import '../components/DiscoveryStreamComponents/TopSites/TopSites';
+@import '../components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu';
+@import '../components/DiscoveryStreamComponents/DSCard/DSCard';
+@import '../components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter';
+@import '../components/DiscoveryStreamComponents/DSImage/DSImage';
+@import '../components/DiscoveryStreamComponents/DSDismiss/DSDismiss';
+@import '../components/DiscoveryStreamComponents/DSMessage/DSMessage';
+@import '../components/DiscoveryStreamImpressionStats/ImpressionStats';
+@import '../components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState';
+@import '../components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo';
+@import '../components/DiscoveryStreamComponents/DSSignup/DSSignup';
+@import '../components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal';
+
+// AS Router
+@import '../asrouter/components/Button/Button';
+@import '../asrouter/components/SnippetBase/SnippetBase';
+@import '../asrouter/components/ModalOverlay/ModalOverlay';
+@import '../asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet';
+@import '../asrouter/templates/SimpleSnippet/SimpleSnippet';
+@import '../asrouter/templates/SubmitFormSnippet/SubmitFormSnippet';
+@import '../asrouter/templates/EOYSnippet/EOYSnippet';
diff --git a/browser/components/newtab/content-src/styles/_icons.scss b/browser/components/newtab/content-src/styles/_icons.scss
new file mode 100644
index 0000000000..f665dc5fb3
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/_icons.scss
@@ -0,0 +1,208 @@
+.icon {
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: $icon-size;
+ -moz-context-properties: fill;
+ display: inline-block;
+ color: var(--newtab-icon-primary-color);
+ fill: currentColor;
+ height: $icon-size;
+ vertical-align: middle;
+ width: $icon-size;
+
+ // helper classes
+ &.icon-spacer {
+ margin-inline-end: 8px;
+ }
+
+ &.icon-small-spacer {
+ margin-inline-end: 6px;
+ }
+
+ &.icon-button-style {
+ fill: var(--newtab-icon-secondary-color);
+ border: 0;
+
+ &:focus,
+ &:hover {
+ fill: var(--newtab-text-primary-color);
+ }
+ }
+
+ // icon images
+ &.icon-bookmark-added {
+ background-image: url('chrome://browser/skin/bookmark.svg');
+ }
+
+ &.icon-bookmark-hollow {
+ background-image: url('chrome://browser/skin/bookmark-hollow.svg');
+ }
+
+ &.icon-clear-input {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-cancel-16.svg');
+ }
+
+ &.icon-delete {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-delete-16.svg');
+ }
+
+ &.icon-search {
+ background-image: url('chrome://browser/skin/search-glass.svg');
+ }
+
+ &.icon-modal-delete {
+ flex-shrink: 0;
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-modal-delete-32.svg');
+ background-size: $larger-icon-size;
+ height: $larger-icon-size;
+ width: $larger-icon-size;
+ }
+
+ &.icon-mail {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-mail-16.svg');
+ }
+
+ &.icon-dismiss {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-dismiss-16.svg');
+ }
+
+ &.icon-info {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-info-16.svg');
+ }
+
+ &.icon-new-window {
+ @include flip-icon;
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-newWindow-16.svg');
+ }
+
+ &.icon-new-window-private {
+ background-image: url('chrome://browser/skin/privateBrowsing.svg');
+ }
+
+ &.icon-settings {
+ background-image: url('chrome://global/skin/icons/settings.svg');
+ }
+
+ &.icon-pin {
+ @include flip-icon;
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pin-16.svg');
+ }
+
+ &.icon-unpin {
+ @include flip-icon;
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-unpin-16.svg');
+ }
+
+ &.icon-edit {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-edit-16.svg');
+ }
+
+ &.icon-pocket {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-16.svg');
+ }
+
+ &.icon-pocket-save {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-save-16.svg');
+ }
+
+ &.icon-pocket-delete {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-delete-16.svg');
+ }
+
+ &.icon-pocket-archive {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pocket-archive-16.svg');
+ }
+
+ &.icon-history-item {
+ background-image: url('chrome://browser/skin/history.svg');
+ }
+
+ &.icon-trending {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-trending-16.svg');
+ transform: translateY(2px); // trending bolt is visually top heavy
+ }
+
+ &.icon-now {
+ background-image: url('chrome://browser/skin/history.svg');
+ }
+
+ &.icon-topsites {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-topsites-16.svg');
+ }
+
+ &.icon-pin-small {
+ @include flip-icon;
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-pin-12.svg');
+ background-size: $smaller-icon-size;
+ height: $smaller-icon-size;
+ width: $smaller-icon-size;
+ }
+
+ &.icon-check {
+ background-image: url('chrome://global/skin/icons/check.svg');
+ }
+
+ &.icon-download {
+ background-image: url('chrome://browser/skin/downloads/download-icons.svg#arrow-with-bar');
+ }
+
+ &.icon-copy {
+ background-image: url('chrome://browser/skin/edit-copy.svg');
+ }
+
+ &.icon-open-file {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-open-file-16.svg');
+ }
+
+ &.icon-webextension {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-webextension-16.svg');
+ }
+
+ &.icon-highlights {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-highlights-16.svg');
+ }
+
+ &.icon-arrowhead-down {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrowhead-down-16.svg');
+ }
+
+ &.icon-arrowhead-down-small {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrowhead-down-12.svg');
+ background-size: $smaller-icon-size;
+ height: $smaller-icon-size;
+ width: $smaller-icon-size;
+ }
+
+ &.icon-arrowhead-forward-small {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrowhead-down-12.svg');
+ background-size: $smaller-icon-size;
+ height: $smaller-icon-size;
+ transform: rotate(-90deg);
+ width: $smaller-icon-size;
+
+ &:dir(rtl) {
+ transform: rotate(90deg);
+ }
+ }
+
+ &.icon-arrowhead-up {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrowhead-down-16.svg');
+ transform: rotate(180deg);
+ }
+
+ &.icon-add {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-add-16.svg');
+ }
+
+ &.icon-minimize {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-minimize-16.svg');
+ }
+
+ &.icon-maximize {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-maximize-16.svg');
+ }
+
+ &.icon-arrow {
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-arrow.svg');
+ }
+}
diff --git a/browser/components/newtab/content-src/styles/_mixins.scss b/browser/components/newtab/content-src/styles/_mixins.scss
new file mode 100644
index 0000000000..0189408b06
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/_mixins.scss
@@ -0,0 +1,50 @@
+// Shared styling of article images shown as background
+@mixin image-as-background {
+ background-color: var(--newtab-card-placeholder-color);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ border-radius: 4px;
+ box-shadow: inset 0 0 0 0.5px $black-15;
+}
+
+// Note: lineHeight and fontSize should be unitless but can be derived from pixel values
+// Bug 1550624 to clean up / remove this mixin to avoid duplicate styles
+@mixin limit-visible-lines($line-count, $line-height, $font-size) {
+ font-size: $font-size * 1px;
+ -webkit-line-clamp: $line-count;
+ line-height: $line-height * 1px;
+}
+
+@mixin dark-theme-only {
+ [lwt-newtab-brighttext] & {
+ @content;
+ }
+}
+
+@mixin ds-border-top {
+ @content;
+
+ @include dark-theme-only {
+ border-top: 1px solid $grey-60;
+ }
+
+ border-top: 1px solid $grey-30;
+}
+
+@mixin ds-border-bottom {
+ @content;
+
+ @include dark-theme-only {
+ border-bottom: 1px solid $grey-60;
+ }
+
+ border-bottom: 1px solid $grey-30;
+}
+
+@mixin ds-fade-in($halo-color: $blue-50-30) {
+ box-shadow: 0 0 0 5px $halo-color;
+ transition: box-shadow 150ms;
+ border-radius: 4px;
+ outline: none;
+}
diff --git a/browser/components/newtab/content-src/styles/_normalize.scss b/browser/components/newtab/content-src/styles/_normalize.scss
new file mode 100644
index 0000000000..32f5ef3677
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/_normalize.scss
@@ -0,0 +1,29 @@
+html {
+ box-sizing: border-box;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: inherit;
+}
+
+*::-moz-focus-inner {
+ border: 0;
+}
+
+body {
+ margin: 0;
+}
+
+button,
+input {
+ background-color: inherit;
+ color: inherit;
+ font-family: inherit;
+ font-size: inherit;
+}
+
+[hidden] {
+ display: none !important; // sass-lint:disable-line no-important
+}
diff --git a/browser/components/newtab/content-src/styles/_theme.scss b/browser/components/newtab/content-src/styles/_theme.scss
new file mode 100644
index 0000000000..29e313873e
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/_theme.scss
@@ -0,0 +1,231 @@
+@function textbox-shadow($color) {
+ @return 0 0 0 1px $color, 0 0 0 $textbox-shadow-size rgba($color, 0.3);
+}
+
+@mixin textbox-focus($color) {
+ --newtab-textbox-focus-color: #{$color};
+ --newtab-textbox-focus-boxshadow: #{textbox-shadow($color)};
+}
+
+// scss variables related to the theme.
+$border-primary: 1px solid var(--newtab-border-primary-color);
+$border-secondary: 1px solid var(--newtab-border-secondary-color);
+$inner-box-shadow: 0 0 0 1px var(--newtab-inner-box-shadow-color);
+$inner-box-shadow-nte: 0 3px 8px var(--newtab-inner-box-shadow-color-nte);
+$tile-shadow-second: 0 0 2px var(--newtab-tile-shadow-secondary);
+$input-border: 1px solid var(--newtab-textbox-border);
+$input-border-active: 1px solid var(--newtab-textbox-focus-color);
+$input-error-border: 1px solid $red-60;
+$input-error-boxshadow: textbox-shadow($red-60);
+$shadow-primary: 0 0 0 5px var(--newtab-card-active-outline-color);
+$shadow-secondary: 0 1px 4px 0 $grey-90-20;
+
+// Default theme
+body {
+ // General styles
+ --newtab-background-color: #{$grey-10};
+ --newtab-border-primary-color: #{$grey-40};
+ --newtab-border-secondary-color: #{$grey-30};
+ --newtab-element-active-color: #{$grey-30-60};
+ --newtab-element-hover-color: #{$grey-20};
+ --newtab-icon-primary-color: #{$grey-90-80};
+ --newtab-icon-secondary-color: #{$grey-90-60};
+ --newtab-icon-tertiary-color: #{$grey-30};
+ --newtab-inner-box-shadow-color: #{$black-10};
+ --newtab-inner-box-shadow-color-nte: #{$newtab-card-firstshadow};
+ --newtab-tile-shadow-secondary: #{$newtab-tile-shadow-secondary};
+
+ --newtab-link-primary-color: #{$blue-60};
+ --newtab-link-secondary-color: #{$teal-70};
+ --newtab-text-conditional-color: #{$grey-60};
+ --newtab-text-primary-color: #{$grey-90};
+ --newtab-text-secondary-color: #{$grey-50};
+ --newtab-textbox-background-color: #{$white};
+ --newtab-textbox-border: #{$grey-90-20};
+ @include textbox-focus($blue-60); // sass-lint:disable-line mixins-before-declarations
+
+ // Background buttons
+ --newtab-background-button-color: #{$newtab-background-button-default-color};
+ --newtab-background-button-text-color: #{$newtab-background-button-default-text-color};
+ --newtab-background-button-hover-color: #{$newtab-background-button-default-hover-color};
+ --newtab-background-button-active-color: #{$newtab-background-button-default-active-color};
+
+ // Buttons
+ --newtab-button-primary-color: #{$blue-60};
+ --newtab-button-secondary-color: inherit;
+ // Feed buttons
+ --newtab-feed-button-background: #{$grey-20};
+ --newtab-feed-button-text: #{$grey-90};
+ --newtab-feed-button-background-faded: #{$grey-20-60};
+ --newtab-feed-button-text-faded: #{$grey-90-00};
+ --newtab-feed-button-spinner: #{$grey-50};
+
+
+ // Context menu
+ --newtab-contextmenu-background-color: #{$grey-10};
+ --newtab-contextmenu-button-color: #{$white};
+
+ // Modal + overlay
+ --newtab-modal-color: #{$white};
+ --newtab-overlay-color: #{$grey-20-80};
+
+ // Sections
+ --newtab-section-header-text-color: #{$grey-50};
+ --newtab-section-navigation-text-color: #{$grey-50};
+ --newtab-section-active-contextmenu-color: #{$grey-90};
+
+ // Search
+ --newtab-search-border-color: transparent;
+ --newtab-search-dropdown-color: #{$white};
+ --newtab-search-dropdown-header-color: #{$grey-10};
+ --newtab-search-header-background-color: #{$grey-10-95};
+ --newtab-search-icon-color: #{$grey-90-40};
+ --newtab-search-wordmark-color: #{$firefox-wordmark-default-color};
+
+ // Top Sites
+ --newtab-topsites-background-color: #{$white};
+ --newtab-topsites-icon-shadow: inset #{$inner-box-shadow};
+ --newtab-topsites-label-color: inherit;
+ --newtab-topsites-outer-card-hover: #{$newtab-card-hover-color};
+ --newtab-topsites-context-menu-hover: #{$newtab-card-hover-color};
+
+ // Cards
+ --newtab-card-active-outline-color: #{$grey-30};
+ --newtab-card-background-color: #{$white};
+ --newtab-card-hairline-color: #{$black-10};
+ --newtab-card-placeholder-color: #{$grey-30};
+ --newtab-card-shadow: 0 1px 4px 0 #{$grey-90-10};
+
+ // Snippets
+ --newtab-snippets-background-color: #{$white};
+ --newtab-snippets-hairline-color: transparent;
+
+ // New New Tab
+ --newtab-background-primary-text-color: #{$newtab-background-primary-text-color};
+ --newtab-focus-outline: #{$newtab-focus-outline-color};
+ --newtab-focus-border: #{$blue-60};
+ --newtab-focus-border-selected: #{$newtab-card-tint};
+ --newtab-seperator-line-color: #{$newtab-card-separator-line-color};
+ --newtab-primary-action-background: #{$blue-60};
+ --newtab-primary-action-background-off: #{$newtab-primary-action-background-off};
+ --customize-menu-primary-text-color: #{$newtab-card-primary-text-color};
+ --customize-menu-check-fill: #{$white};
+ --customize-menu-background: #{$white};
+ --customize-menu-secondary-action-background: #{$grey-10};
+ --customize-menu-secondary-action-background-hover: #{$grey-20};
+ --customize-menu-secondary-action-background-active: #{$newtab-card-secondary-action-background-active};
+ --customize-menu-seperator-line-color: #{$newtab-card-separator-line-color};
+ --customize-menu-first-shadow: #{$newtab-card-firstshadow};
+ --customize-menu-second-shadow: #{$newtab-card-secondshadow};
+ --customize-menu-primary-action-text: #{$white};
+ --customize-menu-line-color: #{$newtab-card-line-color};
+ --newtab-search-first-shadow: #{$newtab-card-firstshadow};
+ --newtab-search-second-shadow: #{$newtab-card-secondshadow};
+ --newtab-search-text-color: #{$newtab-card-secondary-text-color};
+ --newtab-card-first-shadow: #{$newtab-card-firstshadow};
+ --newtab-card-second-shadow: #{$newtab-card-secondshadow};
+ --newtab-wordmark-color: #{$newtab-wordmark-default-color};
+
+ &[lwt-newtab-brighttext] {
+ // General styles
+ --newtab-background-color: #{$grey-80};
+ --newtab-border-primary-color: #{$grey-10-80};
+ --newtab-border-secondary-color: #{$grey-10-10};
+ --newtab-button-primary-color: #{$blue-60};
+ --newtab-button-secondary-color: #{$grey-70};
+ --newtab-element-active-color: #{$grey-10-20};
+ --newtab-element-hover-color: #{$grey-10-10};
+ --newtab-icon-primary-color: #{$grey-10-80};
+ --newtab-icon-secondary-color: #{$grey-10-40};
+ --newtab-icon-tertiary-color: #{$grey-10-40};
+ --newtab-inner-box-shadow-color: #{$grey-10-20};
+ --newtab-inner-box-shadow-color-nte: #{$newtab-card-darktheme-firstshadow};
+ --newtab-tile-shadow-secondary: #{$newtab-tile-darktheme-shadow-secondary};
+ --newtab-link-primary-color: #{$blue-40};
+ --newtab-link-secondary-color: #{$pocket-teal};
+ --newtab-text-conditional-color: #{$grey-10};
+ --newtab-text-primary-color: #{$grey-10};
+ --newtab-text-secondary-color: #{$grey-10-80};
+ --newtab-textbox-background-color: #{$grey-70};
+ --newtab-textbox-border: #{$grey-10-20};
+ @include textbox-focus($blue-40); // sass-lint:disable-line mixins-before-declarations
+
+ // Background buttons.
+ --newtab-background-button-color: #{$newtab-background-button-darktheme-color};
+ --newtab-background-button-text-color: #{$newtab-background-button-darktheme-text-color};
+ --newtab-background-button-hover-color: #{$newtab-background-button-darktheme-hover-color};
+ --newtab-background-button-active-color: #{$newtab-background-button-darktheme-active-color};
+
+ // Feed buttons
+ --newtab-feed-button-background: #{$grey-70};
+ --newtab-feed-button-text: #{$grey-10};
+ --newtab-feed-button-background-faded: #{$grey-70-60};
+ --newtab-feed-button-text-faded: #{$grey-10-00};
+ --newtab-feed-button-spinner: #{$grey-30};
+
+ // Context menu
+ --newtab-contextmenu-background-color: #{$grey-60};
+ --newtab-contextmenu-button-color: #{$grey-80};
+
+ // Modal + overlay
+ --newtab-modal-color: #{$grey-80};
+ --newtab-overlay-color: #{$grey-90-80};
+
+ // Sections
+ --newtab-section-header-text-color: #{$grey-10-80};
+ --newtab-section-navigation-text-color: #{$grey-10-80};
+ --newtab-section-active-contextmenu-color: #{$white};
+
+ // Search
+ --newtab-search-border-color: #{$grey-10-20};
+ --newtab-search-dropdown-color: #{$grey-70};
+ --newtab-search-dropdown-header-color: #{$grey-60};
+ --newtab-search-header-background-color: #{$grey-80-95};
+ --newtab-search-icon-color: #{$grey-10-60};
+ --newtab-search-wordmark-color: #{$firefox-wordmark-darktheme-color};
+
+ // Top Sites
+ --newtab-topsites-background-color: #{$grey-70};
+ --newtab-topsites-icon-shadow: none;
+ --newtab-topsites-label-color: #{$grey-10-80};
+ --newtab-topsites-outer-card-hover: #{$newtab-card-darktheme-hover-color};
+ --newtab-topsites-context-menu-hover: #{$newtab-card-darktheme-hover-color};
+
+ // Cards
+ --newtab-card-active-outline-color: #{$grey-60};
+ --newtab-card-background-color: #{$grey-70};
+ --newtab-card-hairline-color: #{$grey-10-10};
+ --newtab-card-placeholder-color: #{$grey-60};
+ --newtab-card-shadow: 0 1px 8px 0 #{$grey-90-20};
+
+ // Snippets
+ --newtab-snippets-background-color: #{$grey-70};
+ --newtab-snippets-hairline-color: #{$white-10};
+
+ // New New Tab
+ --newtab-background-primary-text-color: #{$newtab-background-darktheme-primary-text-color};
+ --newtab-focus-outline: #{$newtab-darktheme-focus-outline-color};
+ --newtab-focus-border: #{$newtab-darktheme-focus-border};
+ --newtab-focus-border-selected: #{$newtab-darktheme-focus-border-selected};
+ --newtab-primary-action-background: #{$newtab-darktheme-primary-action-background};
+ --newtab-primary-action-background-off: #{$newtab-darktheme-primary-action-background-off};
+ --newtab-seperator-line-color: #{$newtab-card-darktheme-separator-line-color};
+ --customize-menu-primary-text-color: #{$newtab-card-darktheme-primary-text-color};
+ --customize-menu-check-fill: #{$newtab-card-darktheme-primary-text-color};
+ --customize-menu-background: #{$grey-70};
+ --customize-menu-secondary-action-background: #{$newtab-card-darktheme-secondary-action-background};
+ --customize-menu-secondary-action-background-hover: #{$newtab-card-darktheme-secondary-action-background-hover};
+ --customize-menu-secondary-action-background-active: #{$newtab-card-darktheme-secondary-action-background-active};
+ --customize-menu-seperator-line-color: #{$newtab-card-darktheme-separator-line-color};
+ --customize-menu-first-shadow: #{$newtab-card-darktheme-firstshadow};
+ --customize-menu-second-shadow: #{$newtab-darktheme-card-secondshadow};
+ --customize-menu-primary-action-text: #{$newtab-card-darktheme-primary-text-color};
+ --customize-menu-line-color: #{$newtab-card-darktheme-line-color};
+ --newtab-search-first-shadow: #{$newtab-card-darktheme-firstshadow};
+ --newtab-search-second-shadow: #{$newtab-darktheme-card-secondshadow};
+ --newtab-search-text-color: #{$newtab-card-darktheme-secondary-text-color};
+ --newtab-card-first-shadow: #{$newtab-card-darktheme-firstshadow};
+ --newtab-card-second-shadow: #{$newtab-darktheme-card-secondshadow};
+ --newtab-wordmark-color: #{$firefox-wordmark-darktheme-color};
+ }
+}
diff --git a/browser/components/newtab/content-src/styles/_variables.scss b/browser/components/newtab/content-src/styles/_variables.scss
new file mode 100644
index 0000000000..b43b9fe924
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/_variables.scss
@@ -0,0 +1,323 @@
+// Photon colors from http://design.firefox.com/photon/visuals/color.html
+$blue-40: #45A1FF;
+$blue-50: #0A84FF;
+$blue-60: #0060DF;
+$blue-70: #003EAA;
+$blue-80: #002275;
+$grey-10: #F9F9FA;
+$grey-20: #EDEDF0;
+$grey-30: #D7D7DB;
+$grey-40: #B1B1B3;
+$grey-50: #737373;
+$grey-60: #4A4A4F;
+$grey-70: #38383D;
+$grey-80: #2A2A2E;
+$grey-90: #0C0C0D;
+$teal-10: #A7FFFE;
+$teal-60: #00C8D7;
+$teal-70: #008EA4;
+$teal-80: #005A71;
+$red-60: #D70022;
+$yellow-50: #FFE900;
+$violet-20: #CB9EFF;
+
+// Photon opacity from http://design.firefox.com/photon/visuals/color.html#opacity
+$grey-10-00: rgba($grey-10, 0);
+$grey-10-10: rgba($grey-10, 0.1);
+$grey-10-20: rgba($grey-10, 0.2);
+$grey-10-30: rgba($grey-10, 0.3);
+$grey-10-40: rgba($grey-10, 0.4);
+$grey-10-50: rgba($grey-10, 0.5);
+$grey-10-60: rgba($grey-10, 0.6);
+$grey-10-80: rgba($grey-10, 0.8);
+$grey-10-95: rgba($grey-10, 0.95);
+$grey-20-60: rgba($grey-20, 0.6);
+$grey-20-80: rgba($grey-20, 0.8);
+$grey-30-60: rgba($grey-30, 0.6);
+$grey-60-60: rgba($grey-60, 0.6);
+$grey-60-70: rgba($grey-60, 0.7);
+$grey-70-40: rgba($grey-70, 0.4);
+$grey-70-60: rgba($grey-70, 0.6);
+$grey-80-95: rgba($grey-80, 0.95);
+$grey-90-00: rgba($grey-90, 0);
+$grey-90-10: rgba($grey-90, 0.1);
+$grey-90-20: rgba($grey-90, 0.2);
+$grey-90-30: rgba($grey-90, 0.3);
+$grey-90-40: rgba($grey-90, 0.4);
+$grey-90-50: rgba($grey-90, 0.5);
+$grey-90-60: rgba($grey-90, 0.6);
+$grey-90-70: rgba($grey-90, 0.7);
+$grey-90-80: rgba($grey-90, 0.8);
+$grey-90-90: rgba($grey-90, 0.9);
+
+$blue-40-40: rgba($blue-40, 0.4);
+$blue-50-50: rgba($blue-50, 0.5);
+$blue-50-30: rgba($blue-50, 0.3);
+$blue-50-50: rgba($blue-50, 0.5);
+
+$black: #000;
+$black-5: rgba($black, 0.05);
+$black-10: rgba($black, 0.1);
+$black-12: rgba($black, 0.12);
+$black-15: rgba($black, 0.15);
+$black-20: rgba($black, 0.2);
+$black-25: rgba($black, 0.25);
+$black-30: rgba($black, 0.3);
+
+// Other colors
+$white: #FFF;
+$white-0: rgba($white, 0);
+$white-10: rgba($white, 0.1);
+$white-50: rgba($white, 0.5);
+$white-60: rgba($white, 0.6);
+$white-70: rgba($white, 0.7);
+$white-100: rgba($white, 1);
+$ghost-white: #FAFAFC;
+$pocket-teal: #50BCB6;
+$pocket-red: #EF4056;
+$shadow-10: rgba(12, 12, 13, 0.1);
+$bookmark-icon-fill: #0A84FF;
+$download-icon-fill: #12BC00;
+$pocket-icon-fill: #D70022;
+$email-input-focus: rgba($blue-50, 0.3);
+$email-input-invalid: rgba($red-60, 0.3);
+$aw-extra-blue-1: #004EC2;
+$aw-extra-blue-2: #0080FF;
+$aw-extra-blue-3: #00C7FF;
+$about-welcome-gradient: linear-gradient(to bottom, $blue-70 40%, $aw-extra-blue-1 60%, $blue-60 80%, $aw-extra-blue-2 90%, $aw-extra-blue-3 100%);
+$about-welcome-extra-links: #676F7E;
+$firefox-wordmark-default-color: #363959;
+$firefox-wordmark-darktheme-color: $white;
+
+// New New Tab Experience colors.
+$newtab-background-button-default-color: rgba(223, 223, 223, 0.5);
+$newtab-background-button-darktheme-color: rgba(80, 80, 80, 0.5);
+$newtab-background-button-default-text-color: #484848;
+$newtab-background-button-darktheme-text-color: #CDCDD4;
+$newtab-background-button-default-hover-color: rgba(196, 196, 196, 0.5);
+$newtab-background-button-darktheme-hover-color: rgba(114, 114, 114, 0.5);
+$newtab-background-button-default-active-color: rgba(151, 151, 151, 0.5);
+$newtab-background-button-darktheme-active-color: rgba(173, 173, 173, 0.5);
+$newtab-background-primary-text-color: #151515;
+$newtab-background-darktheme-primary-text-color: #CDCDD4;
+$newtab-wordmark-default-color: #20123A;
+
+$newtab-card-primary-text-color: #20123A;
+$newtab-card-darktheme-primary-text-color: #E0E0E6;
+$newtab-card-line-color: #716F87;
+$newtab-card-darktheme-line-color: #B9B7CC;
+$newtab-card-separator-line-color: #E1E0E6;
+$newtab-card-darktheme-separator-line-color: #53515F;
+$newtab-card-tint: rgba(0, 0, 0, 0.15);
+$newtab-card-firstshadow: rgba(9, 32, 77, 0.12);
+$newtab-card-darktheme-firstshadow: rgba(21, 20, 26, 0.5);
+$newtab-card-secondshadow: rgba(29, 17, 51, 0.12);
+$newtab-darktheme-card-secondshadow: rgba(21, 20, 26, 0.75);
+$newtab-focus-outline-color: rgba(0, 96, 223, 0.25);
+$newtab-darktheme-focus-outline-color: rgba(80, 145, 241, 0.5);
+$newtab-darktheme-focus-border: #B5D3FF;
+$newtab-darktheme-focus-border-selected: #B5D3FF;
+$newtab-darktheme-primary-action-background: #4484E2;
+$newtab-primary-action-background-off: #E9E9E9;
+$newtab-darktheme-primary-action-background-off: #6A6A6F;
+$newtab-card-darktheme-secondary-action-background: #515156;
+$newtab-card-darktheme-secondary-action-background-hover: #606065;
+$newtab-card-secondary-action-background-active: #E2E2E6;
+$newtab-card-darktheme-secondary-action-background-active: #6F6F74;
+$newtab-card-darktheme-hover-color: rgba(180, 180, 180, 0.1);
+$newtab-card-hover-color: rgba(0, 0, 0, 0.05);
+$newtab-tile-shadow-secondary: rgba(29, 17, 51, 0.2);
+$newtab-tile-darktheme-shadow-secondary: rgba(21, 20, 26, 0.75);
+$newtab-card-secondary-text-color: #585165;
+$newtab-card-darktheme-secondary-text-color: #B1B1BD;
+
+// Photon transitions from http://design.firefox.com/photon/motion/duration-and-easing.html
+$photon-easing: cubic-bezier(0.07, 0.95, 0, 1);
+
+$border-radius: 3px;
+$border-radius-new: 8px;
+
+// Grid related styles
+$base-gutter: 32px;
+$section-horizontal-padding: 25px;
+$section-vertical-padding: 10px;
+$section-spacing: 40px - $section-vertical-padding * 2;
+$grid-unit: 96px; // 1 top site
+// New Tab Experience grid unit needs to be smaller, but for now we are changing this UI with a pref, so requires duplication.
+$grid-unit-small: 80px; // 1 top site
+
+$icon-size: 16px;
+$smaller-icon-size: 12px;
+$larger-icon-size: 32px;
+
+$searchbar-width-small: $grid-unit * 2 + $base-gutter * 1;
+$searchbar-width-medium: $grid-unit * 4 + $base-gutter * 3;
+$searchbar-width-large: $grid-unit * 6 + $base-gutter * 5;
+
+$searchbar-width-small-new: ($grid-unit * 2 + $base-gutter * 1) - 24px;
+$searchbar-width-medium-new: ($grid-unit * 4 + $base-gutter * 3) - 120px;
+$searchbar-width-large-new: ($grid-unit * 6 + $base-gutter * 5) - 136px;
+$searchbar-width-largest-new: ($grid-unit * 6 + $base-gutter * 5) - 16px;
+
+$wrapper-default-width: $grid-unit * 2 + $base-gutter * 1 + $section-horizontal-padding * 2; // 2 top sites
+$wrapper-max-width-medium: $grid-unit * 4 + $base-gutter * 3 + $section-horizontal-padding * 2; // 4 top sites
+$wrapper-max-width-large: $grid-unit * 6 + $base-gutter * 5 + $section-horizontal-padding * 2; // 6 top sites
+$wrapper-max-width-widest: $grid-unit * 8 + $base-gutter * 7 + $section-horizontal-padding * 2; // 8 top sites
+// For the breakpoints, we need to add space for the scrollbar to avoid weird
+// layout issues when the scrollbar is visible. 16px is wide enough to cover all
+// OSes and keeps it simpler than a per-OS value.
+$scrollbar-width: 16px;
+
+// Breakpoints
+// If updating these breakpoints, don't forget to update uses of DSImage, which
+// might choose the right image src to use depending on the viewport size.
+$break-point-medium: $wrapper-max-width-medium + $base-gutter * 2 + $scrollbar-width; // 610px
+$break-point-large: $wrapper-max-width-large + $base-gutter * 2 + $scrollbar-width; // 866px
+$break-point-widest: $wrapper-max-width-widest + $base-gutter * 2 + $scrollbar-width; // 1122px
+
+$section-title-font-size: 13px;
+
+$card-width: $grid-unit * 2 + $base-gutter;
+$card-width-nte: $grid-unit-small * 2 + $base-gutter;
+
+$card-height: 266px;
+$card-preview-image-height: 122px;
+$card-title-margin: 2px;
+$card-text-line-height: 19px;
+// Larger cards for wider screens:
+$card-width-large: 309px;
+$card-height-large: 370px;
+$card-preview-image-height-large: 155px;
+// Compact cards for Highlights
+$card-height-compact: 160px;
+$card-preview-image-height-compact: 108px;
+
+$topic-margin-top: 12px;
+
+$context-menu-button-size: 27px;
+$context-menu-button-boxshadow: 0 2px $grey-90-10;
+$context-menu-shadow: 0 5px 10px $black-30, 0 0 0 1px $black-20;
+$context-menu-font-size: 14px;
+$context-menu-border-radius: 5px;
+$context-menu-outer-padding: 5px;
+$context-menu-item-padding: 3px 12px;
+
+$error-fallback-font-size: 12px;
+$error-fallback-line-height: 1.5;
+
+$image-path: 'chrome://activity-stream/content/data/content/assets/';
+
+$snippets-container-height: 120px;
+
+$textbox-shadow-size: 4px;
+
+$customize-menu-slide-bezier: cubic-bezier(0.46, 0.03, 0.52, 0.96);
+$customize-menu-expand-bezier: cubic-bezier(0.82, 0.085, 0.395, 0.895);
+$customize-menu-border-tint: 1px solid $newtab-card-tint;
+
+@mixin fade-in {
+ box-shadow: inset $inner-box-shadow, $shadow-primary;
+ transition: box-shadow 150ms;
+}
+
+@mixin fade-in-card {
+ box-shadow: $shadow-primary;
+ transition: box-shadow 150ms;
+}
+
+@mixin ds-focus-nte {
+ border: 0;
+ outline: 0;
+ box-shadow: 0 0 0 3px var(--newtab-focus-outline), 0 0 0 1px var(--newtab-focus-border);
+}
+
+@mixin context-menu-button-newtab-experience {
+ .context-menu-button {
+ background-image: url('chrome://global/skin/icons/more.svg');
+ border: 0;
+ border-radius: 4px;
+ cursor: pointer;
+ fill: var(--newtab-icon-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-topsites-context-menu-hover);
+ fill: var(--newtab-primary-action-background);
+ }
+ }
+}
+
+@mixin context-menu-button {
+ .context-menu-button {
+ background-clip: padding-box;
+ background-color: var(--newtab-contextmenu-button-color);
+ background-image: url('chrome://global/skin/icons/more.svg');
+ background-position: 55%;
+ border: $border-primary;
+ border-radius: 100%;
+ box-shadow: $context-menu-button-boxshadow;
+ cursor: pointer;
+ fill: var(--newtab-icon-primary-color);
+ height: $context-menu-button-size;
+ inset-inline-end: -($context-menu-button-size / 2);
+ opacity: 0;
+ position: absolute;
+ top: -($context-menu-button-size / 2);
+ transform: scale(0.25);
+ transition-duration: 150ms;
+ transition-property: transform, opacity;
+ width: $context-menu-button-size;
+
+ &:is(:active, :focus) {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+}
+
+@mixin context-menu-button-hover {
+ .context-menu-button {
+ opacity: 1;
+ transform: scale(1);
+ transition-delay: 333ms;
+ }
+}
+
+@mixin nt-experience-context-menu-button-hover {
+ .context-menu-button {
+ opacity: 1;
+ }
+}
+
+@mixin context-menu-open-middle {
+ .context-menu {
+ margin-inline-end: auto;
+ margin-inline-start: auto;
+ inset-inline-end: auto;
+ inset-inline-start: -$base-gutter;
+ }
+}
+
+@mixin context-menu-open-left {
+ .context-menu {
+ margin-inline-end: 5px;
+ margin-inline-start: auto;
+ inset-inline-end: 0;
+ inset-inline-start: auto;
+ }
+}
+
+@mixin flip-icon {
+ &:dir(rtl) {
+ transform: scaleX(-1);
+ }
+}
diff --git a/browser/components/newtab/content-src/styles/activity-stream-linux.scss b/browser/components/newtab/content-src/styles/activity-stream-linux.scss
new file mode 100644
index 0000000000..aaff2d2efe
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/activity-stream-linux.scss
@@ -0,0 +1,13 @@
+// sass-lint:disable no-css-comments
+/* 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/. */
+
+/* This is the linux variant */
+// sass-lint:enable no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 6px;
+$os-infopanel-arrow-width: 20px;
+
+@import './activity-stream';
diff --git a/browser/components/newtab/content-src/styles/activity-stream-mac.scss b/browser/components/newtab/content-src/styles/activity-stream-mac.scss
new file mode 100644
index 0000000000..1c19e514fb
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/activity-stream-mac.scss
@@ -0,0 +1,17 @@
+// sass-lint:disable no-css-comments
+/* 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/. */
+
+/* This is the mac variant */
+// sass-lint:enable no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 7px;
+$os-infopanel-arrow-width: 18px;
+
+[lwt-newtab-brighttext] {
+ -moz-osx-font-smoothing: grayscale;
+}
+
+@import './activity-stream';
diff --git a/browser/components/newtab/content-src/styles/activity-stream-windows.scss b/browser/components/newtab/content-src/styles/activity-stream-windows.scss
new file mode 100644
index 0000000000..10b8f61ef4
--- /dev/null
+++ b/browser/components/newtab/content-src/styles/activity-stream-windows.scss
@@ -0,0 +1,13 @@
+// sass-lint:disable no-css-comments
+/* 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/. */
+
+/* This is the windows variant */
+// sass-lint:enable no-css-comments
+
+$os-infopanel-arrow-height: 10px;
+$os-infopanel-arrow-offset-end: 6px;
+$os-infopanel-arrow-width: 20px;
+
+@import './activity-stream';