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/DSCard | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.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/DSCard')
2 files changed, 742 insertions, 0 deletions
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; + } +} |