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/CardGrid | |
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/CardGrid')
2 files changed, 891 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); + } + } + } +} |