172 lines
5.6 KiB
JavaScript
172 lines
5.6 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
import React, { useState, useRef, useCallback } from "react";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
|
|
import { useIntersectionObserver } from "../../../lib/hooks";
|
|
const PREF_FOLLOWED_SECTIONS = "discoverystream.sections.following";
|
|
const PREF_VISIBLE_SECTIONS =
|
|
"discoverystream.sections.interestPicker.visibleSections";
|
|
|
|
/**
|
|
* Shows a list of recommended topics with visual indication whether
|
|
* the user follows some of the topics (active, blue, selected topics)
|
|
* or is yet to do so (neutrally-coloured topics with a "plus" button).
|
|
*
|
|
* @returns {React.Element}
|
|
*/
|
|
function InterestPicker({ data }) {
|
|
const dispatch = useDispatch();
|
|
const focusedRef = useRef(null);
|
|
const focusRef = useRef(null);
|
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
|
const prefs = useSelector(state => state.Prefs.values);
|
|
const visibleSections = prefs[PREF_VISIBLE_SECTIONS]?.split(",")
|
|
.map(item => item.trim())
|
|
.filter(item => item);
|
|
|
|
const { title, subtitle, receivedFeedRank, sections } = data;
|
|
// if undefined or null, assign as empty array to avoid an error
|
|
const interests = sections ?? [];
|
|
|
|
const following = prefs[PREF_FOLLOWED_SECTIONS]
|
|
? prefs[PREF_FOLLOWED_SECTIONS].split(",")
|
|
: [];
|
|
|
|
const handleIntersection = useCallback(() => {
|
|
dispatch(
|
|
ac.AlsoToMain({
|
|
type: at.INLINE_SELECTION_IMPRESSION,
|
|
data: {
|
|
position: receivedFeedRank,
|
|
},
|
|
})
|
|
);
|
|
}, [dispatch, receivedFeedRank]);
|
|
|
|
const ref = useIntersectionObserver(handleIntersection);
|
|
|
|
const onKeyDown = useCallback(e => {
|
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
// prevent the page from scrolling up/down while navigating.
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (
|
|
focusedRef.current?.nextSibling?.querySelector("input") &&
|
|
e.key === "ArrowDown"
|
|
) {
|
|
focusedRef.current.nextSibling.querySelector("input").tabIndex = 0;
|
|
focusedRef.current.nextSibling.querySelector("input").focus();
|
|
}
|
|
if (
|
|
focusedRef.current?.previousSibling?.querySelector("input") &&
|
|
e.key === "ArrowUp"
|
|
) {
|
|
focusedRef.current.previousSibling.querySelector("input").tabIndex = 0;
|
|
focusedRef.current.previousSibling.querySelector("input").focus();
|
|
}
|
|
}, []);
|
|
|
|
function onWrapperFocus() {
|
|
focusRef.current?.addEventListener("keydown", onKeyDown);
|
|
}
|
|
function onWrapperBlur() {
|
|
focusRef.current?.removeEventListener("keydown", onKeyDown);
|
|
}
|
|
function onItemFocus(index) {
|
|
setFocusedIndex(index);
|
|
}
|
|
|
|
// Updates user preferences as they follow or unfollow topics
|
|
// by selecting them from the list
|
|
function handleChange(e, index) {
|
|
const { name: topic, checked } = e.target;
|
|
let updatedTopics = following;
|
|
if (checked) {
|
|
updatedTopics = updatedTopics.length
|
|
? [...updatedTopics, topic]
|
|
: [topic];
|
|
if (!visibleSections.includes(topic)) {
|
|
// add section to visible sections and place after the inline picker
|
|
// subtract 1 from the rank so that it is normalized with array index
|
|
visibleSections.splice(receivedFeedRank - 1, 0, topic);
|
|
dispatch(ac.SetPref(PREF_VISIBLE_SECTIONS, visibleSections.join(",")));
|
|
}
|
|
} else {
|
|
updatedTopics = updatedTopics.filter(t => t !== topic);
|
|
}
|
|
dispatch(
|
|
ac.OnlyToMain({
|
|
type: at.INLINE_SELECTION_CLICK,
|
|
data: {
|
|
topic,
|
|
is_followed: checked,
|
|
topic_position: index,
|
|
position: receivedFeedRank,
|
|
},
|
|
})
|
|
);
|
|
dispatch(ac.SetPref(PREF_FOLLOWED_SECTIONS, updatedTopics.join(",")));
|
|
}
|
|
return (
|
|
<section
|
|
className="inline-selection-wrapper"
|
|
ref={el => {
|
|
ref.current = [el];
|
|
}}
|
|
>
|
|
{/* Will replace copy here to copy sent from over the server */}
|
|
<h2>{title}</h2>
|
|
<p className="inline-selection-copy">{subtitle}</p>
|
|
<ul
|
|
className="topic-list"
|
|
onFocus={onWrapperFocus}
|
|
onBlur={onWrapperBlur}
|
|
ref={focusRef}
|
|
>
|
|
{interests.map((interest, index) => {
|
|
const checked = following.includes(interest.sectionId);
|
|
return (
|
|
<li
|
|
key={interest.id}
|
|
ref={index === focusedIndex ? focusedRef : null}
|
|
>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
id={interest.sectionId}
|
|
name={interest.sectionId}
|
|
checked={checked}
|
|
aria-checked={checked}
|
|
onChange={e => handleChange(e, index)}
|
|
tabIndex={index === focusedIndex ? 0 : -1}
|
|
onFocus={() => {
|
|
onItemFocus(index);
|
|
}}
|
|
/>
|
|
<span
|
|
className="topic-item-label"
|
|
data-l10n-id={`newtab-topic-label-${interest.sectionId}`}
|
|
/>
|
|
<div
|
|
className={`topic-item-icon icon ${checked ? "icon-check-filled" : "icon-add-circle-fill"}`}
|
|
></div>
|
|
</label>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
<p className="learn-more-copy">
|
|
<a
|
|
href={prefs["support.url"]}
|
|
data-l10n-id="newtab-topic-selection-privacy-link"
|
|
/>
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export { InterestPicker };
|