tor-browser

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

TopicSelection.jsx (9474B)


      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, { useCallback, useEffect, useRef, useState } from "react";
      6 import { useDispatch, useSelector } from "react-redux";
      7 import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay";
      8 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
      9 
     10 const EMOJI_LABELS = {
     11  business: "๐Ÿ’ผ",
     12  arts: "๐ŸŽญ",
     13  food: "๐Ÿ•",
     14  health: "๐Ÿฉบ",
     15  finance: "๐Ÿ’ฐ",
     16  government: "๐Ÿ›๏ธ",
     17  sports: "โšฝ๏ธ",
     18  tech: "๐Ÿ’ป",
     19  travel: "โœˆ๏ธ",
     20  "education-science": "๐Ÿงช",
     21  society: "๐Ÿ’ก",
     22 };
     23 
     24 function TopicSelection({ supportUrl }) {
     25  const dispatch = useDispatch();
     26  const inputRef = useRef(null);
     27  const modalRef = useRef(null);
     28  const checkboxWrapperRef = useRef(null);
     29  const prefs = useSelector(state => state.Prefs.values);
     30  const topics = prefs["discoverystream.topicSelection.topics"].split(", ");
     31  const selectedTopics = prefs["discoverystream.topicSelection.selectedTopics"];
     32  const suggestedTopics =
     33    prefs["discoverystream.topicSelection.suggestedTopics"]?.split(", ");
     34  const displayCount =
     35    prefs["discoverystream.topicSelection.onboarding.displayCount"];
     36  const topicsHaveBeenPreviouslySet =
     37    prefs["discoverystream.topicSelection.hasBeenUpdatedPreviously"];
     38  const [isFirstRun] = useState(displayCount === 0);
     39  const displayCountRef = useRef(displayCount);
     40  const preselectedTopics = () => {
     41    if (selectedTopics) {
     42      return selectedTopics.split(", ");
     43    }
     44    return isFirstRun ? suggestedTopics : [];
     45  };
     46  const [topicsToSelect, setTopicsToSelect] = useState(preselectedTopics);
     47 
     48  function isFirstSave() {
     49    // Only return true if the user has not previous set prefs
     50    // and the selected topics pref is empty
     51    if (selectedTopics === "" && !topicsHaveBeenPreviouslySet) {
     52      return true;
     53    }
     54 
     55    return false;
     56  }
     57 
     58  function handleModalClose() {
     59    dispatch(ac.OnlyToMain({ type: at.TOPIC_SELECTION_USER_DISMISS }));
     60    dispatch(
     61      ac.BroadcastToContent({ type: at.TOPIC_SELECTION_SPOTLIGHT_CLOSE })
     62    );
     63  }
     64 
     65  function handleUserClose(e) {
     66    const id = e?.target?.id;
     67 
     68    if (id === "first-run") {
     69      dispatch(ac.AlsoToMain({ type: at.TOPIC_SELECTION_MAYBE_LATER }));
     70      dispatch(
     71        ac.SetPref(
     72          "discoverystream.topicSelection.onboarding.maybeDisplay",
     73          true
     74        )
     75      );
     76    } else {
     77      dispatch(
     78        ac.SetPref(
     79          "discoverystream.topicSelection.onboarding.maybeDisplay",
     80          false
     81        )
     82      );
     83    }
     84    handleModalClose();
     85  }
     86 
     87  // By doing this, the useEffect that sets up the IntersectionObserver
     88  // will not re-run every time displayCount changes,
     89  // but the observer callback will always have access
     90  // to the latest displayCount value through the ref.
     91  useEffect(() => {
     92    displayCountRef.current = displayCount;
     93  }, [displayCount]);
     94 
     95  useEffect(() => {
     96    const { current } = modalRef;
     97    let observer;
     98    if (current) {
     99      observer = new IntersectionObserver(([entry]) => {
    100        if (entry.isIntersecting) {
    101          // if the user has seen the modal more than 3 times,
    102          // automatically remove them from onboarding
    103          if (displayCountRef.current > 3) {
    104            dispatch(
    105              ac.SetPref(
    106                "discoverystream.topicSelection.onboarding.maybeDisplay",
    107                false
    108              )
    109            );
    110          }
    111          observer.unobserve(modalRef.current);
    112          dispatch(
    113            ac.AlsoToMain({
    114              type: at.TOPIC_SELECTION_IMPRESSION,
    115            })
    116          );
    117        }
    118      });
    119      observer.observe(current);
    120    }
    121 
    122    return () => {
    123      if (current) {
    124        observer.unobserve(current);
    125      }
    126    };
    127  }, [modalRef, dispatch]);
    128 
    129  // when component mounts, set focus to input
    130  useEffect(() => {
    131    inputRef?.current?.focus();
    132  }, [inputRef]);
    133 
    134  const handleFocus = useCallback(e => {
    135    // this list will have to be updated with other reusable components that get used inside of this modal
    136    const tabbableElements = modalRef.current.querySelectorAll(
    137      'a[href], button, moz-button, input[tabindex="0"]'
    138    );
    139    const [firstTabableEl] = tabbableElements;
    140    const lastTabbableEl = tabbableElements[tabbableElements.length - 1];
    141 
    142    let isTabPressed = e.key === "Tab" || e.keyCode === 9;
    143    let isArrowPressed = e.key === "ArrowUp" || e.key === "ArrowDown";
    144 
    145    if (isTabPressed) {
    146      if (e.shiftKey) {
    147        if (document.activeElement === firstTabableEl) {
    148          lastTabbableEl.focus();
    149          e.preventDefault();
    150        }
    151      } else if (document.activeElement === lastTabbableEl) {
    152        firstTabableEl.focus();
    153        e.preventDefault();
    154      }
    155    } else if (
    156      isArrowPressed &&
    157      checkboxWrapperRef.current.contains(document.activeElement)
    158    ) {
    159      const checkboxElements =
    160        checkboxWrapperRef.current.querySelectorAll("input");
    161      const [firstInput] = checkboxElements;
    162      const lastInput = checkboxElements[checkboxElements.length - 1];
    163      const inputArr = Array.from(checkboxElements);
    164      const currentIndex = inputArr.indexOf(document.activeElement);
    165      let nextEl;
    166      if (e.key === "ArrowUp") {
    167        nextEl =
    168          document.activeElement === firstInput
    169            ? lastInput
    170            : checkboxElements[currentIndex - 1];
    171      } else if (e.key === "ArrowDown") {
    172        nextEl =
    173          document.activeElement === lastInput
    174            ? firstInput
    175            : checkboxElements[currentIndex + 1];
    176      }
    177      nextEl.tabIndex = 0;
    178      document.activeElement.tabIndex = -1;
    179      nextEl.focus();
    180    }
    181  }, []);
    182 
    183  useEffect(() => {
    184    const ref = modalRef.current;
    185    ref.addEventListener("keydown", handleFocus);
    186 
    187    inputRef.current.tabIndex = 0;
    188 
    189    return () => {
    190      ref.removeEventListener("keydown", handleFocus);
    191    };
    192  }, [handleFocus]);
    193 
    194  function handleChange(e) {
    195    const topic = e.target.name;
    196    const isChecked = e.target.checked;
    197    if (isChecked) {
    198      setTopicsToSelect([...topicsToSelect, topic]);
    199    } else {
    200      const updatedTopics = topicsToSelect.filter(t => t !== topic);
    201      setTopicsToSelect(updatedTopics);
    202    }
    203  }
    204 
    205  function handleSubmit() {
    206    const topicsString = topicsToSelect.join(", ");
    207    dispatch(
    208      ac.SetPref("discoverystream.topicSelection.selectedTopics", topicsString)
    209    );
    210    dispatch(
    211      ac.SetPref(
    212        "discoverystream.topicSelection.onboarding.maybeDisplay",
    213        false
    214      )
    215    );
    216    if (!topicsHaveBeenPreviouslySet) {
    217      dispatch(
    218        ac.SetPref(
    219          "discoverystream.topicSelection.hasBeenUpdatedPreviously",
    220          true
    221        )
    222      );
    223    }
    224    dispatch(
    225      ac.OnlyToMain({
    226        type: at.TOPIC_SELECTION_USER_SAVE,
    227        data: {
    228          topics: topicsString,
    229          previous_topics: selectedTopics,
    230          first_save: isFirstSave(),
    231        },
    232      })
    233    );
    234    handleModalClose();
    235  }
    236 
    237  return (
    238    <ModalOverlayWrapper
    239      onClose={handleUserClose}
    240      innerClassName="topic-selection-container"
    241    >
    242      <div className="topic-selection-form" ref={modalRef}>
    243        <button
    244          className="dismiss-button"
    245          title="dismiss"
    246          onClick={handleUserClose}
    247        />
    248        <h1 className="title" data-l10n-id="newtab-topic-selection-title" />
    249        <p
    250          className="subtitle"
    251          data-l10n-id="newtab-topic-selection-subtitle"
    252        />
    253        <div className="topic-list" ref={checkboxWrapperRef}>
    254          {topics.map((topic, i) => {
    255            const checked = topicsToSelect.includes(topic);
    256            return (
    257              <label className={`topic-item`} key={topic}>
    258                <input
    259                  type="checkbox"
    260                  id={topic}
    261                  name={topic}
    262                  ref={i === 0 ? inputRef : null}
    263                  onChange={handleChange}
    264                  checked={checked}
    265                  aria-checked={checked}
    266                  tabIndex={-1}
    267                />
    268                <div className={`topic-custom-checkbox`}>
    269                  <span className="topic-icon">{EMOJI_LABELS[`${topic}`]}</span>
    270                  <span className="topic-checked" />
    271                </div>
    272                <span
    273                  className="topic-item-label"
    274                  data-l10n-id={`newtab-topic-label-${topic}`}
    275                />
    276              </label>
    277            );
    278          })}
    279        </div>
    280        <div className="modal-footer">
    281          <a
    282            href={supportUrl}
    283            data-l10n-id="newtab-topic-selection-privacy-link"
    284          />
    285          <moz-button-group className="button-group">
    286            <moz-button
    287              id={isFirstRun ? "first-run" : ""}
    288              data-l10n-id={
    289                isFirstRun
    290                  ? "newtab-topic-selection-button-maybe-later"
    291                  : "newtab-topic-selection-cancel-button"
    292              }
    293              onClick={handleUserClose}
    294            />
    295            <moz-button
    296              data-l10n-id="newtab-topic-selection-save-button"
    297              type="primary"
    298              onClick={handleSubmit}
    299            />
    300          </moz-button-group>
    301        </div>
    302      </div>
    303    </ModalOverlayWrapper>
    304  );
    305 }
    306 
    307 export { TopicSelection };