diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /browser/components/newtab/content-src/components/DiscoveryStreamComponents | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/content-src/components/DiscoveryStreamComponents')
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; + } + } +} |