tor-browser

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

InterestPicker.jsx (5728B)


      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 React, { useState, useRef, useCallback } from "react";
      6 import { useDispatch, useSelector } from "react-redux";
      7 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
      8 import { useIntersectionObserver } from "../../../lib/utils";
      9 const PREF_VISIBLE_SECTIONS =
     10  "discoverystream.sections.interestPicker.visibleSections";
     11 
     12 /**
     13 * Shows a list of recommended topics with visual indication whether
     14 * the user follows some of the topics (active, blue, selected topics)
     15 * or is yet to do so (neutrally-coloured topics with a "plus" button).
     16 *
     17 * @returns {React.Element}
     18 */
     19 function InterestPicker({ title, subtitle, interests, receivedFeedRank }) {
     20  const dispatch = useDispatch();
     21  const focusedRef = useRef(null);
     22  const focusRef = useRef(null);
     23  const [focusedIndex, setFocusedIndex] = useState(0);
     24  const prefs = useSelector(state => state.Prefs.values);
     25  const { sectionPersonalization } = useSelector(
     26    state => state.DiscoveryStream
     27  );
     28  const visibleSections = prefs[PREF_VISIBLE_SECTIONS]?.split(",")
     29    .map(item => item.trim())
     30    .filter(item => item);
     31 
     32  const handleIntersection = useCallback(() => {
     33    dispatch(
     34      ac.AlsoToMain({
     35        type: at.INLINE_SELECTION_IMPRESSION,
     36        data: {
     37          section_position: receivedFeedRank,
     38        },
     39      })
     40    );
     41  }, [dispatch, receivedFeedRank]);
     42 
     43  const ref = useIntersectionObserver(handleIntersection);
     44 
     45  const onKeyDown = useCallback(e => {
     46    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
     47      // prevent the page from scrolling up/down while navigating.
     48      e.preventDefault();
     49    }
     50 
     51    if (
     52      focusedRef.current?.nextSibling?.querySelector("input") &&
     53      e.key === "ArrowDown"
     54    ) {
     55      focusedRef.current.nextSibling.querySelector("input").tabIndex = 0;
     56      focusedRef.current.nextSibling.querySelector("input").focus();
     57    }
     58    if (
     59      focusedRef.current?.previousSibling?.querySelector("input") &&
     60      e.key === "ArrowUp"
     61    ) {
     62      focusedRef.current.previousSibling.querySelector("input").tabIndex = 0;
     63      focusedRef.current.previousSibling.querySelector("input").focus();
     64    }
     65  }, []);
     66 
     67  function onWrapperFocus() {
     68    focusRef.current?.addEventListener("keydown", onKeyDown);
     69  }
     70  function onWrapperBlur() {
     71    focusRef.current?.removeEventListener("keydown", onKeyDown);
     72  }
     73  function onItemFocus(index) {
     74    setFocusedIndex(index);
     75  }
     76 
     77  // Updates user preferences as they follow or unfollow topics
     78  // by selecting them from the list
     79  function handleChange(e, index) {
     80    const { name: topic, checked } = e.target;
     81    let updatedSections = { ...sectionPersonalization };
     82    if (checked) {
     83      updatedSections[topic] = {
     84        isFollowed: true,
     85        isBlocked: false,
     86        followedAt: new Date().toISOString(),
     87      };
     88      if (!visibleSections.includes(topic)) {
     89        // add section to visible sections and place after the inline picker
     90        // subtract 1 from the rank so that it is normalized with array index
     91        visibleSections.splice(receivedFeedRank - 1, 0, topic);
     92        dispatch(ac.SetPref(PREF_VISIBLE_SECTIONS, visibleSections.join(", ")));
     93      }
     94    } else {
     95      delete updatedSections[topic];
     96    }
     97    dispatch(
     98      ac.OnlyToMain({
     99        type: at.INLINE_SELECTION_CLICK,
    100        data: {
    101          topic,
    102          is_followed: checked,
    103          topic_position: index,
    104          section_position: receivedFeedRank,
    105        },
    106      })
    107    );
    108    dispatch(
    109      ac.AlsoToMain({
    110        type: at.SECTION_PERSONALIZATION_SET,
    111        data: updatedSections,
    112      })
    113    );
    114  }
    115  return (
    116    <section
    117      className="inline-selection-wrapper ds-section"
    118      ref={el => {
    119        ref.current = [el];
    120      }}
    121    >
    122      <div className="section-heading">
    123        <div className="section-title-wrapper">
    124          <h2 className="section-title">{title}</h2>
    125          <p className="section-subtitle">{subtitle}</p>
    126        </div>
    127      </div>
    128      <ul
    129        className="topic-list"
    130        onFocus={onWrapperFocus}
    131        onBlur={onWrapperBlur}
    132        ref={focusRef}
    133      >
    134        {interests.map((interest, index) => {
    135          const checked =
    136            sectionPersonalization[interest.sectionId]?.isFollowed;
    137          return (
    138            <li
    139              key={interest.sectionId}
    140              ref={index === focusedIndex ? focusedRef : null}
    141            >
    142              <label>
    143                <input
    144                  type="checkbox"
    145                  id={interest.sectionId}
    146                  name={interest.sectionId}
    147                  checked={checked}
    148                  aria-checked={checked}
    149                  onChange={e => handleChange(e, index)}
    150                  key={`${interest.sectionId}-${checked}`} // Force remount to sync DOM state with React state
    151                  tabIndex={index === focusedIndex ? 0 : -1}
    152                  onFocus={() => {
    153                    onItemFocus(index);
    154                  }}
    155                />
    156                <span className="topic-item-label">{interest.title || ""}</span>
    157                <div
    158                  className={`topic-item-icon icon ${checked ? "icon-check-filled" : "icon-add-circle-fill"}`}
    159                ></div>
    160              </label>
    161            </li>
    162          );
    163        })}
    164      </ul>
    165      <p className="learn-more-copy">
    166        <a
    167          href={prefs["support.url"]}
    168          data-l10n-id="newtab-topic-selection-privacy-link"
    169        />
    170      </p>
    171    </section>
    172  );
    173 }
    174 
    175 export { InterestPicker };