summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/content-src/components/Sections/Sections.jsx
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/content-src/components/Sections/Sections.jsx')
-rw-r--r--browser/components/newtab/content-src/components/Sections/Sections.jsx378
1 files changed, 378 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);