tor-browser

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

CardGrid.jsx (12709B)


      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 { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx";
      6 import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx";
      7 import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx";
      8 import { AdBanner } from "../AdBanner/AdBanner.jsx";
      9 import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx";
     10 import React, { useEffect, useRef } from "react";
     11 import { connect } from "react-redux";
     12 const PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled";
     13 const PREF_TOPICS_ENABLED = "discoverystream.topicLabels.enabled";
     14 const PREF_TOPICS_SELECTED = "discoverystream.topicSelection.selectedTopics";
     15 const PREF_TOPICS_AVAILABLE = "discoverystream.topicSelection.topics";
     16 const PREF_SPOCS_STARTUPCACHE_ENABLED =
     17  "discoverystream.spocs.startupCache.enabled";
     18 const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard";
     19 const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position";
     20 const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard";
     21 const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position";
     22 const WIDGET_IDS = {
     23  TOPICS: 1,
     24 };
     25 
     26 export function DSSubHeader({ children }) {
     27  return (
     28    <div className="section-top-bar ds-sub-header">
     29      <h3 className="section-title-container">{children}</h3>
     30    </div>
     31  );
     32 }
     33 
     34 // eslint-disable-next-line no-shadow
     35 export function IntersectionObserver({
     36  children,
     37  windowObj = window,
     38  onIntersecting,
     39 }) {
     40  const intersectionElement = useRef(null);
     41 
     42  useEffect(() => {
     43    let observer;
     44    if (!observer && onIntersecting && intersectionElement.current) {
     45      observer = new windowObj.IntersectionObserver(entries => {
     46        const entry = entries.find(e => e.isIntersecting);
     47 
     48        if (entry) {
     49          // Stop observing since element has been seen
     50          if (observer && intersectionElement.current) {
     51            observer.unobserve(intersectionElement.current);
     52          }
     53 
     54          onIntersecting();
     55        }
     56      });
     57      observer.observe(intersectionElement.current);
     58    }
     59    // Cleanup
     60    return () => observer?.disconnect();
     61  }, [windowObj, onIntersecting]);
     62 
     63  return <div ref={intersectionElement}>{children}</div>;
     64 }
     65 
     66 export class _CardGrid extends React.PureComponent {
     67  constructor(props) {
     68    super(props);
     69    this.state = {
     70      focusedIndex: 0,
     71    };
     72    this.onCardFocus = this.onCardFocus.bind(this);
     73    this.handleCardKeyDown = this.handleCardKeyDown.bind(this);
     74  }
     75 
     76  onCardFocus(index) {
     77    this.setState({ focusedIndex: index });
     78  }
     79 
     80  handleCardKeyDown(e) {
     81    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
     82      e.preventDefault();
     83 
     84      const currentCardEl = e.target.closest("article.ds-card");
     85      if (!currentCardEl) {
     86        return;
     87      }
     88 
     89      // Arrow direction should match visual navigation direction in RTL
     90      const isRTL = document.dir === "rtl";
     91      const navigateToPrevious = isRTL
     92        ? e.key === "ArrowRight"
     93        : e.key === "ArrowLeft";
     94 
     95      let targetCardEl = currentCardEl;
     96 
     97      // Walk through siblings to find the target card element
     98      while (targetCardEl) {
     99        targetCardEl = navigateToPrevious
    100          ? targetCardEl.previousElementSibling
    101          : targetCardEl.nextElementSibling;
    102 
    103        if (targetCardEl && targetCardEl.matches("article.ds-card")) {
    104          const link = targetCardEl.querySelector("a.ds-card-link");
    105          if (link) {
    106            link.focus();
    107          }
    108          break;
    109        }
    110      }
    111    }
    112  }
    113 
    114  // eslint-disable-next-line max-statements
    115  renderCards() {
    116    const prefs = this.props.Prefs.values;
    117    const {
    118      items,
    119      ctaButtonSponsors,
    120      ctaButtonVariant,
    121      widgets,
    122      DiscoveryStream,
    123    } = this.props;
    124 
    125    const { topicsLoading } = DiscoveryStream;
    126    const mayHaveSectionsCards = prefs[PREF_SECTIONS_CARDS_ENABLED];
    127    const showTopics = prefs[PREF_TOPICS_ENABLED];
    128    const selectedTopics = prefs[PREF_TOPICS_SELECTED];
    129    const availableTopics = prefs[PREF_TOPICS_AVAILABLE];
    130    const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED];
    131    const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED];
    132    const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED];
    133 
    134    const recs = this.props.data.recommendations.slice(0, items);
    135    const cards = [];
    136    let cardIndex = 0;
    137 
    138    for (let index = 0; index < items; index++) {
    139      const rec = recs[index];
    140      const isPlaceholder =
    141        topicsLoading ||
    142        this.props.placeholder ||
    143        !rec ||
    144        rec.placeholder ||
    145        (rec.flight_id &&
    146          !spocsStartupCacheEnabled &&
    147          this.props.App.isForStartupCache.DiscoveryStream);
    148 
    149      if (isPlaceholder) {
    150        cards.push(<PlaceholderDSCard key={`dscard-${index}`} />);
    151      } else {
    152        const currentCardIndex = cardIndex;
    153        cardIndex++;
    154        cards.push(
    155          <DSCard
    156            key={`dscard-${rec.id}`}
    157            pos={rec.pos}
    158            flightId={rec.flight_id}
    159            image_src={rec.image_src}
    160            raw_image_src={rec.raw_image_src}
    161            icon_src={rec.icon_src}
    162            word_count={rec.word_count}
    163            time_to_read={rec.time_to_read}
    164            title={rec.title}
    165            topic={rec.topic}
    166            features={rec.features}
    167            showTopics={showTopics}
    168            selectedTopics={selectedTopics}
    169            excerpt={rec.excerpt}
    170            availableTopics={availableTopics}
    171            url={rec.url}
    172            id={rec.id}
    173            shim={rec.shim}
    174            fetchTimestamp={rec.fetchTimestamp}
    175            type={this.props.type}
    176            context={rec.context}
    177            sponsor={rec.sponsor}
    178            sponsored_by_override={rec.sponsored_by_override}
    179            dispatch={this.props.dispatch}
    180            source={rec.domain}
    181            publisher={rec.publisher}
    182            pocket_id={rec.pocket_id}
    183            context_type={rec.context_type}
    184            bookmarkGuid={rec.bookmarkGuid}
    185            ctaButtonSponsors={ctaButtonSponsors}
    186            ctaButtonVariant={ctaButtonVariant}
    187            recommendation_id={rec.recommendation_id}
    188            firstVisibleTimestamp={this.props.firstVisibleTimestamp}
    189            mayHaveSectionsCards={mayHaveSectionsCards}
    190            corpus_item_id={rec.corpus_item_id}
    191            scheduled_corpus_item_id={rec.scheduled_corpus_item_id}
    192            recommended_at={rec.recommended_at}
    193            received_rank={rec.received_rank}
    194            format={rec.format}
    195            alt_text={rec.alt_text}
    196            isTimeSensitive={rec.isTimeSensitive}
    197            tabIndex={currentCardIndex === this.state.focusedIndex ? 0 : -1}
    198            onFocus={() => this.onCardFocus(currentCardIndex)}
    199            attribution={rec.attribution}
    200          />
    201        );
    202      }
    203    }
    204 
    205    if (widgets?.positions?.length && widgets?.data?.length) {
    206      let positionIndex = 0;
    207      const source = "CARDGRID_WIDGET";
    208 
    209      for (const widget of widgets.data) {
    210        let widgetComponent = null;
    211        const position = widgets.positions[positionIndex];
    212 
    213        // Stop if we run out of positions to place widgets.
    214        if (!position) {
    215          break;
    216        }
    217 
    218        switch (widget?.type) {
    219          case "TopicsWidget":
    220            widgetComponent = (
    221              <TopicsWidget
    222                position={position.index}
    223                dispatch={this.props.dispatch}
    224                source={source}
    225                id={WIDGET_IDS.TOPICS}
    226              />
    227            );
    228            break;
    229        }
    230 
    231        if (widgetComponent) {
    232          // We found a widget, so up the position for next try.
    233          positionIndex++;
    234          // We replace an existing card with the widget.
    235          cards.splice(position.index, 1, widgetComponent);
    236        }
    237      }
    238    }
    239 
    240    // if a banner ad is enabled and we have any available, place them in the grid
    241    const { spocs } = this.props.DiscoveryStream;
    242 
    243    if (
    244      (billboardEnabled || leaderboardEnabled) &&
    245      spocs?.data?.newtab_spocs?.items
    246    ) {
    247      // Only render one AdBanner in the grid -
    248      // Prioritize rendering a leaderboard if it exists,
    249      // otherwise render a billboard
    250      const spocToRender =
    251        spocs.data.newtab_spocs.items.find(
    252          ({ format }) => format === "leaderboard" && leaderboardEnabled
    253        ) ||
    254        spocs.data.newtab_spocs.items.find(
    255          ({ format }) => format === "billboard" && billboardEnabled
    256        );
    257 
    258      if (spocToRender && !spocs.blocked.includes(spocToRender.url)) {
    259        const row =
    260          spocToRender.format === "leaderboard"
    261            ? prefs[PREF_LEADERBOARD_POSITION]
    262            : prefs[PREF_BILLBOARD_POSITION];
    263 
    264        function displayCardsPerRow() {
    265          // Determines the number of cards per row based on the window width:
    266          // width <= 1122px: 2 cards per row
    267          // width 1123px to 1697px: 3 cards per row
    268          // width >= 1698px: 4 cards per row
    269          if (window.innerWidth <= 1122) {
    270            return 2;
    271          } else if (window.innerWidth > 1122 && window.innerWidth < 1698) {
    272            return 3;
    273          }
    274          return 4;
    275        }
    276 
    277        const injectAdBanner = bannerIndex => {
    278          // .splice() inserts the AdBanner at the desired index, ensuring correct DOM order for accessibility and keyboard navigation.
    279          // .push() would place it at the end, which is visually incorrect even if adjusted with CSS.
    280          cards.splice(
    281            bannerIndex,
    282            0,
    283            <AdBanner
    284              spoc={spocToRender}
    285              key={`dscard-${spocToRender.id}`}
    286              dispatch={this.props.dispatch}
    287              type={this.props.type}
    288              firstVisibleTimestamp={this.props.firstVisibleTimestamp}
    289              row={row}
    290              prefs={prefs}
    291            />
    292          );
    293        };
    294 
    295        const getBannerIndex = () => {
    296          // Calculate the index for where the AdBanner should be added, depending on number of cards per row on the grid
    297          const cardsPerRow = displayCardsPerRow();
    298          let bannerIndex = (row - 1) * cardsPerRow;
    299          return bannerIndex;
    300        };
    301 
    302        injectAdBanner(getBannerIndex());
    303      }
    304    }
    305 
    306    const gridClassName = this.renderGridClassName();
    307 
    308    return (
    309      <>
    310        {cards?.length > 0 && (
    311          <div className={gridClassName} onKeyDown={this.handleCardKeyDown}>
    312            {cards}
    313          </div>
    314        )}
    315      </>
    316    );
    317  }
    318 
    319  renderGridClassName() {
    320    const {
    321      hybridLayout,
    322      hideCardBackground,
    323      fourCardLayout,
    324      compactGrid,
    325      hideDescriptions,
    326    } = this.props;
    327 
    328    const hideCardBackgroundClass = hideCardBackground
    329      ? `ds-card-grid-hide-background`
    330      : ``;
    331    const fourCardLayoutClass = fourCardLayout
    332      ? `ds-card-grid-four-card-variant`
    333      : ``;
    334    const hideDescriptionsClassName = !hideDescriptions
    335      ? `ds-card-grid-include-descriptions`
    336      : ``;
    337    const compactGridClassName = compactGrid ? `ds-card-grid-compact` : ``;
    338    const hybridLayoutClassName = hybridLayout
    339      ? `ds-card-grid-hybrid-layout`
    340      : ``;
    341 
    342    const gridClassName = `ds-card-grid ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`;
    343    return gridClassName;
    344  }
    345 
    346  render() {
    347    const { data } = this.props;
    348 
    349    // Handle a render before feed has been fetched by displaying nothing
    350    if (!data) {
    351      return null;
    352    }
    353 
    354    // Handle the case where a user has dismissed all recommendations
    355    const isEmpty = data.recommendations.length === 0;
    356 
    357    return (
    358      <div>
    359        {this.props.title && (
    360          <div className="ds-header">
    361            <div className="title">{this.props.title}</div>
    362            {this.props.context && (
    363              <FluentOrText message={this.props.context}>
    364                <div className="ds-context" />
    365              </FluentOrText>
    366            )}
    367          </div>
    368        )}
    369        {isEmpty ? (
    370          <div className="ds-card-grid empty">
    371            <DSEmptyState
    372              status={data.status}
    373              dispatch={this.props.dispatch}
    374              feed={this.props.feed}
    375            />
    376          </div>
    377        ) : (
    378          this.renderCards()
    379        )}
    380      </div>
    381    );
    382  }
    383 }
    384 
    385 _CardGrid.defaultProps = {
    386  items: 4, // Number of stories to display
    387 };
    388 
    389 export const CardGrid = connect(state => ({
    390  Prefs: state.Prefs,
    391  App: state.App,
    392  DiscoveryStream: state.DiscoveryStream,
    393 }))(_CardGrid);