tor-browser

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

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 }