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