diff options
Diffstat (limited to 'browser/components/newtab/content-src/components/Sections')
-rw-r--r-- | browser/components/newtab/content-src/components/Sections/Sections.jsx | 378 | ||||
-rw-r--r-- | browser/components/newtab/content-src/components/Sections/_Sections.scss | 123 |
2 files changed, 501 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/components/Sections/Sections.jsx b/browser/components/newtab/content-src/components/Sections/Sections.jsx new file mode 100644 index 0000000000..e72e9145ad --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/Sections.jsx @@ -0,0 +1,378 @@ +/* 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 { Card, PlaceholderCard } from "content-src/components/Card/Card"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import { connect } from "react-redux"; +import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations"; +import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta"; +import React from "react"; +import { Topics } from "content-src/components/Topics/Topics"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; +const CARDS_PER_ROW_DEFAULT = 3; +const CARDS_PER_ROW_COMPACT_WIDE = 4; + +export class Section extends React.PureComponent { + get numRows() { + const { rowsPref, maxRows, Prefs } = this.props; + return rowsPref ? Prefs.values[rowsPref] : maxRows; + } + + _dispatchImpressionStats() { + const { props } = this; + let cardsPerRow = CARDS_PER_ROW_DEFAULT; + if ( + props.compactCards && + global.matchMedia(`(min-width: 1072px)`).matches + ) { + // If the section has compact cards and the viewport is wide enough, we show + // 4 columns instead of 3. + // $break-point-widest = 1072px (from _variables.scss) + cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE; + } + const maxCards = cardsPerRow * this.numRows; + const cards = props.rows.slice(0, maxCards); + + if (this.needsImpressionStats(cards)) { + props.dispatch( + ac.ImpressionStats({ + source: props.eventSource, + tiles: cards.map(link => ({ id: link.guid })), + }) + ); + this.impressionCardGuids = cards.map(link => link.guid); + } + } + + // This sends an event when a user sees a set of new content. If content + // changes while the page is hidden (i.e. preloaded or on a hidden tab), + // only send the event if the page becomes visible again. + sendImpressionStatsOrAddListener() { + const { props } = this; + + if (!props.shouldSendImpressionStats || !props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + this._dispatchImpressionStats(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + // When the page becomes visible, send the impression stats ping if the section isn't collapsed. + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + if (!this.props.pref.collapsed) { + this._dispatchImpressionStats(); + } + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentWillMount() { + this.sendNewTabRehydrated(this.props.initialized); + } + + componentDidMount() { + if (this.props.rows.length && !this.props.pref.collapsed) { + this.sendImpressionStatsOrAddListener(); + } + } + + componentDidUpdate(prevProps) { + const { props } = this; + const isCollapsed = props.pref.collapsed; + const wasCollapsed = prevProps.pref.collapsed; + if ( + // Don't send impression stats for the empty state + props.rows.length && + // We only want to send impression stats if the content of the cards has changed + // and the section is not collapsed... + ((props.rows !== prevProps.rows && !isCollapsed) || + // or if we are expanding a section that was collapsed. + (wasCollapsed && !isCollapsed)) + ) { + this.sendImpressionStatsOrAddListener(); + } + } + + componentWillUpdate(nextProps) { + this.sendNewTabRehydrated(nextProps.initialized); + } + + componentWillUnmount() { + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + needsImpressionStats(cards) { + if ( + !this.impressionCardGuids || + this.impressionCardGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].guid !== this.impressionCardGuids[i]) { + return true; + } + } + + return false; + } + + // The NEW_TAB_REHYDRATED event is used to inform feeds that their + // data has been consumed e.g. for counting the number of tabs that + // have rendered that data. + sendNewTabRehydrated(initialized) { + if (initialized && !this.renderNotified) { + this.props.dispatch( + ac.AlsoToMain({ type: at.NEW_TAB_REHYDRATED, data: {} }) + ); + this.renderNotified = true; + } + } + + render() { + const { + id, + eventSource, + title, + rows, + Pocket, + topics, + emptyState, + dispatch, + compactCards, + read_more_endpoint, + contextMenuOptions, + initialized, + learnMore, + pref, + privacyNoticeURL, + isFirst, + isLast, + } = this.props; + + const waitingForSpoc = + id === "topstories" && this.props.Pocket.waitingForSpoc; + const maxCardsPerRow = compactCards + ? CARDS_PER_ROW_COMPACT_WIDE + : CARDS_PER_ROW_DEFAULT; + const { numRows } = this; + const maxCards = maxCardsPerRow * numRows; + const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows; + + const { pocketCta, isUserLoggedIn } = Pocket || {}; + const { useCta } = pocketCta || {}; + + // Don't display anything until we have a definitve result from Pocket, + // to avoid a flash of logged out state while we render. + const isPocketLoggedInDefined = + isUserLoggedIn === true || isUserLoggedIn === false; + + const hasTopics = topics && !!topics.length; + + const shouldShowPocketCta = + id === "topstories" && useCta && isUserLoggedIn === false; + + // Show topics only for top stories and if it has loaded with topics. + // The classs .top-stories-bottom-container ensures content doesn't shift as things load. + const shouldShowTopics = + id === "topstories" && + hasTopics && + ((useCta && isUserLoggedIn === true) || + (!useCta && isPocketLoggedInDefined)); + + // We use topics to determine language support for read more. + const shouldShowReadMore = read_more_endpoint && hasTopics; + + const realRows = rows.slice(0, maxCards); + + // The empty state should only be shown after we have initialized and there is no content. + // Otherwise, we should show placeholders. + const shouldShowEmptyState = initialized && !rows.length; + + const cards = []; + if (!shouldShowEmptyState) { + for (let i = 0; i < maxCards; i++) { + const link = realRows[i]; + // On narrow viewports, we only show 3 cards per row. We'll mark the rest as + // .hide-for-narrow to hide in CSS via @media query. + const className = i >= maxCardsOnNarrow ? "hide-for-narrow" : ""; + let usePlaceholder = !link; + // If we are in the third card and waiting for spoc, + // use the placeholder. + if (!usePlaceholder && i === 2 && waitingForSpoc) { + usePlaceholder = true; + } + cards.push( + !usePlaceholder ? ( + <Card + key={i} + index={i} + className={className} + dispatch={dispatch} + link={link} + contextMenuOptions={contextMenuOptions} + eventSource={eventSource} + shouldSendImpressionStats={this.props.shouldSendImpressionStats} + isWebExtension={this.props.isWebExtension} + /> + ) : ( + <PlaceholderCard key={i} className={className} /> + ) + ); + } + } + + const sectionClassName = [ + "section", + compactCards ? "compact-cards" : "normal-cards", + ].join(" "); + + // <Section> <-- React component + // <section> <-- HTML5 element + return ( + <ComponentPerfTimer {...this.props}> + <CollapsibleSection + className={sectionClassName} + title={title} + id={id} + eventSource={eventSource} + collapsed={this.props.pref.collapsed} + showPrefName={(pref && pref.feed) || id} + privacyNoticeURL={privacyNoticeURL} + Prefs={this.props.Prefs} + isFixed={this.props.isFixed} + isFirst={isFirst} + isLast={isLast} + learnMore={learnMore} + dispatch={this.props.dispatch} + isWebExtension={this.props.isWebExtension} + > + {!shouldShowEmptyState && ( + <ul className="section-list" style={{ padding: 0 }}> + {cards} + </ul> + )} + {shouldShowEmptyState && ( + <div className="section-empty-state"> + <div className="empty-state"> + <FluentOrText message={emptyState.message}> + <p className="empty-state-message" /> + </FluentOrText> + </div> + </div> + )} + {id === "topstories" && ( + <div className="top-stories-bottom-container"> + {shouldShowTopics && ( + <div className="wrapper-topics"> + <Topics topics={this.props.topics} /> + </div> + )} + + {shouldShowPocketCta && ( + <div className="wrapper-cta"> + <PocketLoggedInCta /> + </div> + )} + + <div className="wrapper-more-recommendations"> + {shouldShowReadMore && ( + <MoreRecommendations + read_more_endpoint={read_more_endpoint} + /> + )} + </div> + </div> + )} + </CollapsibleSection> + </ComponentPerfTimer> + ); + } +} + +Section.defaultProps = { + document: global.document, + rows: [], + emptyState: {}, + pref: {}, + title: "", +}; + +export const SectionIntl = connect(state => ({ + Prefs: state.Prefs, + Pocket: state.Pocket, +}))(Section); + +export class _Sections extends React.PureComponent { + renderSections() { + const sections = []; + const enabledSections = this.props.Sections.filter( + section => section.enabled + ); + const { sectionOrder, "feeds.topsites": showTopSites } = + this.props.Prefs.values; + // Enabled sections doesn't include Top Sites, so we add it if enabled. + const expectedCount = enabledSections.length + ~~showTopSites; + + for (const sectionId of sectionOrder.split(",")) { + const commonProps = { + key: sectionId, + isFirst: sections.length === 0, + isLast: sections.length === expectedCount - 1, + }; + if (sectionId === "topsites" && showTopSites) { + sections.push(<TopSites {...commonProps} />); + } else { + const section = enabledSections.find(s => s.id === sectionId); + if (section) { + sections.push(<SectionIntl {...section} {...commonProps} />); + } + } + } + return sections; + } + + render() { + return <div className="sections-list">{this.renderSections()}</div>; + } +} + +export const Sections = connect(state => ({ + Sections: state.Sections, + Prefs: state.Prefs, +}))(_Sections); diff --git a/browser/components/newtab/content-src/components/Sections/_Sections.scss b/browser/components/newtab/content-src/components/Sections/_Sections.scss new file mode 100644 index 0000000000..e3fe15f762 --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/_Sections.scss @@ -0,0 +1,123 @@ +.sections-list { + .section-list { + display: grid; + grid-gap: $base-gutter; + grid-template-columns: repeat(auto-fit, $card-width); + margin: 0; + + @media (max-width: $break-point-medium) { + @include context-menu-open-left; + } + + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + :nth-child(2n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) { + :nth-child(3n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) { + // 3n for normal cards, 4n for compact cards + :nth-child(3n), + :nth-child(4n) { + @include context-menu-open-left; + } + } + } + + .section-empty-state { + border: $border-secondary; + border-radius: $border-radius; + display: flex; + height: $card-height; + width: 100%; + + .empty-state { + margin: auto; + max-width: 350px; + + .empty-state-message { + color: var(--newtab-text-primary-color); + font-size: 13px; + margin-bottom: 0; + text-align: center; + } + } + + @media (min-width: $break-point-widest) { + height: $card-height-large; + } + } +} + +.top-stories-bottom-container { + color: var(--newtab-text-primary-color); + font-size: 12px; + line-height: 1.6; + margin-top: $topic-margin-top; + display: flex; + justify-content: space-between; + + a { + color: var(--newtab-primary-action-background); + font-weight: bold; + + &.more-recommendations { + font-weight: normal; + font-size: 13px; + } + } + + .wrapper-topics, + .wrapper-cta + .wrapper-more-recommendations { + @media (max-width: $break-point-large - 1) { + display: none; + } + } + + @media (max-width: $break-point-medium - 1) { + .wrapper-cta { + text-align: center; + + .pocket-logged-in-cta { + display: block; + margin-inline-end: 0; + + .pocket-cta-button { + max-width: none; + display: block; + margin-inline-end: 0; + margin: 5px 0 10px; + } + } + } + + .wrapper-more-recommendations { + width: 100%; + + .more-recommendations { + justify-content: center; + + &::after { + display: none; + } + } + } + } +} + +@media (min-width: $break-point-widest) { + .sections-list { + // Compact cards stay the same size but normal cards get bigger. + .normal-cards { + .section-list { + grid-template-columns: repeat(auto-fit, $card-width-large); + } + } + } +} |