FocusTimer.jsx (20342B)
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 3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 5 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; 6 import React, { useState, useEffect, useRef, useCallback } from "react"; 7 import { useSelector, batch } from "react-redux"; 8 import { useIntersectionObserver } from "../../../lib/utils"; 9 10 const USER_ACTION_TYPES = { 11 TIMER_SET: "timer_set", 12 TIMER_PLAY: "timer_play", 13 TIMER_PAUSE: "timer_pause", 14 TIMER_RESET: "timer_reset", 15 TIMER_END: "timer_end", 16 TIMER_TOGGLE_FOCUS: "timer_toggle_focus", 17 TIMER_TOGGLE_BREAK: "timer_toggle_break", 18 }; 19 20 /** 21 * Calculates the remaining time (in seconds) by subtracting elapsed time from the original duration 22 * 23 * @param duration 24 * @param start 25 * @returns int 26 */ 27 export const calculateTimeRemaining = (duration, start) => { 28 const currentTime = Math.floor(Date.now() / 1000); 29 30 // Subtract the elapsed time from initial duration to get time remaining in the timer 31 return Math.max(duration - (currentTime - start), 0); 32 }; 33 34 /** 35 * Converts a number of seconds into a zero-padded MM:SS time string 36 * 37 * @param seconds 38 * @returns string 39 */ 40 export const formatTime = seconds => { 41 const minutes = Math.floor(seconds / 60) 42 .toString() 43 .padStart(2, "0"); 44 const secs = (seconds % 60).toString().padStart(2, "0"); 45 return `${minutes}:${secs}`; 46 }; 47 48 /** 49 * Validates that the inputs in the timer only allow numerical digits (0-9) 50 * 51 * @param input - The character being input 52 * @returns boolean - true if valid numeric input, false otherwise 53 */ 54 export const isNumericValue = input => { 55 // Check for null/undefined input or non-numeric characters 56 return input && /^\d+$/.test(input); 57 }; 58 59 /** 60 * Validates if adding a new digit would exceed the 2-character limit 61 * 62 * @param currentValue - The current value in the field 63 * @returns boolean - true if at 2-character limit, false otherwise 64 */ 65 export const isAtMaxLength = currentValue => { 66 return currentValue.length >= 2; 67 }; 68 69 /** 70 * Converts a polar coordinate (angle on circle) into a percentage-based [x,y] position for clip-path 71 * 72 * @param cx 73 * @param cy 74 * @param radius 75 * @param angle 76 * @returns string 77 */ 78 export const polarToPercent = (cx, cy, radius, angle) => { 79 const rad = ((angle - 90) * Math.PI) / 180; 80 const x = cx + radius * Math.cos(rad); 81 const y = cy + radius * Math.sin(rad); 82 return `${x}% ${y}%`; 83 }; 84 85 /** 86 * Generates a clip-path polygon string that represents a pie slice from 0 degrees 87 * to the current progress angle 88 * 89 * @returns string 90 * @param progress 91 */ 92 export const getClipPath = progress => { 93 const cx = 50; 94 const cy = 50; 95 const radius = 50; 96 // Show some progress right at the start - 6 degrees is just enough to paint a dot once the timer is ticking 97 const angle = progress > 0 ? Math.max(progress * 360, 6) : 0; 98 const points = [`50% 50%`]; 99 100 for (let a = 0; a <= angle; a += 2) { 101 points.push(polarToPercent(cx, cy, radius, a)); 102 } 103 104 return `polygon(${points.join(", ")})`; 105 }; 106 107 export const FocusTimer = ({ 108 dispatch, 109 handleUserInteraction, 110 isMaximized, 111 }) => { 112 const [timeLeft, setTimeLeft] = useState(0); 113 // calculated value for the progress circle; 1 = 100% 114 const [progress, setProgress] = useState(0); 115 116 const activeMinutesRef = useRef(null); 117 const activeSecondsRef = useRef(null); 118 const arcRef = useRef(null); 119 120 const timerType = useSelector(state => state.TimerWidget.timerType); 121 const timerData = useSelector(state => state.TimerWidget); 122 const { duration, initialDuration, startTime, isRunning } = 123 timerData[timerType]; 124 const initialTimerDuration = timerData[timerType].initialDuration; 125 126 const handleTimerInteraction = useCallback( 127 () => handleUserInteraction("focusTimer"), 128 [handleUserInteraction] 129 ); 130 131 const handleIntersection = useCallback(() => { 132 dispatch( 133 ac.AlsoToMain({ 134 type: at.WIDGETS_TIMER_USER_IMPRESSION, 135 }) 136 ); 137 }, [dispatch]); 138 139 const timerRef = useIntersectionObserver(handleIntersection); 140 141 const resetProgressCircle = useCallback(() => { 142 if (arcRef?.current) { 143 arcRef.current.style.clipPath = "polygon(50% 50%)"; 144 arcRef.current.style.webkitClipPath = "polygon(50% 50%)"; 145 } 146 setProgress(0); 147 handleTimerInteraction(); 148 }, [arcRef, handleTimerInteraction]); 149 150 const prefs = useSelector(state => state.Prefs.values); 151 const showSystemNotifications = 152 prefs["widgets.focusTimer.showSystemNotifications"]; 153 154 useEffect(() => { 155 // resets default values after timer ends 156 let interval; 157 let hasReachedZero = false; 158 if (isRunning && duration > 0) { 159 interval = setInterval(() => { 160 const currentTime = Math.floor(Date.now() / 1000); 161 const elapsed = currentTime - startTime; 162 const remaining = calculateTimeRemaining(duration, startTime); 163 164 // using setTimeLeft to trigger a re-render of the component to show live countdown each second 165 setTimeLeft(remaining); 166 setProgress((initialDuration - remaining) / initialDuration); 167 168 if (elapsed >= duration && hasReachedZero) { 169 clearInterval(interval); 170 171 batch(() => { 172 dispatch( 173 ac.AlsoToMain({ 174 type: at.WIDGETS_TIMER_END, 175 data: { 176 timerType, 177 duration: initialTimerDuration, 178 initialDuration: initialTimerDuration, 179 }, 180 }) 181 ); 182 183 dispatch( 184 ac.OnlyToMain({ 185 type: at.WIDGETS_TIMER_USER_EVENT, 186 data: { userAction: USER_ACTION_TYPES.TIMER_END }, 187 }) 188 ); 189 }); 190 191 // animate the progress circle to turn solid green 192 setProgress(1); 193 194 // More transitions after a delay to allow the animation above to complete 195 setTimeout(() => { 196 // progress circle goes back to default grey 197 resetProgressCircle(); 198 199 // There's more to see! 200 setTimeout(() => { 201 // switch over to the other timer type 202 // eslint-disable-next-line max-nested-callbacks 203 batch(() => { 204 dispatch( 205 ac.AlsoToMain({ 206 type: at.WIDGETS_TIMER_SET_TYPE, 207 data: { 208 timerType: timerType === "focus" ? "break" : "focus", 209 }, 210 }) 211 ); 212 213 dispatch( 214 ac.OnlyToMain({ 215 type: at.WIDGETS_TIMER_USER_EVENT, 216 data: { 217 userAction: 218 timerType === "focus" 219 ? USER_ACTION_TYPES.TIMER_TOGGLE_BREAK 220 : USER_ACTION_TYPES.TIMER_TOGGLE_FOCUS, 221 }, 222 }) 223 ); 224 }); 225 }, 500); 226 }, 1000); 227 } else if (elapsed >= duration) { 228 hasReachedZero = true; 229 } 230 }, 1000); 231 } 232 233 // Shows the correct live time in the UI whenever the timer state changes 234 const newTime = isRunning 235 ? calculateTimeRemaining(duration, startTime) 236 : duration; 237 238 setTimeLeft(newTime); 239 240 // Set progress for paused timers (handles page load and timer type toggling) 241 if (!isRunning && duration < initialDuration) { 242 // Show previously elapsed time 243 setProgress((initialDuration - duration) / initialDuration); 244 } else if (!isRunning) { 245 // Reset progress for fresh timers 246 setProgress(0); 247 } 248 249 return () => clearInterval(interval); 250 }, [ 251 isRunning, 252 startTime, 253 duration, 254 initialDuration, 255 dispatch, 256 resetProgressCircle, 257 timerType, 258 initialTimerDuration, 259 ]); 260 261 // Update the clip-path of the gradient circle to match the current progress value 262 useEffect(() => { 263 if (arcRef?.current) { 264 // Only set clip-path if current timer has been started or is running 265 if (progress > 0 || isRunning) { 266 arcRef.current.style.clipPath = getClipPath(progress); 267 } else { 268 arcRef.current.style.clipPath = ""; 269 } 270 } 271 }, [progress, isRunning]); 272 273 // set timer function 274 const setTimerDuration = () => { 275 const minutesEl = activeMinutesRef.current; 276 const secondsEl = activeSecondsRef.current; 277 278 const minutesValue = minutesEl.innerText.trim() || "0"; 279 const secondsValue = secondsEl.innerText.trim() || "0"; 280 281 let minutes = parseInt(minutesValue || "0", 10); 282 let seconds = parseInt(secondsValue || "0", 10); 283 284 // Set a limit of 99 minutes 285 minutes = Math.min(minutes, 99); 286 // Set a limit of 59 seconds 287 seconds = Math.min(seconds, 59); 288 289 const totalSeconds = minutes * 60 + seconds; 290 291 if ( 292 !Number.isNaN(totalSeconds) && 293 totalSeconds > 0 && 294 totalSeconds !== duration 295 ) { 296 batch(() => { 297 dispatch( 298 ac.AlsoToMain({ 299 type: at.WIDGETS_TIMER_SET_DURATION, 300 data: { timerType, duration: totalSeconds }, 301 }) 302 ); 303 dispatch( 304 ac.OnlyToMain({ 305 type: at.WIDGETS_TIMER_USER_EVENT, 306 data: { userAction: USER_ACTION_TYPES.TIMER_SET }, 307 }) 308 ); 309 }); 310 } 311 handleTimerInteraction(); 312 }; 313 314 // Pause timer function 315 const toggleTimer = () => { 316 if (!isRunning && duration > 0) { 317 batch(() => { 318 dispatch( 319 ac.AlsoToMain({ 320 type: at.WIDGETS_TIMER_PLAY, 321 data: { timerType }, 322 }) 323 ); 324 dispatch( 325 ac.OnlyToMain({ 326 type: at.WIDGETS_TIMER_USER_EVENT, 327 data: { userAction: USER_ACTION_TYPES.TIMER_PLAY }, 328 }) 329 ); 330 }); 331 } else if (isRunning) { 332 // calculated to get the new baseline of the timer when it starts or resumes 333 const remaining = calculateTimeRemaining(duration, startTime); 334 batch(() => { 335 dispatch( 336 ac.AlsoToMain({ 337 type: at.WIDGETS_TIMER_PAUSE, 338 data: { 339 timerType, 340 duration: remaining, 341 }, 342 }) 343 ); 344 dispatch( 345 ac.OnlyToMain({ 346 type: at.WIDGETS_TIMER_USER_EVENT, 347 data: { userAction: USER_ACTION_TYPES.TIMER_PAUSE }, 348 }) 349 ); 350 }); 351 } 352 handleTimerInteraction(); 353 }; 354 355 // reset timer function 356 const resetTimer = () => { 357 batch(() => { 358 dispatch( 359 ac.AlsoToMain({ 360 type: at.WIDGETS_TIMER_RESET, 361 data: { 362 timerType, 363 duration: initialTimerDuration, 364 initialDuration: initialTimerDuration, 365 }, 366 }) 367 ); 368 369 dispatch( 370 ac.OnlyToMain({ 371 type: at.WIDGETS_TIMER_USER_EVENT, 372 data: { userAction: USER_ACTION_TYPES.TIMER_RESET }, 373 }) 374 ); 375 }); 376 377 // Reset progress value and gradient arc on the progress circle 378 resetProgressCircle(); 379 380 handleTimerInteraction(); 381 }; 382 383 // Toggles between "focus" and "break" timer types 384 const toggleType = type => { 385 const oldTypeRemaining = calculateTimeRemaining(duration, startTime); 386 387 batch(() => { 388 // The type we are toggling away from automatically pauses 389 dispatch( 390 ac.AlsoToMain({ 391 type: at.WIDGETS_TIMER_PAUSE, 392 data: { 393 timerType, 394 duration: oldTypeRemaining, 395 }, 396 }) 397 ); 398 399 dispatch( 400 ac.OnlyToMain({ 401 type: at.WIDGETS_TIMER_USER_EVENT, 402 data: { userAction: USER_ACTION_TYPES.TIMER_PAUSE }, 403 }) 404 ); 405 406 // Sets the current timer type so it persists when opening a new tab 407 dispatch( 408 ac.AlsoToMain({ 409 type: at.WIDGETS_TIMER_SET_TYPE, 410 data: { 411 timerType: type, 412 }, 413 }) 414 ); 415 416 dispatch( 417 ac.OnlyToMain({ 418 type: at.WIDGETS_TIMER_USER_EVENT, 419 data: { 420 userAction: 421 type === "focus" 422 ? USER_ACTION_TYPES.TIMER_TOGGLE_FOCUS 423 : USER_ACTION_TYPES.TIMER_TOGGLE_BREAK, 424 }, 425 }) 426 ); 427 }); 428 handleTimerInteraction(); 429 }; 430 431 const handleKeyDown = e => { 432 if (e.key === "Enter") { 433 e.preventDefault(); 434 setTimerDuration(e); 435 handleTimerInteraction(); 436 } 437 438 if (e.key === "Tab") { 439 setTimerDuration(e); 440 handleTimerInteraction(); 441 } 442 }; 443 444 const handleBeforeInput = e => { 445 const input = e.data; 446 const values = e.target.innerText.trim(); 447 448 // only allow numerical digits 0–9 for time input 449 if (!isNumericValue(input)) { 450 e.preventDefault(); 451 return; 452 } 453 454 const selection = window.getSelection(); 455 const selectedText = selection.toString(); 456 457 // if entire value is selected, replace it with the new input 458 if (selectedText === values) { 459 e.preventDefault(); // prevent default typing 460 e.target.innerText = input; 461 462 // Places the caret at the end of the content-editable text 463 // This is a known problem with content-editable where the caret 464 const range = document.createRange(); 465 range.selectNodeContents(e.target); 466 range.collapse(false); 467 const sel = window.getSelection(); 468 sel.removeAllRanges(); 469 sel.addRange(range); 470 return; 471 } 472 473 // only allow 2 values each for minutes and seconds 474 if (isAtMaxLength(values)) { 475 e.preventDefault(); 476 } 477 }; 478 479 const handleFocus = e => { 480 if (isRunning) { 481 // calculated to get the new baseline of the timer when it starts or resumes 482 const remaining = calculateTimeRemaining(duration, startTime); 483 484 batch(() => { 485 dispatch( 486 ac.AlsoToMain({ 487 type: at.WIDGETS_TIMER_PAUSE, 488 data: { 489 timerType, 490 duration: remaining, 491 }, 492 }) 493 ); 494 dispatch( 495 ac.OnlyToMain({ 496 type: at.WIDGETS_TIMER_USER_EVENT, 497 data: { userAction: USER_ACTION_TYPES.TIMER_PAUSE }, 498 }) 499 ); 500 }); 501 } 502 503 // highlight entire text when focused on the time. 504 // this makes it easier to input the new time instead of backspacing 505 const el = e.target; 506 if (document.createRange && window.getSelection) { 507 const range = document.createRange(); 508 range.selectNodeContents(el); 509 const sel = window.getSelection(); 510 sel.removeAllRanges(); 511 sel.addRange(range); 512 } 513 }; 514 515 function handleLearnMore() { 516 dispatch( 517 ac.OnlyToMain({ 518 type: at.OPEN_LINK, 519 data: { 520 url: "https://support.mozilla.org/kb/firefox-new-tab-widgets", 521 }, 522 }) 523 ); 524 handleTimerInteraction(); 525 } 526 527 function handlePrefUpdate(prefName, prefValue) { 528 dispatch( 529 ac.OnlyToMain({ 530 type: at.SET_PREF, 531 data: { 532 name: prefName, 533 value: prefValue, 534 }, 535 }) 536 ); 537 handleTimerInteraction(); 538 } 539 540 return timerData ? ( 541 <article 542 className={`focus-timer ${isMaximized ? "is-maximized" : ""}`} 543 ref={el => { 544 timerRef.current = [el]; 545 }} 546 > 547 <div className="newtab-widget-timer-notification-title-wrapper"> 548 <h3 data-l10n-id="newtab-widget-timer-notification-title"></h3> 549 <div className="focus-timer-context-menu-wrapper"> 550 <moz-button 551 className="focus-timer-context-menu-button" 552 iconSrc="chrome://global/skin/icons/more.svg" 553 menuId="focus-timer-context-menu" 554 type="ghost" 555 /> 556 <panel-list id="focus-timer-context-menu"> 557 <panel-item 558 data-l10n-id={ 559 showSystemNotifications 560 ? "newtab-widget-timer-menu-notifications" 561 : "newtab-widget-timer-menu-notifications-on" 562 } 563 onClick={() => { 564 handlePrefUpdate( 565 "widgets.focusTimer.showSystemNotifications", 566 !showSystemNotifications 567 ); 568 }} 569 /> 570 <panel-item 571 data-l10n-id="newtab-widget-timer-menu-hide" 572 onClick={() => { 573 handlePrefUpdate("widgets.focusTimer.enabled", false); 574 }} 575 /> 576 <panel-item 577 data-l10n-id="newtab-widget-timer-menu-learn-more" 578 onClick={handleLearnMore} 579 /> 580 </panel-list> 581 </div> 582 </div> 583 <div className="focus-timer-tabs"> 584 <div className="focus-timer-tabs-buttons"> 585 <moz-button 586 type={timerType === "focus" ? "default" : "ghost"} 587 data-l10n-id="newtab-widget-timer-mode-focus" 588 size="small" 589 onClick={() => toggleType("focus")} 590 /> 591 <moz-button 592 type={timerType === "break" ? "default" : "ghost"} 593 data-l10n-id="newtab-widget-timer-mode-break" 594 size="small" 595 onClick={() => toggleType("break")} 596 /> 597 </div> 598 </div> 599 <div 600 role="progress" 601 className={`progress-circle-wrapper ${ 602 !showSystemNotifications && !timerData[timerType].isRunning 603 ? "is-small" 604 : "" 605 }`} 606 > 607 <div 608 className={`progress-circle-background${timerType === "break" ? "-break" : ""}`} 609 /> 610 611 <div 612 className={`progress-circle ${timerType === "focus" ? "focus-visible" : "focus-hidden"}`} 613 ref={timerType === "focus" ? arcRef : null} 614 /> 615 616 <div 617 className={`progress-circle ${timerType === "break" ? "break-visible" : "break-hidden"}`} 618 ref={timerType === "break" ? arcRef : null} 619 /> 620 621 <div 622 className={`progress-circle-complete${progress === 1 ? " visible" : ""}`} 623 /> 624 <div role="timer" className="progress-circle-label"> 625 <EditableTimerFields 626 minutesRef={activeMinutesRef} 627 secondsRef={activeSecondsRef} 628 onKeyDown={handleKeyDown} 629 onBeforeInput={handleBeforeInput} 630 onFocus={handleFocus} 631 timeLeft={timeLeft} 632 onBlur={() => setTimerDuration()} 633 /> 634 </div> 635 </div> 636 637 <div className="set-timer-controls-wrapper"> 638 <div className={`focus-timer-controls timer-running`}> 639 <moz-button 640 {...(!isRunning ? { type: "primary" } : {})} 641 iconsrc={`chrome://global/skin/media/${isRunning ? "pause" : "play"}-fill.svg`} 642 data-l10n-id={ 643 isRunning 644 ? "newtab-widget-timer-label-pause" 645 : "newtab-widget-timer-label-play" 646 } 647 onClick={toggleTimer} 648 /> 649 {isRunning && ( 650 <moz-button 651 type="icon ghost" 652 iconsrc="chrome://newtab/content/data/content/assets/arrow-clockwise-16.svg" 653 data-l10n-id="newtab-widget-timer-reset" 654 onClick={resetTimer} 655 /> 656 )} 657 </div> 658 </div> 659 {!showSystemNotifications && !timerData[timerType].isRunning && ( 660 <p 661 className="timer-notification-status" 662 data-l10n-id="newtab-widget-timer-notification-warning" 663 ></p> 664 )} 665 </article> 666 ) : null; 667 }; 668 669 function EditableTimerFields({ 670 minutesRef, 671 secondsRef, 672 tabIndex = 0, 673 ...props 674 }) { 675 return ( 676 <> 677 <span 678 contentEditable="true" 679 ref={minutesRef} 680 className="timer-set-minutes" 681 onKeyDown={props.onKeyDown} 682 onBeforeInput={props.onBeforeInput} 683 onFocus={props.onFocus} 684 onBlur={props.onBlur} 685 tabIndex={tabIndex} 686 > 687 {formatTime(props.timeLeft).split(":")[0]} 688 </span> 689 : 690 <span 691 contentEditable="true" 692 ref={secondsRef} 693 className="timer-set-seconds" 694 onKeyDown={props.onKeyDown} 695 onBeforeInput={props.onBeforeInput} 696 onFocus={props.onFocus} 697 onBlur={props.onBlur} 698 tabIndex={tabIndex} 699 > 700 {formatTime(props.timeLeft).split(":")[1]} 701 </span> 702 </> 703 ); 704 }