tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

Sections.jsx (10481B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
      6 import { Card, PlaceholderCard } from "content-src/components/Card/Card";
      7 import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
      8 import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
      9 import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText";
     10 import { connect } from "react-redux";
     11 import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations";
     12 import React from "react";
     13 import { TopSites } from "content-src/components/TopSites/TopSites";
     14 
     15 const VISIBLE = "visible";
     16 const VISIBILITY_CHANGE_EVENT = "visibilitychange";
     17 const CARDS_PER_ROW_DEFAULT = 3;
     18 const CARDS_PER_ROW_COMPACT_WIDE = 4;
     19 
     20 export class Section extends React.PureComponent {
     21  get numRows() {
     22    const { rowsPref, maxRows, Prefs } = this.props;
     23    return rowsPref ? Prefs.values[rowsPref] : maxRows;
     24  }
     25 
     26  _dispatchImpressionStats() {
     27    const { props } = this;
     28    let cardsPerRow = CARDS_PER_ROW_DEFAULT;
     29    if (
     30      props.compactCards &&
     31      globalThis.matchMedia(`(min-width: 1072px)`).matches
     32    ) {
     33      // If the section has compact cards and the viewport is wide enough, we show
     34      // 4 columns instead of 3.
     35      // $break-point-widest = 1072px (from _variables.scss)
     36      cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE;
     37    }
     38    const maxCards = cardsPerRow * this.numRows;
     39    const cards = props.rows.slice(0, maxCards);
     40 
     41    if (this.needsImpressionStats(cards)) {
     42      props.dispatch(
     43        ac.ImpressionStats({
     44          source: props.eventSource,
     45          tiles: cards.map(link => ({ id: link.guid })),
     46        })
     47      );
     48      this.impressionCardGuids = cards.map(link => link.guid);
     49    }
     50  }
     51 
     52  // This sends an event when a user sees a set of new content. If content
     53  // changes while the page is hidden (i.e. preloaded or on a hidden tab),
     54  // only send the event if the page becomes visible again.
     55  sendImpressionStatsOrAddListener() {
     56    const { props } = this;
     57 
     58    if (!props.shouldSendImpressionStats || !props.dispatch) {
     59      return;
     60    }
     61 
     62    if (props.document.visibilityState === VISIBLE) {
     63      this._dispatchImpressionStats();
     64    } else {
     65      // We should only ever send the latest impression stats ping, so remove any
     66      // older listeners.
     67      if (this._onVisibilityChange) {
     68        props.document.removeEventListener(
     69          VISIBILITY_CHANGE_EVENT,
     70          this._onVisibilityChange
     71        );
     72      }
     73 
     74      // When the page becomes visible, send the impression stats ping if the section isn't collapsed.
     75      this._onVisibilityChange = () => {
     76        if (props.document.visibilityState === VISIBLE) {
     77          if (!this.props.pref.collapsed) {
     78            this._dispatchImpressionStats();
     79          }
     80          props.document.removeEventListener(
     81            VISIBILITY_CHANGE_EVENT,
     82            this._onVisibilityChange
     83          );
     84        }
     85      };
     86      props.document.addEventListener(
     87        VISIBILITY_CHANGE_EVENT,
     88        this._onVisibilityChange
     89      );
     90    }
     91  }
     92 
     93  componentWillMount() {
     94    this.sendNewTabRehydrated(this.props.initialized);
     95  }
     96 
     97  componentDidMount() {
     98    if (this.props.rows.length && !this.props.pref.collapsed) {
     99      this.sendImpressionStatsOrAddListener();
    100    }
    101  }
    102 
    103  componentDidUpdate(prevProps) {
    104    const { props } = this;
    105    const isCollapsed = props.pref.collapsed;
    106    const wasCollapsed = prevProps.pref.collapsed;
    107    if (
    108      // Don't send impression stats for the empty state
    109      props.rows.length &&
    110      // We only want to send impression stats if the content of the cards has changed
    111      // and the section is not collapsed...
    112      ((props.rows !== prevProps.rows && !isCollapsed) ||
    113        // or if we are expanding a section that was collapsed.
    114        (wasCollapsed && !isCollapsed))
    115    ) {
    116      this.sendImpressionStatsOrAddListener();
    117    }
    118  }
    119 
    120  componentWillUpdate(nextProps) {
    121    this.sendNewTabRehydrated(nextProps.initialized);
    122  }
    123 
    124  componentWillUnmount() {
    125    if (this._onVisibilityChange) {
    126      this.props.document.removeEventListener(
    127        VISIBILITY_CHANGE_EVENT,
    128        this._onVisibilityChange
    129      );
    130    }
    131  }
    132 
    133  needsImpressionStats(cards) {
    134    if (
    135      !this.impressionCardGuids ||
    136      this.impressionCardGuids.length !== cards.length
    137    ) {
    138      return true;
    139    }
    140 
    141    for (let i = 0; i < cards.length; i++) {
    142      if (cards[i].guid !== this.impressionCardGuids[i]) {
    143        return true;
    144      }
    145    }
    146 
    147    return false;
    148  }
    149 
    150  // The NEW_TAB_REHYDRATED event is used to inform feeds that their
    151  // data has been consumed e.g. for counting the number of tabs that
    152  // have rendered that data.
    153  sendNewTabRehydrated(initialized) {
    154    if (initialized && !this.renderNotified) {
    155      this.props.dispatch(
    156        ac.AlsoToMain({ type: at.NEW_TAB_REHYDRATED, data: {} })
    157      );
    158      this.renderNotified = true;
    159    }
    160  }
    161 
    162  render() {
    163    const {
    164      id,
    165      eventSource,
    166      title,
    167      rows,
    168      emptyState,
    169      dispatch,
    170      compactCards,
    171      read_more_endpoint,
    172      contextMenuOptions,
    173      initialized,
    174      learnMore,
    175      pref,
    176      privacyNoticeURL,
    177      isFirst,
    178      isLast,
    179    } = this.props;
    180 
    181    const waitingForSpoc =
    182      id === "topstories" && this.props.Pocket.waitingForSpoc;
    183    const maxCardsPerRow = compactCards
    184      ? CARDS_PER_ROW_COMPACT_WIDE
    185      : CARDS_PER_ROW_DEFAULT;
    186    const { numRows } = this;
    187    const maxCards = maxCardsPerRow * numRows;
    188    const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows;
    189    const shouldShowReadMore = read_more_endpoint;
    190 
    191    const realRows = rows.slice(0, maxCards);
    192 
    193    // The empty state should only be shown after we have initialized and there is no content.
    194    // Otherwise, we should show placeholders.
    195    const shouldShowEmptyState = initialized && !rows.length;
    196 
    197    const cards = [];
    198    if (!shouldShowEmptyState) {
    199      for (let i = 0; i < maxCards; i++) {
    200        const link = realRows[i];
    201        // On narrow viewports, we only show 3 cards per row. We'll mark the rest as
    202        // .hide-for-narrow to hide in CSS via @media query.
    203        const className = i >= maxCardsOnNarrow ? "hide-for-narrow" : "";
    204        let usePlaceholder = !link;
    205        // If we are in the third card and waiting for spoc,
    206        // use the placeholder.
    207        if (!usePlaceholder && i === 2 && waitingForSpoc) {
    208          usePlaceholder = true;
    209        }
    210        cards.push(
    211          !usePlaceholder ? (
    212            <Card
    213              key={i}
    214              index={i}
    215              className={className}
    216              dispatch={dispatch}
    217              link={link}
    218              contextMenuOptions={contextMenuOptions}
    219              eventSource={eventSource}
    220              shouldSendImpressionStats={this.props.shouldSendImpressionStats}
    221              isWebExtension={this.props.isWebExtension}
    222            />
    223          ) : (
    224            <PlaceholderCard key={i} className={className} />
    225          )
    226        );
    227      }
    228    }
    229 
    230    const sectionClassName = [
    231      "section",
    232      compactCards ? "compact-cards" : "normal-cards",
    233    ].join(" ");
    234 
    235    // <Section> <-- React component
    236    // <section> <-- HTML5 element
    237    return (
    238      <ComponentPerfTimer {...this.props}>
    239        <CollapsibleSection
    240          className={sectionClassName}
    241          title={title}
    242          id={id}
    243          eventSource={eventSource}
    244          collapsed={this.props.pref.collapsed}
    245          showPrefName={(pref && pref.feed) || id}
    246          privacyNoticeURL={privacyNoticeURL}
    247          Prefs={this.props.Prefs}
    248          isFixed={this.props.isFixed}
    249          isFirst={isFirst}
    250          isLast={isLast}
    251          learnMore={learnMore}
    252          dispatch={this.props.dispatch}
    253          isWebExtension={this.props.isWebExtension}
    254        >
    255          {!shouldShowEmptyState && (
    256            <ul className="section-list" style={{ padding: 0 }}>
    257              {cards}
    258            </ul>
    259          )}
    260          {shouldShowEmptyState && (
    261            <div className="section-empty-state">
    262              <div className="empty-state">
    263                <FluentOrText message={emptyState.message}>
    264                  <p className="empty-state-message" />
    265                </FluentOrText>
    266              </div>
    267            </div>
    268          )}
    269          {id === "topstories" && (
    270            <div className="top-stories-bottom-container">
    271              <div className="wrapper-more-recommendations">
    272                {shouldShowReadMore && (
    273                  <MoreRecommendations
    274                    read_more_endpoint={read_more_endpoint}
    275                  />
    276                )}
    277              </div>
    278            </div>
    279          )}
    280        </CollapsibleSection>
    281      </ComponentPerfTimer>
    282    );
    283  }
    284 }
    285 
    286 Section.defaultProps = {
    287  document: globalThis.document,
    288  rows: [],
    289  emptyState: {},
    290  pref: {},
    291  title: "",
    292 };
    293 
    294 export const SectionIntl = connect(state => ({
    295  Prefs: state.Prefs,
    296  Pocket: state.Pocket,
    297 }))(Section);
    298 
    299 export class _Sections extends React.PureComponent {
    300  renderSections() {
    301    const sections = [];
    302    const enabledSections = this.props.Sections.filter(
    303      section => section.enabled
    304    );
    305    const { sectionOrder, "feeds.topsites": showTopSites } =
    306      this.props.Prefs.values;
    307    // Enabled sections doesn't include Top Sites, so we add it if enabled.
    308    const expectedCount = enabledSections.length + ~~showTopSites;
    309 
    310    for (const sectionId of sectionOrder.split(",")) {
    311      const commonProps = {
    312        key: sectionId,
    313        isFirst: sections.length === 0,
    314        isLast: sections.length === expectedCount - 1,
    315      };
    316      if (sectionId === "topsites" && showTopSites) {
    317        sections.push(<TopSites {...commonProps} />);
    318      } else {
    319        const section = enabledSections.find(s => s.id === sectionId);
    320        if (section) {
    321          sections.push(<SectionIntl {...section} {...commonProps} />);
    322        }
    323      }
    324    }
    325    return sections;
    326  }
    327 
    328  render() {
    329    return <div className="sections-list">{this.renderSections()}</div>;
    330  }
    331 }
    332 
    333 export const Sections = connect(state => ({
    334  Sections: state.Sections,
    335  Prefs: state.Prefs,
    336 }))(_Sections);