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 };