summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/content-src/components/DiscoveryStreamComponents
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/content-src/components/DiscoveryStreamComponents')
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx536
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss355
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx139
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss38
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx491
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss251
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx118
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss81
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx57
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss47
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx100
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss83
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx263
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss48
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx70
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss28
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx34
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss37
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx72
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss48
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx168
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss52
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx143
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss92
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx26
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss45
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx11
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss7
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx112
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss180
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx20
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss10
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx65
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx19
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss18
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss77
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx125
-rw-r--r--browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss88
38 files changed, 4154 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
new file mode 100644
index 0000000000..09dc657cbb
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx
@@ -0,0 +1,536 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx";
+import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
+import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
+import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import React, { useEffect, useState, useRef, useCallback } from "react";
+import { connect, useSelector } from "react-redux";
+const PREF_ONBOARDING_EXPERIENCE_DISMISSED =
+ "discoverystream.onboardingExperience.dismissed";
+const INTERSECTION_RATIO = 0.5;
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+const WIDGET_IDS = {
+ TOPICS: 1,
+};
+
+export function DSSubHeader({ children }) {
+ return (
+ <div className="section-top-bar ds-sub-header">
+ <h3 className="section-title-container">{children}</h3>
+ </div>
+ );
+}
+
+export function OnboardingExperience({
+ children,
+ dispatch,
+ windowObj = global,
+}) {
+ const [dismissed, setDismissed] = useState(false);
+ const [maxHeight, setMaxHeight] = useState(null);
+ const heightElement = useRef(null);
+
+ const onDismissClick = useCallback(() => {
+ // We update this as state and redux.
+ // The state update is for this newtab,
+ // and the redux update is for other tabs, offscreen tabs, and future tabs.
+ // We need the state update for this tab to support the transition.
+ setDismissed(true);
+ dispatch(ac.SetPref(PREF_ONBOARDING_EXPERIENCE_DISMISSED, true));
+ dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "BLOCK",
+ source: "POCKET_ONBOARDING",
+ })
+ );
+ }, [dispatch]);
+
+ useEffect(() => {
+ const resizeObserver = new windowObj.ResizeObserver(() => {
+ if (heightElement.current) {
+ setMaxHeight(heightElement.current.offsetHeight);
+ }
+ });
+
+ const options = { threshold: INTERSECTION_RATIO };
+ const intersectionObserver = new windowObj.IntersectionObserver(entries => {
+ if (
+ entries.some(
+ entry =>
+ entry.isIntersecting &&
+ entry.intersectionRatio >= INTERSECTION_RATIO
+ )
+ ) {
+ dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "IMPRESSION",
+ source: "POCKET_ONBOARDING",
+ })
+ );
+ // Once we have observed an impression, we can stop for this instance of newtab.
+ intersectionObserver.unobserve(heightElement.current);
+ }
+ }, options);
+
+ const onVisibilityChange = () => {
+ intersectionObserver.observe(heightElement.current);
+ windowObj.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ onVisibilityChange
+ );
+ };
+
+ if (heightElement.current) {
+ resizeObserver.observe(heightElement.current);
+ // Check visibility or setup a visibility event to make
+ // sure we don't fire this for off screen pre loaded tabs.
+ if (windowObj.document.visibilityState === VISIBLE) {
+ intersectionObserver.observe(heightElement.current);
+ } else {
+ windowObj.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ onVisibilityChange
+ );
+ }
+ setMaxHeight(heightElement.current.offsetHeight);
+ }
+
+ // Return unmount callback to clean up observers.
+ return () => {
+ resizeObserver?.disconnect();
+ intersectionObserver?.disconnect();
+ windowObj.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ onVisibilityChange
+ );
+ };
+ }, [dispatch, windowObj]);
+
+ const style = {};
+ if (dismissed) {
+ style.maxHeight = "0";
+ style.opacity = "0";
+ style.transition = "max-height 0.26s ease, opacity 0.26s ease";
+ } else if (maxHeight) {
+ style.maxHeight = `${maxHeight}px`;
+ }
+
+ return (
+ <div style={style}>
+ <div className="ds-onboarding-ref" ref={heightElement}>
+ <div className="ds-onboarding-container">
+ <DSDismiss
+ onDismissClick={onDismissClick}
+ extraClasses={`ds-onboarding`}
+ >
+ <div>
+ <header>
+ <span className="icon icon-pocket" />
+ <span data-l10n-id="newtab-pocket-onboarding-discover" />
+ </header>
+ <p data-l10n-id="newtab-pocket-onboarding-cta" />
+ </div>
+ <div className="ds-onboarding-graphic" />
+ </DSDismiss>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export function IntersectionObserver({
+ children,
+ windowObj = window,
+ onIntersecting,
+}) {
+ const intersectionElement = useRef(null);
+
+ useEffect(() => {
+ let observer;
+ if (!observer && onIntersecting && intersectionElement.current) {
+ observer = new windowObj.IntersectionObserver(entries => {
+ const entry = entries.find(e => e.isIntersecting);
+
+ if (entry) {
+ // Stop observing since element has been seen
+ if (observer && intersectionElement.current) {
+ observer.unobserve(intersectionElement.current);
+ }
+
+ onIntersecting();
+ }
+ });
+ observer.observe(intersectionElement.current);
+ }
+ // Cleanup
+ return () => observer?.disconnect();
+ }, [windowObj, onIntersecting]);
+
+ return <div ref={intersectionElement}>{children}</div>;
+}
+
+export function RecentSavesContainer({
+ gridClassName = "",
+ dispatch,
+ windowObj = window,
+ items = 3,
+ source = "CARDGRID_RECENT_SAVES",
+}) {
+ const {
+ recentSavesData,
+ isUserLoggedIn,
+ experimentData: { utmCampaign, utmContent, utmSource },
+ } = useSelector(state => state.DiscoveryStream);
+
+ const [visible, setVisible] = useState(false);
+ const onIntersecting = useCallback(() => setVisible(true), []);
+
+ useEffect(() => {
+ if (visible) {
+ dispatch(
+ ac.AlsoToMain({
+ type: at.DISCOVERY_STREAM_POCKET_STATE_INIT,
+ })
+ );
+ }
+ }, [visible, dispatch]);
+
+ // The user has not yet scrolled to this section,
+ // so wait before potentially requesting Pocket data.
+ if (!visible) {
+ return (
+ <IntersectionObserver
+ windowObj={windowObj}
+ onIntersecting={onIntersecting}
+ />
+ );
+ }
+
+ // Intersection observer has finished, but we're not yet logged in.
+ if (visible && !isUserLoggedIn) {
+ return null;
+ }
+
+ let queryParams = `?utm_source=${utmSource}`;
+ // We really only need to add these params to urls we own.
+ if (utmCampaign && utmContent) {
+ queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`;
+ }
+
+ function renderCard(rec, index) {
+ const url = new URL(rec.url);
+ const urlSearchParams = new URLSearchParams(queryParams);
+ if (rec?.id && !url.href.match(/getpocket\.com\/read/)) {
+ url.href = `https://getpocket.com/read/${rec.id}`;
+ }
+
+ for (let [key, val] of urlSearchParams.entries()) {
+ url.searchParams.set(key, val);
+ }
+
+ return (
+ <DSCard
+ key={`dscard-${rec?.id || index}`}
+ id={rec.id}
+ pos={index}
+ type={source}
+ image_src={rec.image_src}
+ raw_image_src={rec.raw_image_src}
+ word_count={rec.word_count}
+ time_to_read={rec.time_to_read}
+ title={rec.title}
+ excerpt={rec.excerpt}
+ url={url.href}
+ source={rec.domain}
+ isRecentSave={true}
+ dispatch={dispatch}
+ />
+ );
+ }
+
+ function onMyListClicked() {
+ dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: `${source}_VIEW_LIST`,
+ })
+ );
+ }
+
+ const recentSavesCards = [];
+ // We fill the cards with a for loop over an inline map because
+ // we want empty placeholders if there are not enough cards.
+ for (let index = 0; index < items; index++) {
+ const recentSave = recentSavesData[index];
+ if (!recentSave) {
+ recentSavesCards.push(<PlaceholderDSCard key={`dscard-${index}`} />);
+ } else {
+ recentSavesCards.push(
+ renderCard(
+ {
+ id: recentSave.id,
+ image_src: recentSave.top_image_url,
+ raw_image_src: recentSave.top_image_url,
+ word_count: recentSave.word_count,
+ time_to_read: recentSave.time_to_read,
+ title: recentSave.resolved_title || recentSave.given_title,
+ url: recentSave.resolved_url || recentSave.given_url,
+ domain: recentSave.domain_metadata?.name,
+ excerpt: recentSave.excerpt,
+ },
+ index
+ )
+ );
+ }
+ }
+
+ // We are visible and logged in.
+ return (
+ <>
+ <DSSubHeader>
+ <span className="section-title">
+ <FluentOrText message="Recently Saved to your List" />
+ </span>
+ <SafeAnchor
+ onLinkClick={onMyListClicked}
+ className="section-sub-link"
+ url={`https://getpocket.com/a${queryParams}`}
+ >
+ <FluentOrText message="View My List" />
+ </SafeAnchor>
+ </DSSubHeader>
+ <div className={`ds-card-grid-recent-saves ${gridClassName}`}>
+ {recentSavesCards}
+ </div>
+ </>
+ );
+}
+
+export class _CardGrid extends React.PureComponent {
+ renderCards() {
+ const prefs = this.props.Prefs.values;
+ const {
+ items,
+ hybridLayout,
+ hideCardBackground,
+ fourCardLayout,
+ compactGrid,
+ essentialReadsHeader,
+ editorsPicksHeader,
+ onboardingExperience,
+ widgets,
+ recentSavesEnabled,
+ hideDescriptions,
+ DiscoveryStream,
+ } = this.props;
+ const { saveToPocketCard } = DiscoveryStream;
+ const showRecentSaves = prefs.showRecentSaves && recentSavesEnabled;
+ const isOnboardingExperienceDismissed =
+ prefs[PREF_ONBOARDING_EXPERIENCE_DISMISSED];
+
+ const recs = this.props.data.recommendations.slice(0, items);
+ const cards = [];
+ let essentialReadsCards = [];
+ let editorsPicksCards = [];
+
+ for (let index = 0; index < items; index++) {
+ const rec = recs[index];
+ cards.push(
+ !rec || rec.placeholder ? (
+ <PlaceholderDSCard key={`dscard-${index}`} />
+ ) : (
+ <DSCard
+ key={`dscard-${rec.id}`}
+ pos={rec.pos}
+ flightId={rec.flight_id}
+ image_src={rec.image_src}
+ raw_image_src={rec.raw_image_src}
+ word_count={rec.word_count}
+ time_to_read={rec.time_to_read}
+ title={rec.title}
+ excerpt={rec.excerpt}
+ url={rec.url}
+ id={rec.id}
+ shim={rec.shim}
+ type={this.props.type}
+ context={rec.context}
+ sponsor={rec.sponsor}
+ sponsored_by_override={rec.sponsored_by_override}
+ dispatch={this.props.dispatch}
+ source={rec.domain}
+ pocket_id={rec.pocket_id}
+ context_type={rec.context_type}
+ bookmarkGuid={rec.bookmarkGuid}
+ is_collection={this.props.is_collection}
+ saveToPocketCard={saveToPocketCard}
+ />
+ )
+ );
+ }
+
+ if (widgets?.positions?.length && widgets?.data?.length) {
+ let positionIndex = 0;
+ const source = "CARDGRID_WIDGET";
+
+ for (const widget of widgets.data) {
+ let widgetComponent = null;
+ const position = widgets.positions[positionIndex];
+
+ // Stop if we run out of positions to place widgets.
+ if (!position) {
+ break;
+ }
+
+ switch (widget?.type) {
+ case "TopicsWidget":
+ widgetComponent = (
+ <TopicsWidget
+ position={position.index}
+ dispatch={this.props.dispatch}
+ source={source}
+ id={WIDGET_IDS.TOPICS}
+ />
+ );
+ break;
+ }
+
+ if (widgetComponent) {
+ // We found a widget, so up the position for next try.
+ positionIndex++;
+ // We replace an existing card with the widget.
+ cards.splice(position.index, 1, widgetComponent);
+ }
+ }
+ }
+
+ let moreRecsHeader = "";
+ // For now this is English only.
+ if (showRecentSaves || (essentialReadsHeader && editorsPicksHeader)) {
+ let spliceAt = 6;
+ // For 4 card row layouts, second row is 8 cards, and regular it is 6 cards.
+ if (fourCardLayout) {
+ spliceAt = 8;
+ }
+ // If we have a custom header, ensure the more recs section also has a header.
+ moreRecsHeader = "More Recommendations";
+ // Put the first 2 rows into essentialReadsCards.
+ essentialReadsCards = [...cards.splice(0, spliceAt)];
+ // Put the rest into editorsPicksCards.
+ if (essentialReadsHeader && editorsPicksHeader) {
+ editorsPicksCards = [...cards.splice(0, cards.length)];
+ }
+ }
+
+ const hideCardBackgroundClass = hideCardBackground
+ ? `ds-card-grid-hide-background`
+ : ``;
+ const fourCardLayoutClass = fourCardLayout
+ ? `ds-card-grid-four-card-variant`
+ : ``;
+ const hideDescriptionsClassName = !hideDescriptions
+ ? `ds-card-grid-include-descriptions`
+ : ``;
+ const compactGridClassName = compactGrid ? `ds-card-grid-compact` : ``;
+ const hybridLayoutClassName = hybridLayout
+ ? `ds-card-grid-hybrid-layout`
+ : ``;
+
+ const gridClassName = `ds-card-grid ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`;
+
+ return (
+ <>
+ {!isOnboardingExperienceDismissed && onboardingExperience && (
+ <OnboardingExperience dispatch={this.props.dispatch} />
+ )}
+ {essentialReadsCards?.length > 0 && (
+ <div className={gridClassName}>{essentialReadsCards}</div>
+ )}
+ {showRecentSaves && (
+ <RecentSavesContainer
+ gridClassName={gridClassName}
+ dispatch={this.props.dispatch}
+ />
+ )}
+ {editorsPicksCards?.length > 0 && (
+ <>
+ <DSSubHeader>
+ <span className="section-title">
+ <FluentOrText message="Editor’s Picks" />
+ </span>
+ </DSSubHeader>
+ <div className={gridClassName}>{editorsPicksCards}</div>
+ </>
+ )}
+ {cards?.length > 0 && (
+ <>
+ {moreRecsHeader && (
+ <DSSubHeader>
+ <span className="section-title">
+ <FluentOrText message={moreRecsHeader} />
+ </span>
+ </DSSubHeader>
+ )}
+ <div className={gridClassName}>{cards}</div>
+ </>
+ )}
+ </>
+ );
+ }
+
+ render() {
+ const { data } = this.props;
+
+ // Handle a render before feed has been fetched by displaying nothing
+ if (!data) {
+ return null;
+ }
+
+ // Handle the case where a user has dismissed all recommendations
+ const isEmpty = data.recommendations.length === 0;
+
+ return (
+ <div>
+ {this.props.title && (
+ <div className="ds-header">
+ <div className="title">{this.props.title}</div>
+ {this.props.context && (
+ <FluentOrText message={this.props.context}>
+ <div className="ds-context" />
+ </FluentOrText>
+ )}
+ </div>
+ )}
+ {isEmpty ? (
+ <div className="ds-card-grid empty">
+ <DSEmptyState
+ status={data.status}
+ dispatch={this.props.dispatch}
+ feed={this.props.feed}
+ />
+ </div>
+ ) : (
+ this.renderCards()
+ )}
+ </div>
+ );
+ }
+}
+
+_CardGrid.defaultProps = {
+ items: 4, // Number of stories to display
+};
+
+export const CardGrid = connect(state => ({
+ Prefs: state.Prefs,
+ DiscoveryStream: state.DiscoveryStream,
+}))(_CardGrid);
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
new file mode 100644
index 0000000000..fb838f4628
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss
@@ -0,0 +1,355 @@
+$col4-header-line-height: 20;
+$col4-header-font-size: 14;
+
+.ds-onboarding-container,
+.ds-card-grid .ds-card {
+ @include dark-theme-only {
+ background: none;
+ }
+
+ background: $white;
+ border-radius: 4px;
+
+ &:not(.placeholder) {
+ @include dark-theme-only {
+ background: var(--newtab-background-color-secondary);
+ }
+
+ border-radius: $border-radius-new;
+ box-shadow: $shadow-card;
+
+ .img-wrapper .img {
+ img,
+ .placeholder-image {
+ border-radius: $border-radius-new $border-radius-new 0 0;
+ }
+ }
+ }
+}
+
+.ds-onboarding-container {
+ padding-inline-start: 16px;
+ padding-inline-end: 16px;
+
+ @media (min-width: $break-point-medium) {
+ padding-inline-end: 48px;
+ }
+
+ @media (min-width: $break-point-large) {
+ padding-inline-end: 56px;
+ }
+
+ margin-bottom: 24px;
+ // This is to position the dismiss button to the right most of this element.
+ position: relative;
+
+ .ds-onboarding {
+ position: static;
+ display: flex;
+
+ .ds-dismiss-button {
+ inset-inline-end: 8px;
+ top: 8px;
+ }
+ }
+
+ header {
+ @include dark-theme-only {
+ color: var(--newtab-background-color-primary);
+ }
+
+ display: flex;
+ margin: 32px 0 8px;
+
+ @media (min-width: $break-point-medium) {
+ margin: 16px 0 8px;
+ display: block;
+ height: 24px;
+ }
+
+ font-size: 17px;
+ line-height: 23.8px;
+ font-weight: 600;
+ color: $pocket-icon-fill;
+ }
+
+ p {
+ margin: 8px 0 16px;
+ font-size: 13px;
+ line-height: 19.5px;
+ }
+
+ .icon-pocket {
+ @include dark-theme-only {
+ @media (forced-colors: active) {
+ fill: CurrentColor;
+ }
+ fill: var(--newtab-text-primary-color);
+ }
+ @media (forced-colors: active) {
+ fill: CurrentColor;
+ }
+
+ fill: $pocket-icon-fill;
+ margin-top: 3px;
+ margin-inline-end: 8px;
+ height: 22px;
+ width: 22px;
+ background-image: url('chrome://global/skin/icons/pocket.svg');
+
+ @media (min-width: $break-point-medium) {
+ margin-top: -5px;
+ margin-inline-start: -2px;
+ margin-inline-end: 15px;
+ height: 30px;
+ width: 30px;
+ }
+
+ background-size: contain;
+ }
+
+ .ds-onboarding-graphic {
+ background-image: url('chrome://activity-stream/content/data/content/assets/pocket-onboarding.avif');
+
+ @media (min-resolution: 2x) {
+ background-image: url('chrome://activity-stream/content/data/content/assets/pocket-onboarding@2x.avif');
+ }
+
+ border-radius: 8px;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ height: 120px;
+ width: 200px;
+ margin-top: 16px;
+ margin-bottom: 16px;
+ margin-inline-start: 54px;
+ flex-shrink: 0;
+ display: none;
+
+ @media (min-width: $break-point-large) {
+ display: block;
+ }
+ }
+}
+
+.ds-card-grid {
+ display: grid;
+ grid-gap: 24px;
+
+ &.ds-card-grid-compact {
+ grid-gap: 20px;
+ }
+
+ &.ds-card-grid-recent-saves {
+ .ds-card {
+ // Hide the second row orphan on narrow screens.
+ @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+ &:last-child:nth-child(2n - 1) {
+ display: none;
+ }
+ }
+ }
+ }
+
+ .ds-card-link:focus {
+ @include ds-focus;
+
+ transition: none;
+ border-radius: $border-radius-new;
+ }
+
+ // "2/3 width layout"
+ .ds-column-5 &,
+ .ds-column-6 &,
+ .ds-column-7 &,
+ .ds-column-8 & {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ // "Full width layout"
+ .ds-column-9 &,
+ .ds-column-10 &,
+ .ds-column-11 &,
+ .ds-column-12 & {
+ grid-template-columns: repeat(1, 1fr);
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .title {
+ font-size: 17px;
+ line-height: 24px;
+ }
+
+ .excerpt {
+ @include limit-visible-lines(3, 24, 15);
+ }
+ }
+
+ &.empty {
+ grid-template-columns: auto;
+ }
+
+ @mixin small-cards {
+ .ds-card {
+ &.placeholder {
+ min-height: 247px;
+ }
+
+ .meta {
+ .story-footer {
+ margin-top: 8px;
+ }
+
+ .source,
+ .story-sponsored-label,
+ .status-message .story-context-label {
+ color: var(--newtab-text-secondary-color);
+ -webkit-line-clamp: 2;
+ }
+
+ .source,
+ .story-sponsored-label {
+ font-size: 13px;
+ }
+
+ .status-message .story-context-label {
+ font-size: 11.7px;
+ }
+
+ .story-badge-icon {
+ margin-inline-end: 2px;
+ margin-bottom: 2px;
+ height: 14px;
+ width: 14px;
+ background-size: 14px;
+ }
+
+ .title {
+ font-size: 14px;
+ line-height: 20px;
+ }
+
+ .info-wrap {
+ flex-grow: 0;
+ }
+ }
+ }
+ }
+
+ &.ds-card-grid-four-card-variant {
+ // "Full width layout"
+ .ds-column-9 &,
+ .ds-column-10 &,
+ .ds-column-11 &,
+ .ds-column-12 & {
+ grid-template-columns: repeat(1, 1fr);
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ @media (min-width: $break-point-widest) {
+ grid-template-columns: repeat(4, 1fr);
+ }
+ }
+
+ @include small-cards;
+ }
+
+ &.ds-card-grid-hybrid-layout {
+ .ds-column-9 &,
+ .ds-column-10 &,
+ .ds-column-11 &,
+ .ds-column-12 & {
+ grid-template-columns: repeat(1, 1fr);
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ @media (max-height: 1065px) {
+ .excerpt {
+ display: none;
+ }
+ }
+
+ @media (max-width: $break-point-widest) {
+ @include small-cards;
+ }
+
+ @media (min-width: $break-point-widest) and (max-height: 964px) {
+ @include small-cards;
+
+ grid-template-columns: repeat(4, 1fr);
+ }
+ }
+ }
+}
+
+.outer-wrapper .ds-card-grid.ds-card-grid-hide-background .ds-card,
+.outer-wrapper.newtab-experience .ds-card-grid.ds-card-grid-hide-background .ds-card {
+ &:not(.placeholder) {
+ box-shadow: none;
+ background: none;
+
+ .ds-card-link:focus {
+ box-shadow: none;
+
+ .img-wrapper .img img {
+ @include ds-focus;
+ }
+ }
+
+ .img-wrapper .img img {
+ border-radius: 8px;
+ box-shadow: $shadow-card;
+ }
+
+ .meta {
+ padding: 12px 0 0;
+ }
+ }
+}
+
+.ds-layout {
+ .ds-sub-header {
+ margin-top: 24px;
+
+ .section-title-container {
+ flex-direction: row;
+ align-items: baseline;
+ justify-content: space-between;
+ display: flex;
+ }
+
+ .section-sub-link {
+ color: var(--newtab-primary-action-background);
+ font-size: 14px;
+ line-height: 16px;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ color: var(--newtab-primary-element-active-color);
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx
new file mode 100644
index 0000000000..d089a5c8ab
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx
@@ -0,0 +1,139 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid";
+import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
+import { LinkMenuOptions } from "content-src/lib/link-menu-options";
+import React from "react";
+
+export class CollectionCardGrid extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onDismissClick = this.onDismissClick.bind(this);
+ this.state = {
+ dismissed: false,
+ };
+ }
+
+ onDismissClick() {
+ const { data } = this.props;
+ if (this.props.dispatch && data && data.spocs && data.spocs.length) {
+ this.setState({
+ dismissed: true,
+ });
+ const pos = 0;
+ const source = this.props.type.toUpperCase();
+ // Grab the available items in the array to dismiss.
+ // This fires a ping for all items available, even if below the fold.
+ const spocsData = data.spocs.map(item => ({
+ url: item.url,
+ guid: item.id,
+ shim: item.shim,
+ flight_id: item.flightId,
+ }));
+
+ const blockUrlOption = LinkMenuOptions.BlockUrls(spocsData, pos, source);
+ const { action, impression, userEvent } = blockUrlOption;
+ this.props.dispatch(action);
+
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: userEvent,
+ source,
+ action_position: pos,
+ })
+ );
+ if (impression) {
+ this.props.dispatch(impression);
+ }
+ }
+ }
+
+ render() {
+ const { data, dismissible, pocket_button_enabled } = this.props;
+ if (
+ this.state.dismissed ||
+ !data ||
+ !data.spocs ||
+ !data.spocs[0] ||
+ // We only display complete collections.
+ data.spocs.length < 3
+ ) {
+ return null;
+ }
+ const { spocs, placement, feed } = this.props;
+ // spocs.data is spocs state data, and not an array of spocs.
+ const { title, context, sponsored_by_override, sponsor } =
+ spocs.data[placement.name] || {};
+ // Just in case of bad data, don't display a broken collection.
+ if (!title) {
+ return null;
+ }
+
+ let sponsoredByMessage = "";
+
+ // If override is not false or an empty string.
+ if (sponsored_by_override || sponsored_by_override === "") {
+ // We specifically want to display nothing if the server returns an empty string.
+ // So the server can turn off the label.
+ // This is to support the use cases where the sponsored context is displayed elsewhere.
+ sponsoredByMessage = sponsored_by_override;
+ } else if (sponsor) {
+ sponsoredByMessage = {
+ id: `newtab-label-sponsored-by`,
+ values: { sponsor },
+ };
+ } else if (context) {
+ sponsoredByMessage = context;
+ }
+
+ // Generally a card grid displays recs with spocs already injected.
+ // Normally it doesn't care which rec is a spoc and which isn't,
+ // it just displays content in a grid.
+ // For collections, we're only displaying a list of spocs.
+ // We don't need to tell the card grid that our list of cards are spocs,
+ // it shouldn't need to care. So we just pass our spocs along as recs.
+ // Think of it as injecting all rec positions with spocs.
+ // Consider maybe making recommendations in CardGrid use a more generic name.
+ const recsData = {
+ recommendations: data.spocs,
+ };
+
+ // All cards inside of a collection card grid have a slightly different type.
+ // For the case of interactions to the card grid, we use the type "COLLECTIONCARDGRID".
+ // Example, you dismiss the whole collection, we use the type "COLLECTIONCARDGRID".
+ // For interactions inside the card grid, example, you dismiss a single card in the collection,
+ // we use the type "COLLECTIONCARDGRID_CARD".
+ const type = `${this.props.type}_card`;
+
+ const collectionGrid = (
+ <div className="ds-collection-card-grid">
+ <CardGrid
+ pocket_button_enabled={pocket_button_enabled}
+ title={title}
+ context={sponsoredByMessage}
+ data={recsData}
+ feed={feed}
+ type={type}
+ is_collection={true}
+ dispatch={this.props.dispatch}
+ items={this.props.items}
+ />
+ </div>
+ );
+
+ if (dismissible) {
+ return (
+ <DSDismiss
+ onDismissClick={this.onDismissClick}
+ extraClasses={`ds-dismiss-ds-collection`}
+ >
+ {collectionGrid}
+ </DSDismiss>
+ );
+ }
+ return collectionGrid;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss
new file mode 100644
index 0000000000..f4778f3b95
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss
@@ -0,0 +1,38 @@
+.ds-dismiss.ds-dismiss-ds-collection {
+ .ds-dismiss-button {
+ margin: 15px 0 0;
+ inset-inline-end: 25px;
+ }
+
+ &.hovering {
+ background: var(--newtab-element-hover-color);
+ }
+}
+
+.ds-collection-card-grid {
+ padding: 10px 25px 25px;
+ margin: 0 0 20px;
+
+ .story-footer {
+ display: none;
+ }
+
+ .ds-header {
+ padding: 0 40px 0 0;
+ margin-bottom: 12px;
+
+ .title {
+ color: var(--newtab-text-primary-color);
+ font-weight: 600;
+ font-size: 17px;
+ line-height: 24px;
+ }
+
+ .ds-context {
+ color: var(--newtab-text-secondary-color);
+ font-weight: normal;
+ font-size: 13px;
+ line-height: 24px;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
new file mode 100644
index 0000000000..561da8e2fa
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx
@@ -0,0 +1,491 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { DSImage } from "../DSImage/DSImage.jsx";
+import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import {
+ DSContextFooter,
+ SponsorLabel,
+ DSMessageFooter,
+} from "../DSContextFooter/DSContextFooter.jsx";
+import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
+import { connect } from "react-redux";
+
+const READING_WPM = 220;
+
+/**
+ * READ TIME FROM WORD COUNT
+ * @param {int} wordCount number of words in an article
+ * @returns {int} number of words per minute in minutes
+ */
+export function readTimeFromWordCount(wordCount) {
+ if (!wordCount) return false;
+ return Math.ceil(parseInt(wordCount, 10) / READING_WPM);
+}
+
+export const DSSource = ({
+ source,
+ timeToRead,
+ newSponsoredLabel,
+ context,
+ sponsor,
+ sponsored_by_override,
+}) => {
+ // First try to display sponsored label or time to read here.
+ if (newSponsoredLabel) {
+ // If we can display something for spocs, do so.
+ if (sponsored_by_override || sponsor || context) {
+ return (
+ <SponsorLabel
+ context={context}
+ sponsor={sponsor}
+ sponsored_by_override={sponsored_by_override}
+ newSponsoredLabel="new-sponsored-label"
+ />
+ );
+ }
+ }
+
+ // If we are not a spoc, and can display a time to read value.
+ if (source && timeToRead) {
+ return (
+ <p className="source clamp time-to-read">
+ <FluentOrText
+ message={{
+ id: `newtab-label-source-read-time`,
+ values: { source, timeToRead },
+ }}
+ />
+ </p>
+ );
+ }
+
+ // Otherwise display a default source.
+ return <p className="source clamp">{source}</p>;
+};
+
+export const DefaultMeta = ({
+ source,
+ title,
+ excerpt,
+ timeToRead,
+ newSponsoredLabel,
+ context,
+ context_type,
+ sponsor,
+ sponsored_by_override,
+ saveToPocketCard,
+ isRecentSave,
+}) => (
+ <div className="meta">
+ <div className="info-wrap">
+ <DSSource
+ source={source}
+ timeToRead={timeToRead}
+ newSponsoredLabel={newSponsoredLabel}
+ context={context}
+ sponsor={sponsor}
+ sponsored_by_override={sponsored_by_override}
+ />
+ <header title={title} className="title clamp">
+ {title}
+ </header>
+ {excerpt && <p className="excerpt clamp">{excerpt}</p>}
+ </div>
+ {!newSponsoredLabel && (
+ <DSContextFooter
+ context_type={context_type}
+ context={context}
+ sponsor={sponsor}
+ sponsored_by_override={sponsored_by_override}
+ />
+ )}
+ {/* Sponsored label is normally in the way of any message.
+ newSponsoredLabel cards sponsored label is moved to just under the thumbnail,
+ so we can display both, so we specifically don't pass in context. */}
+ {newSponsoredLabel && (
+ <DSMessageFooter
+ context_type={context_type}
+ context={null}
+ saveToPocketCard={saveToPocketCard}
+ />
+ )}
+ </div>
+);
+
+export class _DSCard extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onLinkClick = this.onLinkClick.bind(this);
+ this.onSaveClick = this.onSaveClick.bind(this);
+ this.onMenuUpdate = this.onMenuUpdate.bind(this);
+ this.onMenuShow = this.onMenuShow.bind(this);
+
+ this.setContextMenuButtonHostRef = element => {
+ this.contextMenuButtonHostElement = element;
+ };
+ this.setPlaceholderRef = element => {
+ this.placeholderElement = element;
+ };
+
+ this.state = {
+ isSeen: false,
+ };
+
+ // If this is for the about:home startup cache, then we always want
+ // to render the DSCard, regardless of whether or not its been seen.
+ if (props.App.isForStartupCache) {
+ this.state.isSeen = true;
+ }
+
+ // We want to choose the optimal thumbnail for the underlying DSImage, but
+ // want to do it in a performant way. The breakpoints used in the
+ // CSS of the page are, unfortuntely, not easy to retrieve without
+ // causing a style flush. To avoid that, we hardcode them here.
+ //
+ // The values chosen here were the dimensions of the card thumbnails as
+ // computed by getBoundingClientRect() for each type of viewport width
+ // across both high-density and normal-density displays.
+ this.dsImageSizes = [
+ {
+ mediaMatcher: "(min-width: 1122px)",
+ width: 296,
+ height: 148,
+ },
+
+ {
+ mediaMatcher: "(min-width: 866px)",
+ width: 218,
+ height: 109,
+ },
+
+ {
+ mediaMatcher: "(max-width: 610px)",
+ width: 202,
+ height: 101,
+ },
+ ];
+ }
+
+ onLinkClick(event) {
+ if (this.props.dispatch) {
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: this.props.type.toUpperCase(),
+ action_position: this.props.pos,
+ value: { card_type: this.props.flightId ? "spoc" : "organic" },
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source: this.props.type.toUpperCase(),
+ click: 0,
+ window_inner_width: this.props.windowObj.innerWidth,
+ window_inner_height: this.props.windowObj.innerHeight,
+ tiles: [
+ {
+ id: this.props.id,
+ pos: this.props.pos,
+ ...(this.props.shim && this.props.shim.click
+ ? { shim: this.props.shim.click }
+ : {}),
+ type: this.props.flightId ? "spoc" : "organic",
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ onSaveClick(event) {
+ if (this.props.dispatch) {
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.SAVE_TO_POCKET,
+ data: { site: { url: this.props.url, title: this.props.title } },
+ })
+ );
+
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "SAVE_TO_POCKET",
+ source: "CARDGRID_HOVER",
+ action_position: this.props.pos,
+ value: { card_type: this.props.flightId ? "spoc" : "organic" },
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source: "CARDGRID_HOVER",
+ pocket: 0,
+ tiles: [
+ {
+ id: this.props.id,
+ pos: this.props.pos,
+ ...(this.props.shim && this.props.shim.save
+ ? { shim: this.props.shim.save }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ onMenuUpdate(showContextMenu) {
+ if (!showContextMenu) {
+ const dsLinkMenuHostDiv = this.contextMenuButtonHostElement;
+ if (dsLinkMenuHostDiv) {
+ dsLinkMenuHostDiv.classList.remove("active", "last-item");
+ }
+ }
+ }
+
+ async onMenuShow() {
+ const dsLinkMenuHostDiv = this.contextMenuButtonHostElement;
+ if (dsLinkMenuHostDiv) {
+ // Force translation so we can be sure it's ready before measuring.
+ await this.props.windowObj.document.l10n.translateFragment(
+ dsLinkMenuHostDiv
+ );
+ if (this.props.windowObj.scrollMaxX > 0) {
+ dsLinkMenuHostDiv.classList.add("last-item");
+ }
+ dsLinkMenuHostDiv.classList.add("active");
+ }
+ }
+
+ onSeen(entries) {
+ if (this.state) {
+ const entry = entries.find(e => e.isIntersecting);
+
+ if (entry) {
+ if (this.placeholderElement) {
+ this.observer.unobserve(this.placeholderElement);
+ }
+
+ // Stop observing since element has been seen
+ this.setState({
+ isSeen: true,
+ });
+ }
+ }
+ }
+
+ onIdleCallback() {
+ if (!this.state.isSeen) {
+ if (this.observer && this.placeholderElement) {
+ this.observer.unobserve(this.placeholderElement);
+ }
+ this.setState({
+ isSeen: true,
+ });
+ }
+ }
+
+ componentDidMount() {
+ this.idleCallbackId = this.props.windowObj.requestIdleCallback(
+ this.onIdleCallback.bind(this)
+ );
+ if (this.placeholderElement) {
+ this.observer = new IntersectionObserver(this.onSeen.bind(this));
+ this.observer.observe(this.placeholderElement);
+ }
+ }
+
+ componentWillUnmount() {
+ // Remove observer on unmount
+ if (this.observer && this.placeholderElement) {
+ this.observer.unobserve(this.placeholderElement);
+ }
+ if (this.idleCallbackId) {
+ this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
+ }
+ }
+
+ render() {
+ if (this.props.placeholder || !this.state.isSeen) {
+ return (
+ <div className="ds-card placeholder" ref={this.setPlaceholderRef} />
+ );
+ }
+
+ const { isRecentSave, DiscoveryStream, saveToPocketCard } = this.props;
+ let { source } = this.props;
+ if (!source) {
+ try {
+ source = new URL(this.props.url).hostname;
+ } catch (e) {}
+ }
+
+ const {
+ pocketButtonEnabled,
+ hideDescriptions,
+ compactImages,
+ imageGradient,
+ newSponsoredLabel,
+ titleLines = 3,
+ descLines = 3,
+ readTime: displayReadTime,
+ } = DiscoveryStream;
+
+ const excerpt = !hideDescriptions ? this.props.excerpt : "";
+
+ let timeToRead;
+ if (displayReadTime) {
+ timeToRead =
+ this.props.time_to_read || readTimeFromWordCount(this.props.word_count);
+ }
+
+ const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``;
+ const imageGradientClassName = imageGradient
+ ? `ds-card-image-gradient`
+ : ``;
+ const titleLinesName = `ds-card-title-lines-${titleLines}`;
+ const descLinesClassName = `ds-card-desc-lines-${descLines}`;
+
+ let stpButton = () => {
+ return (
+ <button className="card-stp-button" onClick={this.onSaveClick}>
+ {this.props.context_type === "pocket" ? (
+ <>
+ <span className="story-badge-icon icon icon-pocket" />
+ <span data-l10n-id="newtab-pocket-saved" />
+ </>
+ ) : (
+ <>
+ <span className="story-badge-icon icon icon-pocket-save" />
+ <span data-l10n-id="newtab-pocket-save" />
+ </>
+ )}
+ </button>
+ );
+ };
+
+ return (
+ <div
+ className={`ds-card ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName}`}
+ ref={this.setContextMenuButtonHostRef}
+ >
+ <SafeAnchor
+ className="ds-card-link"
+ dispatch={this.props.dispatch}
+ onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined}
+ url={this.props.url}
+ >
+ <div className="img-wrapper">
+ <DSImage
+ extraClassNames="img"
+ source={this.props.image_src}
+ rawSource={this.props.raw_image_src}
+ sizes={this.dsImageSizes}
+ url={this.props.url}
+ title={this.props.title}
+ isRecentSave={isRecentSave}
+ />
+ </div>
+ <DefaultMeta
+ source={source}
+ title={this.props.title}
+ excerpt={excerpt}
+ newSponsoredLabel={newSponsoredLabel}
+ timeToRead={timeToRead}
+ context={this.props.context}
+ context_type={this.props.context_type}
+ sponsor={this.props.sponsor}
+ sponsored_by_override={this.props.sponsored_by_override}
+ saveToPocketCard={saveToPocketCard}
+ />
+ <ImpressionStats
+ flightId={this.props.flightId}
+ rows={[
+ {
+ id: this.props.id,
+ pos: this.props.pos,
+ ...(this.props.shim && this.props.shim.impression
+ ? { shim: this.props.shim.impression }
+ : {}),
+ },
+ ]}
+ dispatch={this.props.dispatch}
+ source={this.props.type}
+ />
+ </SafeAnchor>
+ {saveToPocketCard && (
+ <div className="card-stp-button-hover-background">
+ <div className="card-stp-button-position-wrapper">
+ {!this.props.flightId && stpButton()}
+ <DSLinkMenu
+ id={this.props.id}
+ index={this.props.pos}
+ dispatch={this.props.dispatch}
+ url={this.props.url}
+ title={this.props.title}
+ source={source}
+ type={this.props.type}
+ pocket_id={this.props.pocket_id}
+ shim={this.props.shim}
+ bookmarkGuid={this.props.bookmarkGuid}
+ flightId={
+ !this.props.is_collection ? this.props.flightId : undefined
+ }
+ showPrivacyInfo={!!this.props.flightId}
+ onMenuUpdate={this.onMenuUpdate}
+ onMenuShow={this.onMenuShow}
+ saveToPocketCard={saveToPocketCard}
+ pocket_button_enabled={pocketButtonEnabled}
+ isRecentSave={isRecentSave}
+ />
+ </div>
+ </div>
+ )}
+ {!saveToPocketCard && (
+ <DSLinkMenu
+ id={this.props.id}
+ index={this.props.pos}
+ dispatch={this.props.dispatch}
+ url={this.props.url}
+ title={this.props.title}
+ source={source}
+ type={this.props.type}
+ pocket_id={this.props.pocket_id}
+ shim={this.props.shim}
+ bookmarkGuid={this.props.bookmarkGuid}
+ flightId={
+ !this.props.is_collection ? this.props.flightId : undefined
+ }
+ showPrivacyInfo={!!this.props.flightId}
+ hostRef={this.contextMenuButtonHostRef}
+ onMenuUpdate={this.onMenuUpdate}
+ onMenuShow={this.onMenuShow}
+ pocket_button_enabled={pocketButtonEnabled}
+ isRecentSave={isRecentSave}
+ />
+ )}
+ </div>
+ );
+ }
+}
+
+_DSCard.defaultProps = {
+ windowObj: window, // Added to support unit tests
+};
+
+export const DSCard = connect(state => ({
+ App: state.App,
+ DiscoveryStream: state.DiscoveryStream,
+}))(_DSCard);
+
+export const PlaceholderDSCard = props => <DSCard placeholder={true} />;
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
new file mode 100644
index 0000000000..a29087a5df
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss
@@ -0,0 +1,251 @@
+// Type sizes
+$header-font-size: 17;
+$header-line-height: 24;
+$excerpt-font-size: 14;
+$excerpt-line-height: 20;
+$ds-card-image-gradient-fade: rgba(0, 0, 0, 0%);
+$ds-card-image-gradient-solid: rgba(0, 0, 0, 100%);
+
+.ds-card {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+
+ &.placeholder {
+ background: transparent;
+ box-shadow: inset $inner-box-shadow;
+ border-radius: 4px;
+ min-height: 300px;
+ }
+
+ .img-wrapper {
+ width: 100%;
+ position: relative;
+ }
+
+ .card-stp-button-hover-background {
+ opacity: 0;
+ width: 100%;
+ position: absolute;
+ top: 0;
+ height: 0;
+ transition: opacity;
+ transition-duration: 0s;
+ padding-top: 50%;
+ pointer-events: none;
+ background: $black-40;
+ border-radius: 8px 8px 0 0;
+
+ .card-stp-button-position-wrapper {
+ position: absolute;
+ inset-inline-end: 10px;
+ top: 10px;
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ }
+
+ .icon-pocket-save,
+ .icon-pocket {
+ margin-inline-end: 4px;
+ height: 15px;
+ width: 15px;
+ background-size: 15px;
+ fill: $white;
+ }
+
+ .context-menu-button {
+ position: static;
+ transition: none;
+ border-radius: 3px;
+ }
+
+ .context-menu-position-container {
+ position: relative;
+ }
+
+ .context-menu {
+ margin-inline-start: 18.5px;
+ inset-inline-start: auto;
+ position: absolute;
+ top: 20.25px;
+ }
+
+ .card-stp-button {
+ display: flex;
+ margin-inline-end: 7px;
+ font-weight: 400;
+ font-size: 13px;
+ line-height: 16px;
+ background-color: $pocket-icon-fill;
+ border: 0;
+ border-radius: 4px;
+ padding: 6px;
+ white-space: nowrap;
+ color: $white;
+ }
+
+ button,
+ .context-menu {
+ pointer-events: auto;
+ }
+
+ button {
+ cursor: pointer;
+ }
+ }
+
+ &.last-item {
+ .card-stp-button-hover-background {
+ .context-menu {
+ margin-inline-start: auto;
+ margin-inline-end: 18.5px;
+ }
+ }
+ }
+
+ // The active class is added when the context menu is open.
+ &.active,
+ &:focus-within,
+ &:hover {
+ .card-stp-button-hover-background {
+ display: block;
+ opacity: 1;
+ transition-duration: 0.3s;
+
+ .context-menu-button {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+ }
+
+ .img {
+ height: 0;
+ padding-top: 50%; // 2:1 aspect ratio
+
+ img {
+ border-radius: 4px;
+ box-shadow: $shadow-image-inset;
+ }
+ }
+
+ .ds-card-link {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ text-decoration: none;
+
+ &:hover {
+ header {
+ color: var(--newtab-primary-action-background);
+ }
+ }
+
+ &:focus {
+ @include ds-focus;
+
+ transition: none;
+
+ header {
+ color: var(--newtab-primary-action-background);
+ }
+ }
+
+ &:active {
+ header {
+ color: var(--newtab-primary-element-active-color);
+ }
+ }
+ }
+
+ .meta {
+ display: flex;
+ flex-direction: column;
+ padding: 12px 16px;
+ flex-grow: 1;
+
+ .info-wrap {
+ flex-grow: 1;
+ }
+
+ .title {
+ // show only 3 lines of copy
+ @include limit-visible-lines(3, $header-line-height, $header-font-size);
+
+ font-weight: 600;
+ }
+
+ .excerpt {
+ // show only 3 lines of copy
+ @include limit-visible-lines(
+ 3,
+ $excerpt-line-height,
+ $excerpt-font-size
+ );
+ }
+
+ .source {
+ -webkit-line-clamp: 1;
+ margin-bottom: 2px;
+ font-size: 13px;
+ color: var(--newtab-text-secondary-color);
+
+ span {
+ display: inline-block;
+ }
+ }
+
+ .new-sponsored-label {
+ font-size: 13px;
+ margin-bottom: 2px;
+ }
+ }
+
+ &.ds-card-title-lines-2 .meta .title {
+ // show only 2 lines of copy
+ @include limit-visible-lines(2, $header-line-height, $header-font-size);
+ }
+
+ &.ds-card-title-lines-1 .meta .title {
+ // show only 1 line of copy
+ @include limit-visible-lines(1, $header-line-height, $header-font-size);
+ }
+
+ &.ds-card-desc-lines-2 .meta .excerpt {
+ // show only 2 lines of copy
+ @include limit-visible-lines(2, $excerpt-line-height, $excerpt-font-size);
+ }
+
+ &.ds-card-desc-lines-1 .meta .excerpt {
+ // show only 1 line of copy
+ @include limit-visible-lines(1, $excerpt-line-height, $excerpt-font-size);
+ }
+
+ &.ds-card-compact-image .img {
+ padding-top: 47%;
+ }
+
+ &.ds-card-image-gradient {
+ img {
+ mask-image: linear-gradient(to top, $ds-card-image-gradient-fade, $ds-card-image-gradient-solid 40px);
+ }
+
+ .meta {
+ padding: 3px 15px 11px;
+ }
+ }
+
+ header {
+ line-height: $header-line-height * 1px;
+ font-size: $header-font-size * 1px;
+ color: var(--newtab-text-primary-color);
+ }
+
+ p {
+ font-size: $excerpt-font-size * 1px;
+ line-height: $excerpt-line-height * 1px;
+ color: var(--newtab-text-primary-color);
+ margin: 0;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx
new file mode 100644
index 0000000000..0d7d4f666e
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx
@@ -0,0 +1,118 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { cardContextTypes } from "../../Card/types.js";
+import { CSSTransition, TransitionGroup } from "react-transition-group";
+import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
+import React from "react";
+
+// Animation time is mirrored in DSContextFooter.scss
+const ANIMATION_DURATION = 3000;
+
+export const DSMessageLabel = props => {
+ const { context, context_type } = props;
+ const { icon, fluentID } = cardContextTypes[context_type] || {};
+
+ if (!context && context_type) {
+ return (
+ <TransitionGroup component={null}>
+ <CSSTransition
+ key={fluentID}
+ timeout={ANIMATION_DURATION}
+ classNames="story-animate"
+ >
+ <StatusMessage icon={icon} fluentID={fluentID} />
+ </CSSTransition>
+ </TransitionGroup>
+ );
+ }
+
+ return null;
+};
+
+export const StatusMessage = ({ icon, fluentID }) => (
+ <div className="status-message">
+ <span
+ aria-haspopup="true"
+ className={`story-badge-icon icon icon-${icon}`}
+ />
+ <div className="story-context-label" data-l10n-id={fluentID} />
+ </div>
+);
+
+export const SponsorLabel = ({
+ sponsored_by_override,
+ sponsor,
+ context,
+ newSponsoredLabel,
+}) => {
+ const classList = `story-sponsored-label ${newSponsoredLabel || ""} clamp`;
+ // If override is not false or an empty string.
+ if (sponsored_by_override) {
+ return <p className={classList}>{sponsored_by_override}</p>;
+ } else if (sponsored_by_override === "") {
+ // We specifically want to display nothing if the server returns an empty string.
+ // So the server can turn off the label.
+ // This is to support the use cases where the sponsored context is displayed elsewhere.
+ return null;
+ } else if (sponsor) {
+ return (
+ <p className={classList}>
+ <FluentOrText
+ message={{
+ id: `newtab-label-sponsored-by`,
+ values: { sponsor },
+ }}
+ />
+ </p>
+ );
+ } else if (context) {
+ return <p className={classList}>{context}</p>;
+ }
+ return null;
+};
+
+export class DSContextFooter extends React.PureComponent {
+ render() {
+ const { context, context_type, sponsor, sponsored_by_override } =
+ this.props;
+
+ const sponsorLabel = SponsorLabel({
+ sponsored_by_override,
+ sponsor,
+ context,
+ });
+ const dsMessageLabel = DSMessageLabel({
+ context,
+ context_type,
+ });
+
+ if (sponsorLabel || dsMessageLabel) {
+ return (
+ <div className="story-footer">
+ {sponsorLabel}
+ {dsMessageLabel}
+ </div>
+ );
+ }
+
+ return null;
+ }
+}
+
+export const DSMessageFooter = props => {
+ const { context, context_type, saveToPocketCard } = props;
+
+ const dsMessageLabel = DSMessageLabel({
+ context,
+ context_type,
+ });
+
+ // This case is specific and already displayed to the user elsewhere.
+ if (!dsMessageLabel || (saveToPocketCard && context_type === "pocket")) {
+ return null;
+ }
+
+ return <div className="story-footer">{dsMessageLabel}</div>;
+};
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss
new file mode 100644
index 0000000000..c23bb1c661
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss
@@ -0,0 +1,81 @@
+.story-footer {
+ color: var(--newtab-text-secondary-color);
+ inset-inline-start: 0;
+ margin-top: 12px;
+ position: relative;
+
+ .story-sponsored-label span {
+ display: inline-block;
+ }
+
+ .story-sponsored-label,
+ .status-message {
+ -webkit-line-clamp: 1;
+ font-size: 13px;
+ line-height: 24px;
+ color: var(--newtab-text-secondary-color);
+ }
+
+ .status-message {
+ display: flex;
+ align-items: center;
+ height: 24px;
+
+ .story-badge-icon {
+ fill: var(--newtab-text-secondary-color);
+ height: 16px;
+ margin-inline-end: 6px;
+
+ &.icon-bookmark-removed {
+ background-image: url('#{$image-path}icon-removed-bookmark.svg');
+ }
+ }
+
+ .story-context-label {
+ color: var(--newtab-text-secondary-color);
+ flex-grow: 1;
+ font-size: 13px;
+ line-height: 24px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+}
+
+.story-animate-enter {
+ opacity: 0;
+}
+
+.story-animate-enter-active {
+ opacity: 1;
+ transition: opacity 150ms ease-in 300ms;
+
+ .story-badge-icon,
+ .story-context-label {
+ animation: color 3s ease-out 0.3s;
+
+ @keyframes color {
+ 0% {
+ color: var(--newtab-status-success);
+ fill: var(--newtab-status-success);
+ }
+
+ 100% {
+ color: var(--newtab-text-secondary-color);
+ fill: var(--newtab-text-secondary-color);
+ }
+ }
+ }
+}
+
+.story-animate-exit {
+ position: absolute;
+ top: 0;
+ opacity: 1;
+}
+
+.story-animate-exit-active {
+ opacity: 0;
+ transition: opacity 250ms ease-in;
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx
new file mode 100644
index 0000000000..9090ebe582
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx
@@ -0,0 +1,57 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+export class DSDismiss extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onDismissClick = this.onDismissClick.bind(this);
+ this.onHover = this.onHover.bind(this);
+ this.offHover = this.offHover.bind(this);
+ this.state = {
+ hovering: false,
+ };
+ }
+
+ onDismissClick() {
+ if (this.props.onDismissClick) {
+ this.props.onDismissClick();
+ }
+ }
+
+ onHover() {
+ this.setState({
+ hovering: true,
+ });
+ }
+
+ offHover() {
+ this.setState({
+ hovering: false,
+ });
+ }
+
+ render() {
+ let className = `ds-dismiss
+ ${this.state.hovering ? ` hovering` : ``}
+ ${this.props.extraClasses ? ` ${this.props.extraClasses}` : ``}`;
+
+ return (
+ <div className={className}>
+ {this.props.children}
+ <button
+ className="ds-dismiss-button"
+ data-l10n-id="newtab-dismiss-button-tooltip"
+ onHover={this.onHover}
+ onClick={this.onDismissClick}
+ onMouseEnter={this.onHover}
+ onMouseLeave={this.offHover}
+ >
+ <span className="icon icon-dismiss" />
+ </button>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
new file mode 100644
index 0000000000..3c736a24ad
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss
@@ -0,0 +1,47 @@
+.ds-dismiss {
+ position: relative;
+ border-radius: 8px;
+ transition-duration: 250ms;
+ transition-property: background;
+
+ &:hover {
+ .ds-dismiss-button {
+ opacity: 1;
+ }
+ }
+
+ .ds-dismiss-button {
+ border: 0;
+ cursor: pointer;
+ height: 32px;
+ width: 32px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ inset-inline-end: 0;
+ top: 0;
+ border-radius: 50%;
+ background-color: transparent;
+
+ .icon {
+ @media (forced-colors: active) {
+ fill: CurrentColor;
+ }
+ fill: var(--newtab-text-primary-color);
+ }
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ }
+
+ &:active {
+ background: var(--newtab-element-active-color);
+ }
+
+ &:focus {
+ box-shadow: $shadow-secondary;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx
new file mode 100644
index 0000000000..ff3886b407
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx
@@ -0,0 +1,100 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import React from "react";
+
+export class DSEmptyState extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onReset = this.onReset.bind(this);
+ this.state = {};
+ }
+
+ componentWillUnmount() {
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ }
+
+ onReset() {
+ if (this.props.dispatch && this.props.feed) {
+ const { feed } = this.props;
+ const { url } = feed;
+ this.props.dispatch({
+ type: at.DISCOVERY_STREAM_FEED_UPDATE,
+ data: {
+ feed: {
+ ...feed,
+ data: {
+ ...feed.data,
+ status: "waiting",
+ },
+ },
+ url,
+ },
+ });
+
+ this.setState({ waiting: true });
+ this.timeout = setTimeout(() => {
+ this.timeout = null;
+ this.setState({
+ waiting: false,
+ });
+ }, 300);
+
+ this.props.dispatch(
+ ac.OnlyToMain({ type: at.DISCOVERY_STREAM_RETRY_FEED, data: { feed } })
+ );
+ }
+ }
+
+ renderButton() {
+ if (this.props.status === "waiting" || this.state.waiting) {
+ return (
+ <button
+ className="try-again-button waiting"
+ data-l10n-id="newtab-discovery-empty-section-topstories-loading"
+ />
+ );
+ }
+
+ return (
+ <button
+ className="try-again-button"
+ onClick={this.onReset}
+ data-l10n-id="newtab-discovery-empty-section-topstories-try-again-button"
+ />
+ );
+ }
+
+ renderState() {
+ if (this.props.status === "waiting" || this.props.status === "failed") {
+ return (
+ <React.Fragment>
+ <h2 data-l10n-id="newtab-discovery-empty-section-topstories-timed-out" />
+ {this.renderButton()}
+ </React.Fragment>
+ );
+ }
+
+ return (
+ <React.Fragment>
+ <h2 data-l10n-id="newtab-discovery-empty-section-topstories-header" />
+ <p data-l10n-id="newtab-discovery-empty-section-topstories-content" />
+ </React.Fragment>
+ );
+ }
+
+ render() {
+ return (
+ <div className="section-empty-state">
+ <div className="empty-state-message">{this.renderState()}</div>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss
new file mode 100644
index 0000000000..9f9accf71b
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss
@@ -0,0 +1,83 @@
+.section-empty-state {
+ border: $border-secondary;
+ border-radius: 4px;
+ display: flex;
+ height: $card-height-compact;
+ width: 100%;
+
+ .empty-state-message {
+ color: var(--newtab-text-secondary-color);
+ font-size: 14px;
+ line-height: 20px;
+ text-align: center;
+ margin: auto;
+ max-width: 936px;
+ }
+
+ .try-again-button {
+ margin-top: 12px;
+ padding: 6px 32px;
+ border-radius: 2px;
+ border: 0;
+ background: var(--newtab-button-secondary-color);
+ color: var(--newtab-text-primary-color);
+ cursor: pointer;
+ position: relative;
+ transition: background 0.2s ease, color 0.2s ease;
+
+ &:not(.waiting) {
+ &:focus {
+ @include ds-fade-in;
+
+ @include dark-theme-only {
+ @include ds-fade-in($blue-40-40);
+ }
+ }
+
+ &:hover {
+ @include ds-fade-in(var(--newtab-element-secondary-color));
+ }
+ }
+
+ &::after {
+ content: '';
+ height: 20px;
+ width: 20px;
+ animation: spinner 1s linear infinite;
+ opacity: 0;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -10px 0 0 -10px;
+ mask-image: url('chrome://activity-stream/content/data/content/assets/spinner.svg');
+ mask-size: 20px;
+ background: var(--newtab-text-secondary-color);
+ }
+
+ &.waiting {
+ cursor: initial;
+ background: var(--newtab-element-secondary-color);
+ color: transparent;
+ transition: background 0.2s ease;
+
+ &::after {
+ transition: opacity 0.2s ease;
+ opacity: 1;
+ }
+ }
+ }
+
+ h2 {
+ font-size: 15px;
+ font-weight: 600;
+ margin: 0;
+ }
+
+ p {
+ margin: 0;
+ }
+}
+
+@keyframes spinner {
+ to { transform: rotate(360deg); }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
new file mode 100644
index 0000000000..8a6cefed3a
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx
@@ -0,0 +1,263 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+const PLACEHOLDER_IMAGE_DATA_ARRAY = [
+ {
+ rotation: "0deg",
+ offsetx: "20px",
+ offsety: "8px",
+ scale: "45%",
+ },
+ {
+ rotation: "54deg",
+ offsetx: "-26px",
+ offsety: "62px",
+ scale: "55%",
+ },
+ {
+ rotation: "-30deg",
+ offsetx: "78px",
+ offsety: "30px",
+ scale: "68%",
+ },
+ {
+ rotation: "-22deg",
+ offsetx: "0",
+ offsety: "92px",
+ scale: "60%",
+ },
+ {
+ rotation: "-65deg",
+ offsetx: "66px",
+ offsety: "28px",
+ scale: "60%",
+ },
+ {
+ rotation: "22deg",
+ offsetx: "-35px",
+ offsety: "62px",
+ scale: "52%",
+ },
+ {
+ rotation: "-25deg",
+ offsetx: "86px",
+ offsety: "-15px",
+ scale: "68%",
+ },
+];
+
+const PLACEHOLDER_IMAGE_COLORS_ARRAY =
+ "#0090ED #FF4F5F #2AC3A2 #FF7139 #A172FF #FFA437 #FF2A8A".split(" ");
+
+function generateIndex({ keyCode, max }) {
+ if (!keyCode) {
+ // Just grab a random index if we cannot generate an index from a key.
+ return Math.floor(Math.random() * max);
+ }
+
+ const hashStr = str => {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ let charCode = str.charCodeAt(i);
+ hash += charCode;
+ }
+ return hash;
+ };
+
+ const hash = hashStr(keyCode);
+ return hash % max;
+}
+
+export function PlaceholderImage({ urlKey, titleKey }) {
+ const dataIndex = generateIndex({
+ keyCode: urlKey,
+ max: PLACEHOLDER_IMAGE_DATA_ARRAY.length,
+ });
+ const colorIndex = generateIndex({
+ keyCode: titleKey,
+ max: PLACEHOLDER_IMAGE_COLORS_ARRAY.length,
+ });
+ const { rotation, offsetx, offsety, scale } =
+ PLACEHOLDER_IMAGE_DATA_ARRAY[dataIndex];
+ const color = PLACEHOLDER_IMAGE_COLORS_ARRAY[colorIndex];
+ const style = {
+ "--placeholderBackgroundColor": color,
+ "--placeholderBackgroundRotation": rotation,
+ "--placeholderBackgroundOffsetx": offsetx,
+ "--placeholderBackgroundOffsety": offsety,
+ "--placeholderBackgroundScale": scale,
+ };
+
+ return <div style={style} className="placeholder-image" />;
+}
+
+export class DSImage extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onOptimizedImageError = this.onOptimizedImageError.bind(this);
+ this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this);
+ this.onLoad = this.onLoad.bind(this);
+
+ this.state = {
+ isLoaded: false,
+ optimizedImageFailed: false,
+ useTransition: false,
+ };
+ }
+
+ onIdleCallback() {
+ if (!this.state.isLoaded) {
+ this.setState({
+ useTransition: true,
+ });
+ }
+ }
+
+ reformatImageURL(url, width, height) {
+ // Change the image URL to request a size tailored for the parent container width
+ // Also: force JPEG, quality 60, no upscaling, no EXIF data
+ // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html
+ return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent(
+ url
+ )}`;
+ }
+
+ componentDidMount() {
+ this.idleCallbackId = this.props.windowObj.requestIdleCallback(
+ this.onIdleCallback.bind(this)
+ );
+ }
+
+ componentWillUnmount() {
+ if (this.idleCallbackId) {
+ this.props.windowObj.cancelIdleCallback(this.idleCallbackId);
+ }
+ }
+
+ render() {
+ let classNames = `ds-image
+ ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``}
+ ${this.state && this.state.useTransition ? ` use-transition` : ``}
+ ${this.state && this.state.isLoaded ? ` loaded` : ``}
+ `;
+
+ let img;
+
+ if (this.state) {
+ if (
+ this.props.optimize &&
+ this.props.rawSource &&
+ !this.state.optimizedImageFailed
+ ) {
+ let baseSource = this.props.rawSource;
+
+ let sizeRules = [];
+ let srcSetRules = [];
+
+ for (let rule of this.props.sizes) {
+ let { mediaMatcher, width, height } = rule;
+ let sizeRule = `${mediaMatcher} ${width}px`;
+ sizeRules.push(sizeRule);
+ let srcSetRule = `${this.reformatImageURL(
+ baseSource,
+ width,
+ height
+ )} ${width}w`;
+ let srcSetRule2x = `${this.reformatImageURL(
+ baseSource,
+ width * 2,
+ height * 2
+ )} ${width * 2}w`;
+ srcSetRules.push(srcSetRule);
+ srcSetRules.push(srcSetRule2x);
+ }
+
+ if (this.props.sizes.length) {
+ // We have to supply a fallback in the very unlikely event that none of
+ // the media queries match. The smallest dimension was chosen arbitrarily.
+ sizeRules.push(
+ `${this.props.sizes[this.props.sizes.length - 1].width}px`
+ );
+ }
+
+ img = (
+ <img
+ loading="lazy"
+ alt={this.props.alt_text}
+ crossOrigin="anonymous"
+ onLoad={this.onLoad}
+ onError={this.onOptimizedImageError}
+ sizes={sizeRules.join(",")}
+ src={baseSource}
+ srcSet={srcSetRules.join(",")}
+ />
+ );
+ } else if (this.props.source && !this.state.nonOptimizedImageFailed) {
+ img = (
+ <img
+ loading="lazy"
+ alt={this.props.alt_text}
+ crossOrigin="anonymous"
+ onLoad={this.onLoad}
+ onError={this.onNonOptimizedImageError}
+ src={this.props.source}
+ />
+ );
+ } else {
+ // We consider a failed to load img or source without an image as loaded.
+ classNames = `${classNames} loaded`;
+ // Remove the img element if we have no source. Render a placeholder instead.
+ // This only happens for recent saves without a source.
+ if (
+ this.props.isRecentSave &&
+ !this.props.rawSource &&
+ !this.props.source
+ ) {
+ img = (
+ <PlaceholderImage
+ urlKey={this.props.url}
+ titleKey={this.props.title}
+ />
+ );
+ } else {
+ img = <div className="broken-image" />;
+ }
+ }
+ }
+
+ return <picture className={classNames}>{img}</picture>;
+ }
+
+ onOptimizedImageError() {
+ // This will trigger a re-render and the unoptimized 450px image will be used as a fallback
+ this.setState({
+ optimizedImageFailed: true,
+ });
+ }
+
+ onNonOptimizedImageError() {
+ this.setState({
+ nonOptimizedImageFailed: true,
+ });
+ }
+
+ onLoad() {
+ this.setState({
+ isLoaded: true,
+ });
+ }
+}
+
+DSImage.defaultProps = {
+ source: null, // The current source style from Pocket API (always 450px)
+ rawSource: null, // Unadulterated image URL to filter through Thumbor
+ extraClassNames: null, // Additional classnames to append to component
+ optimize: true, // Measure parent container to request exact sizes
+ alt_text: null,
+ windowObj: window, // Added to support unit tests
+ sizes: [],
+};
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
new file mode 100644
index 0000000000..b11bcdcf55
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss
@@ -0,0 +1,48 @@
+.ds-image {
+ display: block;
+ position: relative;
+ opacity: 0;
+
+ &.use-transition {
+ transition: opacity 0.8s;
+ }
+
+ &.loaded {
+ opacity: 1;
+ }
+
+ img,
+ .placeholder-image,
+ .broken-image {
+ background-color: var(--newtab-element-secondary-color);
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .placeholder-image {
+ overflow: hidden;
+ background-color: var(--placeholderBackgroundColor);
+
+ &::before {
+ content: '';
+ background-image: url('chrome://activity-stream/content/data/content/assets/pocket-swoosh.svg');
+ background-repeat: no-repeat;
+ background-position: center;
+ transform: rotate(var(--placeholderBackgroundRotation));
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ height: 200%;
+ // We use margin-left over margin-inline-start on purpose.
+ // This is because we are using it to offset an image's content,
+ // and the image content is the same in ltr and rtl.
+ margin-left: var(--placeholderBackgroundOffsetx); // stylelint-disable-line property-disallowed-list
+ margin-top: var(--placeholderBackgroundOffsety);
+ background-size: var(--placeholderBackgroundScale);
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx
new file mode 100644
index 0000000000..b75063940c
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx
@@ -0,0 +1,70 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import React from "react";
+
+export class DSLinkMenu extends React.PureComponent {
+ render() {
+ const { index, dispatch } = this.props;
+ let pocketMenuOptions = [];
+ let TOP_STORIES_CONTEXT_MENU_OPTIONS = [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ ];
+ if (!this.props.isRecentSave) {
+ if (this.props.pocket_button_enabled) {
+ pocketMenuOptions = this.props.saveToPocketCard
+ ? ["CheckDeleteFromPocket"]
+ : ["CheckSavedToPocket"];
+ }
+ TOP_STORIES_CONTEXT_MENU_OPTIONS = [
+ "CheckBookmark",
+ "CheckArchiveFromPocket",
+ ...pocketMenuOptions,
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ ...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : []),
+ ];
+ }
+ const type = this.props.type || "DISCOVERY_STREAM";
+ const title = this.props.title || this.props.source;
+
+ return (
+ <div className="context-menu-position-container">
+ <ContextMenuButton
+ tooltip={"newtab-menu-content-tooltip"}
+ tooltipArgs={{ title }}
+ onUpdate={this.props.onMenuUpdate}
+ >
+ <LinkMenu
+ dispatch={dispatch}
+ index={index}
+ source={type.toUpperCase()}
+ onShow={this.props.onMenuShow}
+ options={TOP_STORIES_CONTEXT_MENU_OPTIONS}
+ shouldSendImpressionStats={true}
+ userEvent={ac.DiscoveryStreamUserEvent}
+ site={{
+ referrer: "https://getpocket.com/recommendations",
+ title: this.props.title,
+ type: this.props.type,
+ url: this.props.url,
+ guid: this.props.id,
+ pocket_id: this.props.pocket_id,
+ shim: this.props.shim,
+ bookmarkGuid: this.props.bookmarkGuid,
+ flight_id: this.props.flightId,
+ }}
+ />
+ </ContextMenuButton>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss
new file mode 100644
index 0000000000..e85eab11e7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss
@@ -0,0 +1,28 @@
+.ds-card,
+.ds-signup {
+ @include context-menu-button;
+
+ .context-menu {
+ opacity: 0;
+ }
+
+ &.active {
+ .context-menu {
+ opacity: 1;
+ }
+ }
+
+ &.last-item {
+ @include context-menu-open-left;
+
+ .context-menu {
+ opacity: 1;
+ }
+ }
+
+ &:is(:hover, :focus, .active) {
+ @include context-menu-button-hover;
+
+ outline: none;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx
new file mode 100644
index 0000000000..df9ad4f641
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+
+export class DSMessage extends React.PureComponent {
+ render() {
+ return (
+ <div className="ds-message">
+ <header className="title">
+ {this.props.icon && (
+ <div
+ className="glyph"
+ style={{ backgroundImage: `url(${this.props.icon})` }}
+ />
+ )}
+ {this.props.title && (
+ <span className="title-text">
+ <FluentOrText message={this.props.title} />
+ </span>
+ )}
+ {this.props.link_text && this.props.link_url && (
+ <SafeAnchor className="link" url={this.props.link_url}>
+ <FluentOrText message={this.props.link_text} />
+ </SafeAnchor>
+ )}
+ </header>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss
new file mode 100644
index 0000000000..bb9666ae38
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss
@@ -0,0 +1,37 @@
+.ds-message {
+ margin: 8px 0 0;
+
+ .title {
+ display: flex;
+ align-items: center;
+
+ .glyph {
+ width: 16px;
+ height: 16px;
+ margin: 0 6px 0 0;
+ -moz-context-properties: fill;
+ fill: var(--newtab-text-secondary-color);
+ background-position: center center;
+ background-size: 16px;
+ background-repeat: no-repeat;
+ }
+
+ .title-text {
+ line-height: 20px;
+ font-size: 13px;
+ color: var(--newtab-text-secondary-color);
+ font-weight: 600;
+ padding-right: 12px;
+ }
+
+ .link {
+ line-height: 20px;
+ font-size: 13px;
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
new file mode 100644
index 0000000000..06ec1f7e78
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay";
+
+export class DSPrivacyModal extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.closeModal = this.closeModal.bind(this);
+ this.onLearnLinkClick = this.onLearnLinkClick.bind(this);
+ this.onManageLinkClick = this.onManageLinkClick.bind(this);
+ }
+
+ onLearnLinkClick(event) {
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK_PRIVACY_INFO",
+ source: "DS_PRIVACY_MODAL",
+ })
+ );
+ }
+
+ onManageLinkClick(event) {
+ this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN }));
+ }
+
+ closeModal() {
+ this.props.dispatch({
+ type: `HIDE_PRIVACY_INFO`,
+ data: {},
+ });
+ }
+
+ render() {
+ return (
+ <ModalOverlayWrapper
+ onClose={this.closeModal}
+ innerClassName="ds-privacy-modal"
+ >
+ <div className="privacy-notice">
+ <h3 data-l10n-id="newtab-privacy-modal-header" />
+ <p data-l10n-id="newtab-privacy-modal-paragraph-2" />
+ <a
+ className="modal-link modal-link-privacy"
+ data-l10n-id="newtab-privacy-modal-link"
+ onClick={this.onLearnLinkClick}
+ href="https://help.getpocket.com/article/1142-firefox-new-tab-recommendations-faq"
+ />
+ <button
+ className="modal-link modal-link-manage"
+ data-l10n-id="newtab-privacy-modal-button-manage"
+ onClick={this.onManageLinkClick}
+ />
+ </div>
+ <section className="actions">
+ <button
+ className="done"
+ type="submit"
+ onClick={this.closeModal}
+ data-l10n-id="newtab-privacy-modal-button-done"
+ />
+ </section>
+ </ModalOverlayWrapper>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss
new file mode 100644
index 0000000000..2077f35709
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss
@@ -0,0 +1,48 @@
+.ds-privacy-modal {
+ .modal-link {
+ display: flex;
+ align-items: center;
+ margin: 0 0 8px;
+ border: 0;
+ padding: 0;
+ color: var(--newtab-primary-action-background);
+ width: max-content;
+
+ &:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ }
+
+ &::before {
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-action-background);
+ content: '';
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ margin-inline-end: 8px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ }
+
+ &.modal-link-privacy::before {
+ background-image: url('chrome://global/skin/icons/info.svg');
+ }
+
+ &.modal-link-manage::before {
+ background-image: url('chrome://global/skin/icons/settings.svg');
+ }
+ }
+
+ p {
+ line-height: 24px;
+ }
+
+ .privacy-notice {
+ max-width: 572px;
+ padding: 40px;
+ margin: auto;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx
new file mode 100644
index 0000000000..b7e3205646
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx
@@ -0,0 +1,168 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
+import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+
+export class DSSignup extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ active: false,
+ lastItem: false,
+ };
+ this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this);
+ this.onLinkClick = this.onLinkClick.bind(this);
+ this.onMenuShow = this.onMenuShow.bind(this);
+ }
+
+ onMenuButtonUpdate(showContextMenu) {
+ if (!showContextMenu) {
+ this.setState({
+ active: false,
+ lastItem: false,
+ });
+ }
+ }
+
+ nextAnimationFrame() {
+ return new Promise(resolve =>
+ this.props.windowObj.requestAnimationFrame(resolve)
+ );
+ }
+
+ async onMenuShow() {
+ let { lastItem } = this.state;
+ // Wait for next frame before computing scrollMaxX to allow fluent menu strings to be visible
+ await this.nextAnimationFrame();
+ if (this.props.windowObj.scrollMaxX > 0) {
+ lastItem = true;
+ }
+ this.setState({
+ active: true,
+ lastItem,
+ });
+ }
+
+ onLinkClick() {
+ const { data } = this.props;
+ if (this.props.dispatch && data && data.spocs && data.spocs.length) {
+ const source = this.props.type.toUpperCase();
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source,
+ action_position: 0,
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source,
+ click: 0,
+ tiles: [
+ {
+ id: spoc.id,
+ pos: 0,
+ ...(spoc.shim && spoc.shim.click
+ ? { shim: spoc.shim.click }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ render() {
+ const { data, dispatch, type } = this.props;
+ if (!data || !data.spocs || !data.spocs[0]) {
+ return null;
+ }
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ const { title, url, excerpt, flight_id, id, shim } = spoc;
+
+ const SIGNUP_CONTEXT_MENU_OPTIONS = [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ ...(flight_id ? ["ShowPrivacyInfo"] : []),
+ ];
+
+ const outerClassName = [
+ "ds-signup",
+ this.state.active && "active",
+ this.state.lastItem && "last-item",
+ ]
+ .filter(v => v)
+ .join(" ");
+
+ return (
+ <div className={outerClassName}>
+ <div className="ds-signup-content">
+ <span className="icon icon-small-spacer icon-mail"></span>
+ <span>
+ {title}{" "}
+ <SafeAnchor
+ className="ds-chevron-link"
+ dispatch={dispatch}
+ onLinkClick={this.onLinkClick}
+ url={url}
+ >
+ {excerpt}
+ </SafeAnchor>
+ </span>
+ <ImpressionStats
+ flightId={flight_id}
+ rows={[
+ {
+ id,
+ pos: 0,
+ shim: shim && shim.impression,
+ },
+ ]}
+ dispatch={dispatch}
+ source={type}
+ />
+ </div>
+ <ContextMenuButton
+ tooltip={"newtab-menu-content-tooltip"}
+ tooltipArgs={{ title }}
+ onUpdate={this.onMenuButtonUpdate}
+ >
+ <LinkMenu
+ dispatch={dispatch}
+ index={0}
+ source={type.toUpperCase()}
+ onShow={this.onMenuShow}
+ options={SIGNUP_CONTEXT_MENU_OPTIONS}
+ shouldSendImpressionStats={true}
+ userEvent={ac.DiscoveryStreamUserEvent}
+ site={{
+ referrer: "https://getpocket.com/recommendations",
+ title,
+ type,
+ url,
+ guid: id,
+ shim,
+ flight_id,
+ }}
+ />
+ </ContextMenuButton>
+ </div>
+ );
+ }
+}
+
+DSSignup.defaultProps = {
+ windowObj: window, // Added to support unit tests
+};
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss
new file mode 100644
index 0000000000..dcaf0e804a
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss
@@ -0,0 +1,52 @@
+.ds-signup {
+ max-width: 300px;
+ margin: 0 auto;
+ padding: 8px;
+ position: relative;
+ text-align: center;
+ font-size: 17px;
+ font-weight: 600;
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ border-radius: 4px;
+ }
+
+ .icon-mail {
+ height: 40px;
+ width: 40px;
+ margin-inline-end: 8px;
+ fill: var(--newtab-text-secondary-color);
+ background-size: 30px;
+ flex-shrink: 0;
+ }
+
+ .ds-signup-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+
+ .ds-chevron-link {
+ margin-top: 4px;
+ box-shadow: none;
+ display: block;
+ white-space: nowrap;
+ }
+ }
+
+ @media (min-width: $break-point-large) {
+ min-width: 756px;
+ width: max-content;
+ text-align: start;
+
+ .ds-signup-content {
+ flex-direction: row;
+
+ .ds-chevron-link {
+ margin-top: 0;
+ display: inline;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
new file mode 100644
index 0000000000..02a3326eb7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx
@@ -0,0 +1,143 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss";
+import { DSImage } from "../DSImage/DSImage.jsx";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import { LinkMenuOptions } from "content-src/lib/link-menu-options";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+
+export class DSTextPromo extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onLinkClick = this.onLinkClick.bind(this);
+ this.onDismissClick = this.onDismissClick.bind(this);
+ }
+
+ onLinkClick() {
+ const { data } = this.props;
+ if (this.props.dispatch && data && data.spocs && data.spocs.length) {
+ const source = this.props.type.toUpperCase();
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source,
+ action_position: 0,
+ })
+ );
+
+ this.props.dispatch(
+ ac.ImpressionStats({
+ source,
+ click: 0,
+ tiles: [
+ {
+ id: spoc.id,
+ pos: 0,
+ ...(spoc.shim && spoc.shim.click
+ ? { shim: spoc.shim.click }
+ : {}),
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ onDismissClick() {
+ const { data } = this.props;
+ if (this.props.dispatch && data && data.spocs && data.spocs.length) {
+ const index = 0;
+ const source = this.props.type.toUpperCase();
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ const spocData = {
+ url: spoc.url,
+ guid: spoc.id,
+ shim: spoc.shim,
+ };
+ const blockUrlOption = LinkMenuOptions.BlockUrl(spocData, index, source);
+
+ const { action, impression, userEvent } = blockUrlOption;
+
+ this.props.dispatch(action);
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: userEvent,
+ source,
+ action_position: index,
+ })
+ );
+ if (impression) {
+ this.props.dispatch(impression);
+ }
+ }
+ }
+
+ render() {
+ const { data } = this.props;
+ if (!data || !data.spocs || !data.spocs[0]) {
+ return null;
+ }
+ // Grab the first item in the array as we only have 1 spoc position.
+ const [spoc] = data.spocs;
+ const {
+ image_src,
+ raw_image_src,
+ alt_text,
+ title,
+ url,
+ context,
+ cta,
+ flight_id,
+ id,
+ shim,
+ } = spoc;
+
+ return (
+ <DSDismiss
+ onDismissClick={this.onDismissClick}
+ extraClasses={`ds-dismiss-ds-text-promo`}
+ >
+ <div className="ds-text-promo">
+ <DSImage
+ alt_text={alt_text}
+ source={image_src}
+ rawSource={raw_image_src}
+ />
+ <div className="text">
+ <h3>
+ {`${title}\u2003`}
+ <SafeAnchor
+ className="ds-chevron-link"
+ dispatch={this.props.dispatch}
+ onLinkClick={this.onLinkClick}
+ url={url}
+ >
+ {cta}
+ </SafeAnchor>
+ </h3>
+ <p className="subtitle">{context}</p>
+ </div>
+ <ImpressionStats
+ flightId={flight_id}
+ rows={[
+ {
+ id,
+ pos: 0,
+ shim: shim && shim.impression,
+ },
+ ]}
+ dispatch={this.props.dispatch}
+ source={this.props.type}
+ />
+ </div>
+ </DSDismiss>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
new file mode 100644
index 0000000000..b0abea1213
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss
@@ -0,0 +1,92 @@
+.ds-dismiss-ds-text-promo {
+ max-width: 744px;
+ margin: auto;
+ overflow: hidden;
+
+ &.hovering {
+ background: var(--newtab-element-hover-color);
+ }
+
+ .ds-dismiss-button {
+ margin-inline: 0 18px;
+ margin-block: 18px 0;
+ }
+}
+
+.ds-text-promo {
+ max-width: 640px;
+ margin: 0;
+ padding: 18px;
+
+ @media(min-width: $break-point-medium) {
+ display: flex;
+ margin: 18px 24px;
+ padding: 0 32px 0 0;
+ }
+
+ .ds-image {
+ width: 40px;
+ height: 40px;
+ flex-shrink: 0;
+ margin: 0 0 18px;
+
+ @media(min-width: $break-point-medium) {
+ margin: 4px 12px 0 0;
+ }
+
+ img {
+ border-radius: 4px;
+ }
+ }
+
+ .text {
+ line-height: 24px;
+ }
+
+ h3 {
+ color: var(--newtab-text-primary-color);
+ margin: 0;
+ font-weight: 600;
+ font-size: 15px;
+ }
+
+ .subtitle {
+ font-size: 13px;
+ margin: 0;
+ color: var(--newtab-text-primary-color);
+ }
+}
+
+.ds-chevron-link {
+ color: var(--newtab-primary-action-background);
+ display: inline-block;
+ outline: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &:active {
+ color: var(--newtab-primary-element-active-color);
+
+ &::after {
+ background-color: var(--newtab-primary-element-active-color);
+ }
+ }
+
+ &:focus {
+ box-shadow: $shadow-secondary;
+ border-radius: 2px;
+ }
+
+ &::after {
+ background-color: var(--newtab-primary-action-background);
+ content: ' ';
+ mask: url('chrome://global/skin/icons/arrow-right-12.svg') 0 -8px no-repeat;
+ margin: 0 0 0 4px;
+ width: 5px;
+ height: 8px;
+ text-decoration: none;
+ display: inline-block;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx
new file mode 100644
index 0000000000..d0cc87cce3
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { connect } from "react-redux";
+import React from "react";
+import { SectionIntl } from "content-src/components/Sections/Sections";
+
+export class _Highlights extends React.PureComponent {
+ render() {
+ const section = this.props.Sections.find(s => s.id === "highlights");
+ if (!section || !section.enabled) {
+ return null;
+ }
+
+ return (
+ <div className="ds-highlights sections-list">
+ <SectionIntl {...section} isFixed={true} />
+ </div>
+ );
+ }
+}
+
+export const Highlights = connect(state => ({ Sections: state.Sections }))(
+ _Highlights
+);
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss
new file mode 100644
index 0000000000..3c5b60e946
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss
@@ -0,0 +1,45 @@
+.ds-highlights {
+ .section {
+ .section-list {
+ grid-gap: var(--gridRowGap);
+ grid-template-columns: repeat(1, 1fr);
+
+ @media (min-width: $break-point-medium) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .card-outer {
+ $line-height: 20px;
+
+ height: 175px;
+
+ .card-host-name {
+ font-size: 13px;
+ line-height: $line-height;
+ margin-bottom: 2px;
+ padding-bottom: 0;
+ text-transform: unset;
+ }
+
+ .card-title {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: $line-height;
+ max-height: $line-height;
+ }
+
+ a {
+ text-decoration: none;
+ }
+ }
+ }
+ }
+
+ .hide-for-narrow {
+ display: block;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx
new file mode 100644
index 0000000000..4cdfc7594f
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx
@@ -0,0 +1,11 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+export class HorizontalRule extends React.PureComponent {
+ render() {
+ return <hr className="ds-hr" />;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss
new file mode 100644
index 0000000000..aa5d6ff9f3
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss
@@ -0,0 +1,7 @@
+.ds-hr {
+ @include ds-border-top {
+ border: 0;
+ };
+
+ height: 0;
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx
new file mode 100644
index 0000000000..1062c3cade
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx
@@ -0,0 +1,112 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+
+export class Topic extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onLinkClick = this.onLinkClick.bind(this);
+ }
+
+ onLinkClick(event) {
+ if (this.props.dispatch) {
+ this.props.dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source: "POPULAR_TOPICS",
+ action_position: 0,
+ value: {
+ topic: event.target.text.toLowerCase().replace(` `, `-`),
+ },
+ })
+ );
+ }
+ }
+
+ render() {
+ const { url, name } = this.props;
+ return (
+ <SafeAnchor
+ onLinkClick={this.onLinkClick}
+ className={this.props.className}
+ url={url}
+ >
+ {name}
+ </SafeAnchor>
+ );
+ }
+}
+
+export class Navigation extends React.PureComponent {
+ render() {
+ let links = this.props.links || [];
+ const alignment = this.props.alignment || "centered";
+ const header = this.props.header || {};
+ const english = this.props.locale.startsWith("en-");
+ const privacyNotice = this.props.privacyNoticeURL || {};
+ const { newFooterSection } = this.props;
+ const className = `ds-navigation ds-navigation-${alignment} ${
+ newFooterSection ? `ds-navigation-new-topics` : ``
+ }`;
+ let { title } = header;
+ if (newFooterSection) {
+ title = { id: "newtab-pocket-new-topics-title" };
+ if (this.props.extraLinks) {
+ links = [
+ ...links.slice(0, links.length - 1),
+ ...this.props.extraLinks,
+ links[links.length - 1],
+ ];
+ }
+ }
+
+ return (
+ <div className={className}>
+ {title && english ? (
+ <FluentOrText message={title}>
+ <span className="ds-navigation-header" />
+ </FluentOrText>
+ ) : null}
+
+ {english ? (
+ <ul>
+ {links &&
+ links.map(t => (
+ <li key={t.name}>
+ <Topic
+ url={t.url}
+ name={t.name}
+ dispatch={this.props.dispatch}
+ />
+ </li>
+ ))}
+ </ul>
+ ) : null}
+
+ {!newFooterSection ? (
+ <SafeAnchor className="ds-navigation-privacy" url={privacyNotice.url}>
+ <FluentOrText message={privacyNotice.title} />
+ </SafeAnchor>
+ ) : null}
+
+ {newFooterSection ? (
+ <div className="ds-navigation-family">
+ <span className="icon firefox-logo" />
+ <span>|</span>
+ <span className="icon pocket-logo" />
+ <span
+ className="ds-navigation-family-message"
+ data-l10n-id="newtab-pocket-pocket-firefox-family"
+ />
+ </div>
+ ) : null}
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss
new file mode 100644
index 0000000000..f9b5e5c704
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss
@@ -0,0 +1,180 @@
+.ds-navigation {
+ color: var(--newtab-text-primary-color);
+ font-size: 11.5px;
+ font-weight: 500;
+ line-height: 22px;
+ padding: 4px 0;
+
+ @media (min-width: $break-point-widest) {
+ line-height: 32px;
+ font-size: 14px;
+ }
+
+ &.ds-navigation-centered {
+ text-align: center;
+ }
+
+ &.ds-navigation-right-aligned {
+ text-align: end;
+ }
+
+ ul {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ }
+
+ ul li {
+ display: inline-block;
+
+ &::after {
+ content: '·';
+ padding: 6px;
+ }
+
+ &:last-child::after {
+ content: none;
+ }
+
+ a {
+ &:hover,
+ &:active {
+ text-decoration: none;
+ }
+
+ &:active {
+ color: var(--newtab-primary-element-active-color);
+ }
+ }
+ }
+
+ .ds-navigation-header {
+ padding-inline-end: 6px;
+ }
+
+ .ds-navigation-privacy {
+ padding-inline-start: 6px;
+ float: inline-end;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ &.ds-navigation-new-topics {
+ display: block;
+ padding-top: 32px;
+
+ .ds-navigation-header {
+ font-size: 14px;
+ line-height: 20px;
+ font-weight: 700;
+ display: inline-block;
+ margin-bottom: 8px;
+ }
+
+ .ds-navigation-family {
+ text-align: center;
+ font-size: 14px;
+ line-height: 20px;
+ margin: 16px auto 28px;
+
+ span {
+ margin: 0 6px;
+ }
+
+ .firefox-logo,
+ .pocket-logo {
+ height: 20px;
+ width: 20px;
+ background-size: cover;
+ }
+
+ .firefox-logo {
+ background-image: url('chrome://activity-stream/content/data/content/assets/firefox.svg');
+ }
+
+ .pocket-logo {
+ background-image: url('chrome://global/skin/icons/pocket.svg');
+ fill: $pocket-icon-fill;
+ }
+
+ .ds-navigation-family-message {
+ font-weight: 400;
+ display: block;
+
+ @media (min-width: $break-point-medium) {
+ display: inline;
+ }
+ }
+
+ @media (min-width: $break-point-medium) {
+ margin-top: 43px;
+ }
+ }
+
+ ul {
+ display: grid;
+ grid-gap: 0 24px;
+ grid-auto-flow: column;
+ grid-template: repeat(8, 1fr) / repeat(1, 1fr);
+
+ li {
+ border-top: $border-primary;
+ line-height: 24px;
+ font-size: 13px;
+ font-weight: 500;
+
+ &::after {
+ content: '';
+ padding: 0;
+ }
+
+ &:nth-last-child(2),
+ &:nth-last-child(3) {
+ display: none;
+ }
+
+ &:nth-last-child(1) {
+ border-bottom: $border-primary;
+ }
+ }
+
+ @media (min-width: $break-point-medium) {
+ grid-template: repeat(3, 1fr) / repeat(2, 1fr);
+
+ li {
+ &:nth-child(3) {
+ border-bottom: $border-primary;
+ }
+ }
+ }
+
+ @media (min-width: $break-point-large) {
+ grid-template: repeat(2, 1fr) / repeat(3, 1fr);
+
+
+ li {
+ &:nth-child(odd) {
+ border-bottom: 0;
+ }
+
+ &:nth-child(even) {
+ border-bottom: $border-primary;
+ }
+ }
+ }
+
+ @media (min-width: $break-point-widest) {
+ grid-template: repeat(2, 1fr) / repeat(4, 1fr);
+
+ li {
+ &:nth-last-child(2),
+ &:nth-last-child(3) {
+ display: block;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx
new file mode 100644
index 0000000000..8f7d88be85
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
+
+export class PrivacyLink extends React.PureComponent {
+ render() {
+ const { properties } = this.props;
+ return (
+ <div className="ds-privacy-link">
+ <SafeAnchor url={properties.url}>
+ <FluentOrText message={properties.title} />
+ </SafeAnchor>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss
new file mode 100644
index 0000000000..08ce093c27
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss
@@ -0,0 +1,10 @@
+.ds-privacy-link {
+ text-align: center;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 24px;
+
+ a:hover {
+ text-decoration: none;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx
new file mode 100644
index 0000000000..cfbc6fe6cb
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx
@@ -0,0 +1,65 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ actionCreators as ac,
+ actionTypes as at,
+} from "common/Actions.sys.mjs";
+import React from "react";
+
+export class SafeAnchor extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick(event) {
+ // Use dispatch instead of normal link click behavior to include referrer
+ if (this.props.dispatch) {
+ event.preventDefault();
+ const { altKey, button, ctrlKey, metaKey, shiftKey } = event;
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.OPEN_LINK,
+ data: {
+ event: { altKey, button, ctrlKey, metaKey, shiftKey },
+ referrer: "https://getpocket.com/recommendations",
+ // Use the anchor's url, which could have been cleaned up
+ url: event.currentTarget.href,
+ },
+ })
+ );
+ }
+
+ // Propagate event if there's a handler
+ if (this.props.onLinkClick) {
+ this.props.onLinkClick(event);
+ }
+ }
+
+ safeURI(url) {
+ let protocol = null;
+ try {
+ protocol = new URL(url).protocol;
+ } catch (e) {
+ return "";
+ }
+
+ const isAllowed = ["http:", "https:"].includes(protocol);
+ if (!isAllowed) {
+ console.warn(`${url} is not allowed for anchor targets.`); // eslint-disable-line no-console
+ return "";
+ }
+ return url;
+ }
+
+ render() {
+ const { url, className } = this.props;
+ return (
+ <a href={this.safeURI(url)} className={className} onClick={this.onClick}>
+ {this.props.children}
+ </a>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx
new file mode 100644
index 0000000000..646dc2263e
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+export class SectionTitle extends React.PureComponent {
+ render() {
+ const {
+ header: { title, subtitle },
+ } = this.props;
+ return (
+ <div className="ds-section-title">
+ <div className="title">{title}</div>
+ {subtitle ? <div className="subtitle">{subtitle}</div> : null}
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss
new file mode 100644
index 0000000000..453001b1b7
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss
@@ -0,0 +1,18 @@
+.ds-section-title {
+ text-align: center;
+ margin-top: 24px;
+
+ .title {
+ color: var(--newtab-text-primary-color);
+ line-height: 48px;
+ font-size: 36px;
+ font-weight: 300;
+ }
+
+ .subtitle {
+ line-height: 24px;
+ font-size: 14px;
+ color: var(--newtab-text-secondary-color);
+ margin-top: 4px;
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss
new file mode 100644
index 0000000000..e0c7c1a8eb
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss
@@ -0,0 +1,77 @@
+.outer-wrapper {
+ .ds-top-sites {
+ .top-sites {
+ .top-site-outer {
+ .top-site-inner > a:is(.active, :focus) .tile {
+ @include ds-focus;
+ }
+
+ .top-site-inner > a:is(:hover) .top-site-inner {
+ @include ds-fade-in(var(--newtab-background-color-secondary));
+ }
+ }
+
+ .top-sites-list {
+ margin: 0 -12px;
+ }
+ }
+ }
+}
+
+// Size overrides for topsites in the 2/3 view.
+.ds-column-5,
+.ds-column-6,
+.ds-column-7,
+.ds-column-8 {
+ .ds-top-sites {
+ .top-site-outer {
+ padding: 0 10px;
+ }
+
+ .top-sites-list {
+ margin: 0 -10px;
+ }
+
+ .top-site-inner {
+ --leftPanelIconWidth: 84.67px;
+
+ .tile {
+ width: var(--leftPanelIconWidth);
+ height: var(--leftPanelIconWidth);
+ }
+
+ .title {
+ width: var(--leftPanelIconWidth);
+ }
+ }
+ }
+}
+
+// Size overrides for topsites in the 1/3 view.
+.ds-column-1,
+.ds-column-2,
+.ds-column-3,
+.ds-column-4 {
+ .ds-top-sites {
+ .top-site-outer {
+ padding: 0 8px;
+ }
+
+ .top-sites-list {
+ margin: 0 -8px;
+ }
+
+ .top-site-inner {
+ --rightPanelIconWidth: 82.67px;
+
+ .tile {
+ width: var(--rightPanelIconWidth);
+ height: var(--rightPanelIconWidth);
+ }
+
+ .title {
+ width: var(--rightPanelIconWidth);
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx
new file mode 100644
index 0000000000..1fe2343b94
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import { actionCreators as ac } from "common/Actions.sys.mjs";
+import { SafeAnchor } from "../SafeAnchor/SafeAnchor";
+import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats";
+import { connect } from "react-redux";
+
+export function _TopicsWidget(props) {
+ const { id, source, position, DiscoveryStream, dispatch } = props;
+
+ const { utmCampaign, utmContent, utmSource } = DiscoveryStream.experimentData;
+
+ let queryParams = `?utm_source=${utmSource}`;
+ if (utmCampaign && utmContent) {
+ queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`;
+ }
+
+ const topics = [
+ { label: "Technology", name: "technology" },
+ { label: "Science", name: "science" },
+ { label: "Self-Improvement", name: "self-improvement" },
+ { label: "Travel", name: "travel" },
+ { label: "Career", name: "career" },
+ { label: "Entertainment", name: "entertainment" },
+ { label: "Food", name: "food" },
+ { label: "Health", name: "health" },
+ {
+ label: "Must-Reads",
+ name: "must-reads",
+ url: `https://getpocket.com/collections${queryParams}`,
+ },
+ ];
+
+ function onLinkClick(topic, positionInCard) {
+ if (dispatch) {
+ dispatch(
+ ac.DiscoveryStreamUserEvent({
+ event: "CLICK",
+ source,
+ action_position: position,
+ value: {
+ card_type: "topics_widget",
+ topic,
+ ...(positionInCard || positionInCard === 0
+ ? { position_in_card: positionInCard }
+ : {}),
+ },
+ })
+ );
+ dispatch(
+ ac.ImpressionStats({
+ source,
+ click: 0,
+ window_inner_width: props.windowObj.innerWidth,
+ window_inner_height: props.windowObj.innerHeight,
+ tiles: [
+ {
+ id,
+ pos: position,
+ },
+ ],
+ })
+ );
+ }
+ }
+
+ function mapTopicItem(topic, index) {
+ return (
+ <li
+ key={topic.name}
+ className={topic.overflow ? "ds-topics-widget-list-overflow-item" : ""}
+ >
+ <SafeAnchor
+ url={
+ topic.url ||
+ `https://getpocket.com/explore/${topic.name}${queryParams}`
+ }
+ dispatch={dispatch}
+ onLinkClick={() => onLinkClick(topic.name, index)}
+ >
+ {topic.label}
+ </SafeAnchor>
+ </li>
+ );
+ }
+
+ return (
+ <div className="ds-topics-widget">
+ <header className="ds-topics-widget-header">Popular Topics</header>
+ <hr />
+ <div className="ds-topics-widget-list-container">
+ <ul>{topics.map(mapTopicItem)}</ul>
+ </div>
+ <SafeAnchor
+ className="ds-topics-widget-button button primary"
+ url={`https://getpocket.com/${queryParams}`}
+ dispatch={dispatch}
+ onLinkClick={() => onLinkClick("more-topics")}
+ >
+ More Topics
+ </SafeAnchor>
+ <ImpressionStats
+ dispatch={dispatch}
+ rows={[
+ {
+ id,
+ pos: position,
+ },
+ ]}
+ source={source}
+ />
+ </div>
+ );
+}
+
+_TopicsWidget.defaultProps = {
+ windowObj: window, // Added to support unit tests
+};
+
+export const TopicsWidget = connect(state => ({
+ DiscoveryStream: state.DiscoveryStream,
+}))(_TopicsWidget);
diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss
new file mode 100644
index 0000000000..d05d46cd07
--- /dev/null
+++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss
@@ -0,0 +1,88 @@
+.ds-topics-widget {
+ display: flex;
+ position: relative;
+ flex-direction: column;
+
+ .ds-topics-widget-header {
+ font-size: 18px;
+ line-height: 20px;
+ }
+
+ hr {
+ background-color: color-mix(in srgb, var(--newtab-border-color) 52%, transparent);
+ height: 1px;
+ border: 0;
+ margin: 10px 0 0;
+ }
+
+ .ds-topics-widget-list-container {
+ flex-grow: 1;
+
+ ul {
+ margin: 14px 0 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ grid-gap: 10px;
+ flex-wrap: wrap;
+
+ li {
+ display: flex;
+
+ a {
+ font-size: 14px;
+ line-height: 16px;
+ text-decoration: none;
+ padding: 8px 15px;
+ background: var(--newtab-background-color-secondary);
+ border: 1px solid color-mix(in srgb, var(--newtab-border-color) 52%, transparent);
+ color: var(--newtab-text-primary-color);
+ border-radius: 8px;
+
+ &:hover {
+ background: var(--newtab-element-hover-color);
+ }
+
+ &:focus {
+ outline: 0;
+ box-shadow: 0 0 0 3px var(--newtab-primary-action-background-dimmed), 0 0 0 1px var(--newtab-primary-action-background);
+ transition: box-shadow 150ms;
+ }
+ }
+ }
+
+ .ds-topics-widget-list-overflow-item {
+ display: flex;
+
+ @media (min-width: $break-point-medium) {
+ display: none;
+ }
+
+ @media (min-width: $break-point-widest) {
+ display: flex;
+ }
+ }
+ }
+ }
+
+ .ds-topics-widget-button {
+ margin: 14px 0 0;
+ font-size: 16px;
+ line-height: 24px;
+ text-align: center;
+ padding: 8px;
+ border-radius: 4px;
+ background-color: var(--newtab-primary-action-background-pocket);
+ border: 0;
+
+ &:hover {
+ background: var(--newtab-primary-element-hover-pocket-color);
+ }
+
+ &:focus {
+ outline: 0;
+ box-shadow: 0 0 0 3px var(--newtab-primary-action-background-pocket-dimmed), 0 0 0 1px var(--newtab-primary-action-background-pocket);
+ transition: box-shadow 150ms;
+ }
+ }
+}