tor-browser

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

CardSections.jsx (21166B)


      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
      3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
      4 
      5 import React, { useCallback, useState } from "react";
      6 import { DSEmptyState } from "../DSEmptyState/DSEmptyState";
      7 import { DSCard, PlaceholderDSCard } from "../DSCard/DSCard";
      8 import { useSelector } from "react-redux";
      9 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
     10 import {
     11  selectWeatherPlacement,
     12  useIntersectionObserver,
     13  getActiveColumnLayout,
     14 } from "../../../lib/utils";
     15 import { SectionContextMenu } from "../SectionContextMenu/SectionContextMenu";
     16 import { InterestPicker } from "../InterestPicker/InterestPicker";
     17 import { AdBanner } from "../AdBanner/AdBanner.jsx";
     18 import { PersonalizedCard } from "../PersonalizedCard/PersonalizedCard";
     19 import { FollowSectionButtonHighlight } from "../FeatureHighlight/FollowSectionButtonHighlight";
     20 import { MessageWrapper } from "content-src/components/MessageWrapper/MessageWrapper";
     21 import { Weather } from "../../Weather/Weather.jsx";
     22 
     23 // Prefs
     24 const PREF_SECTIONS_CARDS_ENABLED = "discoverystream.sections.cards.enabled";
     25 const PREF_SECTIONS_PERSONALIZATION_ENABLED =
     26  "discoverystream.sections.personalization.enabled";
     27 const PREF_TOPICS_ENABLED = "discoverystream.topicLabels.enabled";
     28 const PREF_TOPICS_SELECTED = "discoverystream.topicSelection.selectedTopics";
     29 const PREF_TOPICS_AVAILABLE = "discoverystream.topicSelection.topics";
     30 const PREF_INTEREST_PICKER_ENABLED =
     31  "discoverystream.sections.interestPicker.enabled";
     32 const PREF_VISIBLE_SECTIONS =
     33  "discoverystream.sections.interestPicker.visibleSections";
     34 const PREF_BILLBOARD_ENABLED = "newtabAdSize.billboard";
     35 const PREF_BILLBOARD_POSITION = "newtabAdSize.billboard.position";
     36 const PREF_LEADERBOARD_ENABLED = "newtabAdSize.leaderboard";
     37 const PREF_LEADERBOARD_POSITION = "newtabAdSize.leaderboard.position";
     38 const PREF_REFINED_CARDS_ENABLED = "discoverystream.refinedCardsLayout.enabled";
     39 const PREF_INFERRED_PERSONALIZATION_USER =
     40  "discoverystream.sections.personalization.inferred.user.enabled";
     41 const PREF_DAILY_BRIEF_SECTIONID = "discoverystream.dailyBrief.sectionId";
     42 const PREF_SPOCS_STARTUPCACHE_ENABLED =
     43  "discoverystream.spocs.startupCache.enabled";
     44 
     45 function getLayoutData(responsiveLayouts, index, refinedCardsLayout) {
     46  let layoutData = {
     47    classNames: [],
     48    imageSizes: {},
     49  };
     50 
     51  responsiveLayouts.forEach(layout => {
     52    layout.tiles.forEach((tile, tileIndex) => {
     53      if (tile.position === index) {
     54        layoutData.classNames.push(`col-${layout.columnCount}-${tile.size}`);
     55        layoutData.classNames.push(
     56          `col-${layout.columnCount}-position-${tileIndex}`
     57        );
     58        layoutData.imageSizes[layout.columnCount] = tile.size;
     59 
     60        // The API tells us whether the tile should show the excerpt or not.
     61        // Apply extra styles accordingly.
     62        if (tile.hasExcerpt) {
     63          if (tile.size === "medium" && refinedCardsLayout) {
     64            layoutData.classNames.push(
     65              `col-${layout.columnCount}-hide-excerpt`
     66            );
     67          } else {
     68            layoutData.classNames.push(
     69              `col-${layout.columnCount}-show-excerpt`
     70            );
     71          }
     72        } else {
     73          layoutData.classNames.push(`col-${layout.columnCount}-hide-excerpt`);
     74        }
     75      }
     76    });
     77  });
     78 
     79  return layoutData;
     80 }
     81 
     82 // function to determine amount of tiles shown per section per viewport
     83 function getMaxTiles(responsiveLayouts) {
     84  return responsiveLayouts
     85    .flatMap(responsiveLayout => responsiveLayout)
     86    .reduce((acc, t) => {
     87      acc[t.columnCount] = t.tiles.length;
     88 
     89      // Update maxTile if current tile count is greater
     90      if (!acc.maxTile || t.tiles.length > acc.maxTile) {
     91        acc.maxTile = t.tiles.length;
     92      }
     93      return acc;
     94    }, {});
     95 }
     96 
     97 /**
     98 * Transforms a comma-separated string in user preferences
     99 * into a cleaned-up array.
    100 *
    101 * @param {string} pref - The comma-separated pref to be converted.
    102 * @returns {string[]} An array of trimmed strings, excluding empty values.
    103 */
    104 
    105 const prefToArray = (pref = "") => {
    106  return pref
    107    .split(",")
    108    .map(item => item.trim())
    109    .filter(item => item);
    110 };
    111 
    112 function shouldShowOMCHighlight(messageData, componentId) {
    113  if (!messageData || Object.keys(messageData).length === 0) {
    114    return false;
    115  }
    116  return messageData?.content?.messageType === componentId;
    117 }
    118 
    119 function CardSection({
    120  sectionPosition,
    121  section,
    122  dispatch,
    123  type,
    124  firstVisibleTimestamp,
    125  ctaButtonVariant,
    126  ctaButtonSponsors,
    127  anySectionsFollowed,
    128  showWeather,
    129  placeholder,
    130 }) {
    131  const prefs = useSelector(state => state.Prefs.values);
    132 
    133  const { messageData } = useSelector(state => state.Messages);
    134 
    135  const { sectionPersonalization } = useSelector(
    136    state => state.DiscoveryStream
    137  );
    138  const { isForStartupCache } = useSelector(state => state.App);
    139 
    140  const [focusedIndex, setFocusedIndex] = useState(0);
    141 
    142  const onCardFocus = index => {
    143    setFocusedIndex(index);
    144  };
    145 
    146  const handleCardKeyDown = e => {
    147    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
    148      e.preventDefault();
    149 
    150      const currentCardEl = e.target.closest("article.ds-card");
    151      if (!currentCardEl) {
    152        return;
    153      }
    154 
    155      const activeColumn = getActiveColumnLayout(window.innerWidth);
    156 
    157      // Arrow direction should match visual navigation direction in RTL
    158      const isRTL = document.dir === "rtl";
    159      const navigateToPrevious = isRTL
    160        ? e.key === "ArrowRight"
    161        : e.key === "ArrowLeft";
    162 
    163      // Extract current position from classList
    164      let currentPosition = null;
    165      const positionPrefix = `${activeColumn}-position-`;
    166      for (let className of currentCardEl.classList) {
    167        if (className.startsWith(positionPrefix)) {
    168          currentPosition = parseInt(
    169            className.substring(positionPrefix.length),
    170            10
    171          );
    172          break;
    173        }
    174      }
    175 
    176      if (currentPosition === null) {
    177        return;
    178      }
    179 
    180      const targetPosition = navigateToPrevious
    181        ? currentPosition - 1
    182        : currentPosition + 1;
    183 
    184      // Find card with target position
    185      const parentEl = currentCardEl.parentElement;
    186      if (parentEl) {
    187        const targetSelector = `article.ds-card.${activeColumn}-position-${targetPosition}`;
    188        const targetCardEl = parentEl.querySelector(targetSelector);
    189 
    190        if (targetCardEl) {
    191          const link = targetCardEl.querySelector("a.ds-card-link");
    192          if (link) {
    193            link.focus();
    194          }
    195        }
    196      }
    197    }
    198  };
    199 
    200  const showTopics = prefs[PREF_TOPICS_ENABLED];
    201  const mayHaveSectionsCards = prefs[PREF_SECTIONS_CARDS_ENABLED];
    202  const selectedTopics = prefs[PREF_TOPICS_SELECTED];
    203  const availableTopics = prefs[PREF_TOPICS_AVAILABLE];
    204  const refinedCardsLayout = prefs[PREF_REFINED_CARDS_ENABLED];
    205  const spocsStartupCacheEnabled = prefs[PREF_SPOCS_STARTUPCACHE_ENABLED];
    206 
    207  const mayHaveSectionsPersonalization =
    208    prefs[PREF_SECTIONS_PERSONALIZATION_ENABLED];
    209 
    210  const { sectionKey, title, subtitle } = section;
    211  const { responsiveLayouts, name: layoutName } = section.layout;
    212 
    213  const following = sectionPersonalization[sectionKey]?.isFollowed;
    214 
    215  const handleIntersection = useCallback(() => {
    216    dispatch(
    217      ac.AlsoToMain({
    218        type: at.CARD_SECTION_IMPRESSION,
    219        data: {
    220          section: sectionKey,
    221          section_position: sectionPosition,
    222          is_section_followed: following,
    223          layout_name: layoutName,
    224        },
    225      })
    226    );
    227  }, [dispatch, sectionKey, sectionPosition, following, layoutName]);
    228 
    229  // Ref to hold the section element
    230  const sectionRefs = useIntersectionObserver(handleIntersection);
    231 
    232  const onFollowClick = useCallback(() => {
    233    const updatedSectionData = {
    234      ...sectionPersonalization,
    235      [sectionKey]: {
    236        isFollowed: true,
    237        isBlocked: false,
    238        followedAt: new Date().toISOString(),
    239      },
    240    };
    241    dispatch(
    242      ac.AlsoToMain({
    243        type: at.SECTION_PERSONALIZATION_SET,
    244        data: updatedSectionData,
    245      })
    246    );
    247    // Telemetry Event Dispatch
    248    dispatch(
    249      ac.OnlyToMain({
    250        type: "FOLLOW_SECTION",
    251        data: {
    252          section: sectionKey,
    253          section_position: sectionPosition,
    254          event_source: "MOZ_BUTTON",
    255        },
    256      })
    257    );
    258  }, [dispatch, sectionPersonalization, sectionKey, sectionPosition]);
    259 
    260  const onUnfollowClick = useCallback(() => {
    261    const updatedSectionData = { ...sectionPersonalization };
    262    delete updatedSectionData[sectionKey];
    263    dispatch(
    264      ac.AlsoToMain({
    265        type: at.SECTION_PERSONALIZATION_SET,
    266        data: updatedSectionData,
    267      })
    268    );
    269 
    270    // Telemetry Event Dispatch
    271    dispatch(
    272      ac.OnlyToMain({
    273        type: "UNFOLLOW_SECTION",
    274        data: {
    275          section: sectionKey,
    276          section_position: sectionPosition,
    277          event_source: "MOZ_BUTTON",
    278        },
    279      })
    280    );
    281  }, [dispatch, sectionPersonalization, sectionKey, sectionPosition]);
    282 
    283  let { maxTile } = getMaxTiles(responsiveLayouts);
    284  if (placeholder) {
    285    // We need a number that divides evenly by 2, 3, and 4.
    286    // So it can be displayed without orphans in grids with 2, 3, and 4 columns.
    287    maxTile = 12;
    288  }
    289 
    290  const displaySections = section.data.slice(0, maxTile);
    291  const isSectionEmpty = !displaySections?.length;
    292  const shouldShowLabels = sectionKey === "top_stories_section" && showTopics;
    293 
    294  if (isSectionEmpty) {
    295    return null;
    296  }
    297 
    298  const sectionContextWrapper = (
    299    <div className="section-context-wrapper">
    300      <div
    301        className={following ? "section-follow following" : "section-follow"}
    302      >
    303        {!anySectionsFollowed &&
    304          sectionPosition === 0 &&
    305          shouldShowOMCHighlight(
    306            messageData,
    307            "FollowSectionButtonHighlight"
    308          ) && (
    309            <MessageWrapper dispatch={dispatch}>
    310              <FollowSectionButtonHighlight
    311                verticalPosition="inset-block-center"
    312                position="arrow-inline-start"
    313                dispatch={dispatch}
    314                feature="FEATURE_FOLLOW_SECTION_BUTTON"
    315                messageData={messageData}
    316              />
    317            </MessageWrapper>
    318          )}
    319        {!anySectionsFollowed &&
    320          sectionPosition === 0 &&
    321          shouldShowOMCHighlight(
    322            messageData,
    323            "FollowSectionButtonAltHighlight"
    324          ) && (
    325            <MessageWrapper dispatch={dispatch}>
    326              <FollowSectionButtonHighlight
    327                verticalPosition="inset-block-center"
    328                position="arrow-inline-start"
    329                dispatch={dispatch}
    330                feature="FEATURE_ALT_FOLLOW_SECTION_BUTTON"
    331              />
    332            </MessageWrapper>
    333          )}
    334        <moz-button
    335          onClick={following ? onUnfollowClick : onFollowClick}
    336          type="default"
    337          index={sectionPosition}
    338          section={sectionKey}
    339        >
    340          <span
    341            className="section-button-follow-text"
    342            data-l10n-id="newtab-section-follow-button"
    343          />
    344          <span
    345            className="section-button-following-text"
    346            data-l10n-id="newtab-section-following-button"
    347          />
    348          <span
    349            className="section-button-unfollow-text"
    350            data-l10n-id="newtab-section-unfollow-button"
    351          />
    352        </moz-button>
    353      </div>
    354      <SectionContextMenu
    355        dispatch={dispatch}
    356        index={sectionPosition}
    357        following={following}
    358        sectionPersonalization={sectionPersonalization}
    359        sectionKey={sectionKey}
    360        title={title}
    361        type={type}
    362        sectionPosition={sectionPosition}
    363      />
    364    </div>
    365  );
    366  return (
    367    <section
    368      className="ds-section"
    369      ref={el => {
    370        sectionRefs.current[0] = el;
    371      }}
    372    >
    373      <div className="section-heading">
    374        <div className="section-heading-inline-start">
    375          <div className="section-title-wrapper">
    376            <h2 className="section-title">{title}</h2>
    377            {subtitle && <p className="section-subtitle">{subtitle}</p>}
    378          </div>
    379          {showWeather && <Weather isInSection={true} />}
    380        </div>
    381        {mayHaveSectionsPersonalization ? sectionContextWrapper : null}
    382      </div>
    383      <div
    384        className={`ds-section-grid ds-card-grid`}
    385        onKeyDown={handleCardKeyDown}
    386      >
    387        {section.data.slice(0, maxTile).map((rec, index) => {
    388          const layoutData = getLayoutData(
    389            responsiveLayouts,
    390            index,
    391            refinedCardsLayout
    392          );
    393 
    394          const { classNames, imageSizes } = layoutData;
    395          // Render a placeholder card when:
    396          // 1. No recommendation is available.
    397          // 2. The item is flagged as a placeholder.
    398          // 3. Spocs are loading for with spocs startup cache disabled.
    399          if (
    400            !rec ||
    401            rec.placeholder ||
    402            placeholder ||
    403            (rec.flight_id &&
    404              !spocsStartupCacheEnabled &&
    405              isForStartupCache.DiscoveryStream)
    406          ) {
    407            return <PlaceholderDSCard key={`dscard-${index}`} />;
    408          }
    409 
    410          const card = (
    411            <DSCard
    412              key={`dscard-${rec.id}`}
    413              pos={rec.pos}
    414              flightId={rec.flight_id}
    415              image_src={rec.image_src}
    416              raw_image_src={rec.raw_image_src}
    417              icon_src={rec.icon_src}
    418              word_count={rec.word_count}
    419              time_to_read={rec.time_to_read}
    420              title={rec.title}
    421              topic={rec.topic}
    422              features={rec.features}
    423              excerpt={rec.excerpt}
    424              url={rec.url}
    425              id={rec.id}
    426              shim={rec.shim}
    427              fetchTimestamp={rec.fetchTimestamp}
    428              type={type}
    429              context={rec.context}
    430              sponsor={rec.sponsor}
    431              sponsored_by_override={rec.sponsored_by_override}
    432              dispatch={dispatch}
    433              source={rec.domain}
    434              publisher={rec.publisher}
    435              pocket_id={rec.pocket_id}
    436              context_type={rec.context_type}
    437              bookmarkGuid={rec.bookmarkGuid}
    438              recommendation_id={rec.recommendation_id}
    439              firstVisibleTimestamp={firstVisibleTimestamp}
    440              corpus_item_id={rec.corpus_item_id}
    441              scheduled_corpus_item_id={rec.scheduled_corpus_item_id}
    442              recommended_at={rec.recommended_at}
    443              received_rank={rec.received_rank}
    444              format={rec.format}
    445              alt_text={rec.alt_text}
    446              mayHaveSectionsCards={mayHaveSectionsCards}
    447              showTopics={shouldShowLabels}
    448              selectedTopics={selectedTopics}
    449              availableTopics={availableTopics}
    450              ctaButtonSponsors={ctaButtonSponsors}
    451              ctaButtonVariant={ctaButtonVariant}
    452              sectionsClassNames={classNames.join(" ")}
    453              sectionsCardImageSizes={imageSizes}
    454              section={sectionKey}
    455              sectionPosition={sectionPosition}
    456              sectionFollowed={following}
    457              sectionLayoutName={layoutName}
    458              isTimeSensitive={rec.isTimeSensitive}
    459              tabIndex={index === focusedIndex ? 0 : -1}
    460              onFocus={() => onCardFocus(index)}
    461              attribution={rec.attribution}
    462            />
    463          );
    464          return [card];
    465        })}
    466      </div>
    467    </section>
    468  );
    469 }
    470 
    471 function CardSections({
    472  data,
    473  feed,
    474  dispatch,
    475  type,
    476  firstVisibleTimestamp,
    477  ctaButtonVariant,
    478  ctaButtonSponsors,
    479  placeholder,
    480 }) {
    481  const prefs = useSelector(state => state.Prefs.values);
    482  const { spocs, sectionPersonalization } = useSelector(
    483    state => state.DiscoveryStream
    484  );
    485  const { messageData } = useSelector(state => state.Messages);
    486  const weatherPlacement = useSelector(selectWeatherPlacement);
    487  const dailyBriefSectionId =
    488    prefs.trainhopConfig?.dailyBriefing?.sectionId ||
    489    prefs[PREF_DAILY_BRIEF_SECTIONID];
    490  const weatherEnabled = prefs.showWeather;
    491  const personalizationEnabled = prefs[PREF_SECTIONS_PERSONALIZATION_ENABLED];
    492  const interestPickerEnabled = prefs[PREF_INTEREST_PICKER_ENABLED];
    493 
    494  // Handle a render before feed has been fetched by displaying nothing
    495  if (!data) {
    496    return null;
    497  }
    498 
    499  const visibleSections = prefToArray(prefs[PREF_VISIBLE_SECTIONS]);
    500  const { interestPicker } = data;
    501 
    502  // Used to determine if we should show FollowSectionButtonHighlight
    503  const anySectionsFollowed =
    504    sectionPersonalization &&
    505    Object.values(sectionPersonalization).some(section => section?.isFollowed);
    506 
    507  let sectionsData = data.sections;
    508 
    509  if (placeholder) {
    510    // To clean up the placeholder state for sections if the whole section is loading still.
    511    sectionsData = [
    512      {
    513        ...sectionsData[0],
    514        title: "",
    515        subtitle: "",
    516      },
    517      {
    518        ...sectionsData[1],
    519        title: "",
    520        subtitle: "",
    521      },
    522    ];
    523  }
    524 
    525  let filteredSections = sectionsData.filter(
    526    section => !sectionPersonalization[section.sectionKey]?.isBlocked
    527  );
    528 
    529  if (interestPickerEnabled && visibleSections.length) {
    530    filteredSections = visibleSections.reduce((acc, visibleSection) => {
    531      const found = filteredSections.find(
    532        ({ sectionKey }) => sectionKey === visibleSection
    533      );
    534      if (found) {
    535        acc.push(found);
    536      }
    537      return acc;
    538    }, []);
    539  }
    540 
    541  let sectionsToRender = filteredSections.map((section, sectionPosition) => (
    542    <CardSection
    543      key={`section-${section.sectionKey}`}
    544      sectionPosition={sectionPosition}
    545      section={section}
    546      dispatch={dispatch}
    547      type={type}
    548      firstVisibleTimestamp={firstVisibleTimestamp}
    549      ctaButtonVariant={ctaButtonVariant}
    550      ctaButtonSponsors={ctaButtonSponsors}
    551      anySectionsFollowed={anySectionsFollowed}
    552      placeholder={placeholder}
    553      showWeather={
    554        weatherEnabled &&
    555        weatherPlacement === "section" &&
    556        sectionPosition === 0 &&
    557        section.sectionKey === dailyBriefSectionId
    558      }
    559    />
    560  ));
    561 
    562  // Add a billboard/leaderboard IAB ad to the sectionsToRender array (if enabled/possible).
    563  const billboardEnabled = prefs[PREF_BILLBOARD_ENABLED];
    564  const leaderboardEnabled = prefs[PREF_LEADERBOARD_ENABLED];
    565 
    566  if (
    567    (billboardEnabled || leaderboardEnabled) &&
    568    spocs?.data?.newtab_spocs?.items
    569  ) {
    570    const spocToRender =
    571      spocs.data.newtab_spocs.items.find(
    572        ({ format }) => format === "leaderboard" && leaderboardEnabled
    573      ) ||
    574      spocs.data.newtab_spocs.items.find(
    575        ({ format }) => format === "billboard" && billboardEnabled
    576      );
    577 
    578    if (spocToRender && !spocs.blocked.includes(spocToRender.url)) {
    579      const row =
    580        spocToRender.format === "leaderboard"
    581          ? prefs[PREF_LEADERBOARD_POSITION]
    582          : prefs[PREF_BILLBOARD_POSITION];
    583 
    584      sectionsToRender.splice(
    585        // Math.min is used here to ensure the given row stays within the bounds of the sectionsToRender array.
    586        Math.min(sectionsToRender.length - 1, row),
    587        0,
    588        <AdBanner
    589          spoc={spocToRender}
    590          key={`dscard-${spocToRender.id}`}
    591          dispatch={dispatch}
    592          type={type}
    593          firstVisibleTimestamp={firstVisibleTimestamp}
    594          row={row}
    595          prefs={prefs}
    596        />
    597      );
    598    }
    599  }
    600 
    601  // Add the interest picker to the sectionsToRender array (if enabled/possible).
    602  if (
    603    interestPickerEnabled &&
    604    personalizationEnabled &&
    605    interestPicker?.sections
    606  ) {
    607    const index = interestPicker.receivedFeedRank - 1;
    608 
    609    sectionsToRender.splice(
    610      // Math.min is used here to ensure the given row stays within the bounds of the sectionsToRender array.
    611      Math.min(sectionsToRender.length - 1, index),
    612      0,
    613      <InterestPicker
    614        title={interestPicker.title}
    615        subtitle={interestPicker.subtitle}
    616        interests={interestPicker.sections || []}
    617        receivedFeedRank={interestPicker.receivedFeedRank}
    618      />
    619    );
    620  }
    621 
    622  function displayP13nCard() {
    623    if (messageData && Object.keys(messageData).length >= 1) {
    624      if (
    625        shouldShowOMCHighlight(messageData, "PersonalizedCard") &&
    626        prefs[PREF_INFERRED_PERSONALIZATION_USER]
    627      ) {
    628        const row = messageData.content.position;
    629        sectionsToRender.splice(
    630          row,
    631          0,
    632          <MessageWrapper dispatch={dispatch} onDismiss={() => {}}>
    633            <PersonalizedCard
    634              position={row}
    635              dispatch={dispatch}
    636              messageData={messageData}
    637            />
    638          </MessageWrapper>
    639        );
    640      }
    641    }
    642  }
    643 
    644  displayP13nCard();
    645 
    646  const isEmpty = sectionsToRender.length === 0;
    647 
    648  return isEmpty ? (
    649    <div className="ds-card-grid empty">
    650      <DSEmptyState status={data.status} dispatch={dispatch} feed={feed} />
    651    </div>
    652  ) : (
    653    <div className="ds-section-wrapper">{sectionsToRender}</div>
    654  );
    655 }
    656 
    657 export { CardSections };