hooks/idle.jsxjavascript
/*
  Detects whether the user has interacted with the page within the last
  `idleTime` ms. Intended to drive screensaver start/stop: consumers watch
  `isIdle` and show the screensaver once it flips to true, hiding it again
  on the next interaction.

  Activity is tracked via a set of global DOM events. `visibilitychange` is
  handled separately from the rest because it fires on `document` (not
  `window`) and only counts as activity when the tab becomes visible again -
  switching away should not keep postponing the idle timeout.
*/

import { useState, useEffect, useCallback, useRef } from "react";

const ACTIVITY_EVENTS = [
  "mousemove",
  "mousedown",
  "mouseup",
  "touchstart",
  "touchmove",
  "pointerdown",
  "keydown",
];

/**
 * @param {number} idleTime - Time in ms of inactivity after which `isIdle` becomes true.
 * @returns {{isIdle: boolean, trigger: ?string, forceIdle: function(): void}} `trigger` is
 *   the event type that ended the last idle period ("init" on first mount), and is
 *   reset to null once idle again - useful for debugging what woke the app up. `forceIdle`
 *   shows the idle state immediately, without waiting for the timeout.
 */
export const useIdle = (idleTime) => {
  const [isIdle, setIsIdle] = useState(false);
  const [trigger, setTrigger] = useState(null);

  const idleTimerRef = useRef();

  const handleActivity = useCallback(
    (event) => {
      // Only Enter counts as keyboard activity (e.g. a remote/kiosk confirm button) -
      // other keys are ignored on purpose.
      if (event?.type === "keydown" && event?.key !== "Enter") return;

      // Only record the event that ended the idle period, not every subsequent one.
      setTrigger((prev) => prev ?? (event?.type || "init"));
      setIsIdle(false);

      if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
      idleTimerRef.current = setTimeout(() => {
        setIsIdle(true);
        setTrigger(null);
      }, idleTime);
    },
    // Deliberately excludes `trigger` - handleActivity must stay stable across
    // idle/active transitions, otherwise every transition would tear down and
    // re-register all listeners below.
    [idleTime]
  );

  const handleVisibilityChange = useCallback(() => {
    if (document.visibilityState === "visible") handleActivity();
  }, [handleActivity]);

  const forceIdle = useCallback(() => {
    if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
    setIsIdle(true);
    setTrigger(null);
  }, []);

  useEffect(() => {
    ACTIVITY_EVENTS.forEach((event) => window.addEventListener(event, handleActivity));
    document.addEventListener("visibilitychange", handleVisibilityChange);

    const initTimer = setTimeout(handleActivity);

    return () => {
      clearTimeout(initTimer);
      if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
      ACTIVITY_EVENTS.forEach((event) => window.removeEventListener(event, handleActivity));
      document.removeEventListener("visibilitychange", handleVisibilityChange);
    };
  }, [handleActivity, handleVisibilityChange]);

  return { isIdle, trigger, forceIdle };
};