@classytic/react-presence
v0.2.0
Published
React 19 hooks for user presence detection - tab visibility, window focus, online status, idle detection, page leave, wake lock, network info, heartbeat, and devtools detection
Readme
@classytic/react-presence
React 19 hooks for user presence detection. 13 hooks, context provider with granular selectors, tree-shakeable.
Requires react >= 19.0.0.
npm install @classytic/react-presenceHooks
| Hook | Purpose |
| ---------------------- | -------------------------------------------------------------------------------------- |
| usePresence | Combined presence -- composes tab, focus, online, idle, network into one status |
| useTabVisibility | Tab visible/hidden via Page Visibility API |
| useWindowFocus | Window focus/blur |
| useOnlineStatus | Browser online/offline |
| useIdleDetection | User inactivity with prompt stage, cross-tab sync, sleep detection |
| usePageLeave | Page leave detection (beforeunload, pagehide, mobile visibility) |
| useWakeLock | Screen Wake Lock API -- prevent screen dimming |
| useNetworkInfo | Network Information API -- effective type, downlink, RTT |
| useDevToolsDetection | DevTools open detection via window size heuristic |
| useHeartbeat | Periodic server pings with auto-pause on presence status |
| useMultiDisplay | Detects multiple connected monitors via Window Management API |
| useDeviceChange | Detects media hardware connections/disconnections (mics, cameras, headphones) |
| useScreenShareGuard | Validates active screen shares to prevent cheating (e.g. blocking full-screen sharing) |
| useFullscreenGuard | Enforce Fullscreen mode across browsers for focused/proctored apps |
| useClipboardGuard | Block or detect copy, cut, paste, drag, and drop events to prevent cheating |
| useBattery | Track battery level and discharging state to warn users before unexpected power loss |
| usePermissions | Pre-emptively query OS-level browser permissions (camera, microphone, clipboard) |
Quick start
Combined hook
import { usePresence } from "@classytic/react-presence";
function App() {
const { status, state, resetAll } = usePresence({
idleTimeout: 30_000,
promptTimeout: 10_000,
onStatusChange: (status) => console.log(status),
});
// status: 'active' | 'prompted' | 'idle' | 'away' | 'offline'
return <div>{status}</div>;
}Context provider (multi-component)
import {
PresenceProvider,
usePresenceStatus,
useIsTabVisible,
} from "@classytic/react-presence";
function App() {
return (
<PresenceProvider idleTimeout={60_000}>
<StatusBadge />
</PresenceProvider>
);
}
// Only re-renders when status changes
function StatusBadge() {
const status = usePresenceStatus();
return <span>{status}</span>;
}Individual hooks
import { useTabVisibility, useIdleDetection } from "@classytic/react-presence";
const { isVisible, hiddenDuration, resetDuration } = useTabVisibility({
onHidden: () => log("tab hidden"),
});
const { isIdle, isPrompted, getRemainingTime } = useIdleDetection({
idleTimeout: 60_000,
promptTimeout: 10_000,
onPrompt: () => showModal("Still there?"),
});API reference
Status priority
offline > away > idle > prompted > active
| Status | Condition |
| ---------- | ------------------------------------------------------------------- |
| offline | Browser offline |
| away | Tab hidden or window unfocused |
| idle | No user activity for idleTimeout ms |
| prompted | No activity for idleTimeout - promptTimeout ms (pre-idle warning) |
| active | None of the above |
usePresence(options?): UsePresenceReturn
Composes useTabVisibility, useWindowFocus, useOnlineStatus, useIdleDetection, and optionally useNetworkInfo.
// Options
interface UsePresenceOptions {
idleTimeout?: number; // Default: 60000
promptTimeout?: number; // Pre-idle warning window (ms)
idleEvents?: string[]; // Default: mousemove, mousedown, keydown, touchstart, scroll, wheel
trackIdle?: boolean; // Default: true
trackNetwork?: boolean; // Default: false
crossTab?: boolean | CrossTabOptions;
detectSleep?: boolean | SleepDetectorOptions;
timers?: TimerFunctions; // Custom timers (e.g. worker timers)
onStatusChange?: (status: PresenceStatus, state: PresenceState) => void;
onTabVisibilityChange?: (state: TabVisibilityState) => void;
onWindowFocusChange?: (state: WindowFocusState) => void;
onOnlineStatusChange?: (state: OnlineState) => void;
onIdleChange?: (state: IdleState) => void;
onPrompt?: () => void;
onNetworkChange?: (state: NetworkInfoState) => void;
onSleep?: (event: SleepEvent) => void;
}
// Return
interface UsePresenceReturn {
status: PresenceStatus;
state: PresenceState;
tabVisibility: UseTabVisibilityReturn;
windowFocus: UseWindowFocusReturn;
onlineStatus: UseOnlineStatusReturn;
idle: UseIdleDetectionReturn;
network?: NetworkInfoState;
resetAll: () => void;
getRemainingTime: () => number; // ms until idle
getElapsedTime: () => number; // ms since last activity
activate: () => void; // reset from prompted/idle to active
}useTabVisibility(options?): UseTabVisibilityReturn
Tracks tab visibility via document.visibilityState. Live hiddenDuration counter updates every 1s while hidden.
// Options
interface UseTabVisibilityOptions {
onVisible?: () => void;
onHidden?: () => void;
onChange?: (state: TabVisibilityState) => void;
}
// Return
interface UseTabVisibilityReturn {
isVisible: boolean;
visibilityState: "visible" | "hidden";
lastChange: number; // timestamp
hiddenDuration: number; // cumulative ms hidden
resetDuration: () => void; // reset counter (safe to call while hidden)
}useWindowFocus(options?): UseWindowFocusReturn
Tracks window focus/blur. Live blurDuration counter updates every 1s while blurred.
// Options
interface UseWindowFocusOptions {
onFocus?: () => void;
onBlur?: () => void;
onChange?: (state: WindowFocusState) => void;
}
// Return
interface UseWindowFocusReturn {
isFocused: boolean;
lastChange: number;
blurDuration: number; // cumulative ms blurred
resetDuration: () => void; // safe to call while blurred
}useOnlineStatus(options?): UseOnlineStatusReturn
Tracks navigator.onLine via online/offline events.
// Options
interface UseOnlineStatusOptions {
onOnline?: () => void;
onOffline?: () => void;
onChange?: (state: OnlineState) => void;
}
// Return
interface UseOnlineStatusReturn {
isOnline: boolean;
lastChange: number;
wasOffline: boolean; // true if ever went offline this session
resetOfflineFlag: () => void;
}useIdleDetection(options?): UseIdleDetectionReturn
Detects user inactivity. State machine: active -> prompted -> idle -> active.
Features: prompt stage, cross-tab sync, sleep/hibernate detection, worker timer support, imperative time getters.
// Options
interface UseIdleDetectionOptions {
idleTimeout?: number; // Default: 60000
promptTimeout?: number; // ms before idle to trigger prompt
events?: string[]; // Default: mousemove, mousedown, keydown, touchstart, scroll, wheel
startOnMount?: boolean; // Default: true
timers?: TimerFunctions; // Worker timers for background accuracy
crossTab?: boolean | CrossTabOptions;
detectSleep?: boolean | SleepDetectorOptions;
onIdle?: () => void;
onActive?: () => void;
onPrompt?: () => void;
onChange?: (state: IdleState) => void;
onSleep?: (event: SleepEvent) => void;
}
// Return
interface UseIdleDetectionReturn {
isIdle: boolean;
isPrompted: boolean;
idleTime: number; // ms since last activity (live, updates every 1s)
lastActivity: number; // timestamp
isRunning: boolean;
markActive: () => void; // reset to active
activate: () => void; // alias for markActive
start: () => void;
stop: () => void;
getRemainingTime: () => number; // imperative, ms until idle
getElapsedTime: () => number; // imperative, ms since last activity
getActiveTime: () => number; // imperative, total active ms
crossTab?: CrossTabState; // { isLeader, tabCount, lastActivityAcrossTabs }
}usePageLeave(options?): UsePageLeaveReturn
bfcache-compatible leave detection. Uses beforeunload, pagehide, visibility change (mobile). No deprecated unload event.
// Options
interface UsePageLeaveOptions {
preventLeave?: boolean | (() => boolean); // triggers browser "Leave site?" dialog
beaconUrl?: string; // send beacon on leave
beaconData?: () => BodyInit;
onBeforeLeave?: () => void;
onLeave?: () => void;
onChange?: (state: PageLeaveState) => void;
}
// Return
interface UsePageLeaveReturn {
isLeaving: boolean;
leaveAttempts: number;
lastLeaveAttempt: number;
enablePrevention: () => void; // dynamically enable leave prevention (sets override)
disablePrevention: () => void; // dynamically disable (sets override)
clearOverride: () => void; // clear override, return to prop-controlled behavior
}Override behavior:
enablePrevention()/disablePrevention()set an override that persists across re-renders- Boolean prop changes (
true→false) clear any active override - Function props can be inline (new reference each render) without clearing override
clearOverride()explicitly returns to prop-controlled behavior
useWakeLock(options?): UseWakeLockReturn
Screen Wake Lock API. Auto re-acquires on tab return (browser releases on tab hide).
// Options
interface UseWakeLockOptions {
autoReacquire?: boolean; // Default: true
onLock?: () => void;
onRelease?: () => void;
onError?: (error: Error) => void;
}
// Return
interface UseWakeLockReturn {
isSupported: boolean;
isLocked: boolean;
error: string | null;
request: () => Promise<void>;
release: () => Promise<void>;
}useNetworkInfo(options?): UseNetworkInfoReturn
Network Information API (Chromium only, graceful no-op fallback).
// Options
interface UseNetworkInfoOptions {
enabled?: boolean; // Default: true. Set false to skip subscription.
onChange?: (state: NetworkInfoState) => void;
onSlow?: () => void; // connection became slow-2g or 2g
onRecover?: () => void; // connection recovered from slow
}
// Return
interface UseNetworkInfoReturn {
isSupported: boolean;
effectiveType: "slow-2g" | "2g" | "3g" | "4g" | null;
downlink: number | null; // Mbps
rtt: number | null; // ms
saveData: boolean;
isSlowConnection: boolean; // true if slow-2g or 2g
}useDevToolsDetection(options?): UseDevToolsDetectionReturn
Detects DevTools open state via outerWidth - innerWidth > threshold.
Limitation: This is a best-effort heuristic. Cannot detect undocked DevTools (separate window) and may false-positive on narrow browser windows. Use for analytics/UX hints, not security.
// Options
interface UseDevToolsDetectionOptions {
checkInterval?: number; // Default: 1000
threshold?: number; // Default: 160 (px)
onOpen?: () => void;
onClose?: () => void;
onChange?: (state: DevToolsState) => void;
}
// Return
interface UseDevToolsDetectionReturn {
isOpen: boolean;
}useHeartbeat(options): UseHeartbeatReturn
Periodic server pings. Auto-pauses when presence status is idle/away/offline.
// Options (onPing is required)
interface UseHeartbeatOptions {
onPing: () => Promise<void>;
onPingError?: (error: Error) => void;
interval?: number; // Default: 30000
jitter?: number; // 0-1 range, e.g. 0.2 = ±20% interval variance
startOnMount?: boolean; // Default: true
autoPause?: boolean; // Default: true
pauseOnStatus?: PresenceStatus[]; // Default: ['idle', 'away', 'offline']
presenceStatus?: PresenceStatus; // pass from usePresence
}
// Jitter prevents "thundering herd" when many clients reconnect simultaneously
useHeartbeat({ onPing, interval: 30000, jitter: 0.2 }); // 24-36s intervals
// Return
interface UseHeartbeatReturn {
isActive: boolean;
lastPing: number;
pingCount: number;
lastPingFailed: boolean;
start: () => void;
stop: () => void;
ping: () => Promise<void>; // manual ping
}useMultiDisplay(options?): UseMultiDisplayReturn
Detects connected external monitors.
Detailed Mode: Uses the Window Management API (
window.getScreenDetails()) which requires a browser permission prompt. IfdetailedModeis false, it uses a passive check (window.screen.isExtended) which does not prompt for permission but only provides a boolean state.
// Options
interface UseMultiDisplayOptions {
detailedMode?: boolean; // If true, prompts for permission to get exact screenCount
onDisplayConnected?: () => void;
onDisplayDisconnected?: () => void;
onChange?: (state: MultiDisplayState) => void;
}
// Return
interface UseMultiDisplayReturn {
isExtended: boolean; // True if multiple displays are connected
screenCount: number | null; // Only populated if detailedMode was requested and granted
isSupported: boolean; // True on Chrome/Edge 100+
requestDetailedMode: () => Promise<boolean>; // Manually request screen details and permission
}useDeviceChange(options?): UseDeviceChangeReturn
Detects when a user plugs in or unplugs a media device (headphones, microphone, webcam) via the devicechange event listener on navigator.mediaDevices.
// Options
interface UseDeviceChangeOptions {
onChange?: (state: DeviceChangeState) => void;
onFirstChange?: () => void;
}
// Return
interface UseDeviceChangeReturn {
hasChanged: boolean; // True once a device is added/removed
changeCount: number; // Number of times devices were changed
lastChangedAt: Date | null;
isSupported: boolean;
reset: () => void; // Resets hasChanged and changeCount
}useScreenShareGuard(options?): UseScreenShareGuardReturn
Monitors an active screen share MediaStreamTrack to enforce proctoring rules, such as preventing a user from sharing just a specific window instead of their entire screen.
// Options
interface UseScreenShareGuardOptions {
/**
* Allowed surface types. Defaults to `["monitor"]` (entire screen only).
* Set to `["monitor", "window"]` to also allow window sharing.
*/
allowedSurfaces?: DisplaySurfaceType[];
}
// Return
interface UseScreenShareGuardReturn {
/**
* Request screen share with entire-screen preference.
* Resolves with the MediaStream if valid, or throws with a user-friendly
* message if the user selected a disallowed surface type.
*/
requestScreenShare: (
constraints?: DisplayMediaStreamOptions,
) => Promise<MediaStream>;
/** Validate an existing screen share track (e.g. after reconnect) */
validateTrack: (track: MediaStreamTrack) => ScreenShareValidation;
}useFullscreenGuard(options?): UseFullscreenGuardReturn
Monitors whether the current app is rendering in full screen mode, allowing you to force focus visually for tests and proctored environments. Also provides methods for triggering or exiting fullscreen manually.
// Options
interface FullscreenGuardOptions {
onEnter?: () => void;
onExit?: () => void;
onChange?: (state: FullscreenGuardState) => void;
onError?: (error: Error) => void;
}
// Return
interface UseFullscreenGuardReturn {
isFullscreen: boolean;
isSupported: boolean;
requestFullscreen: () => Promise<void>;
exitFullscreen: () => Promise<void>;
toggleFullscreen: () => Promise<void>;
}useClipboardGuard(options?): UseClipboardGuardReturn
Blocks attempts to copy, cut, paste, drag, and drop anywhere inside an application globally, or scoped to a specific target Ref. Helpful for preventing external cheating.
// Options
interface ClipboardGuardOptions {
blockCopy?: boolean; // Default: true
blockCut?: boolean; // Default: true
blockPaste?: boolean; // Default: true
blockDragDrop?: boolean; // Default: true
targetRef?: React.RefObject<HTMLElement | null>;
onViolation?: (type: "copy" | "cut" | "paste" | "drag" | "drop") => void;
}
// Return
interface UseClipboardGuardReturn {
violationCount: number;
lastViolationAt: Date | null;
lastViolationType: "copy" | "cut" | "paste" | "drag" | "drop" | null;
reset: () => void;
}useBattery(options?): UseBatteryReturn
Leverages the navigator.getBattery() API. Often necessary for long-running processes (like AI interviews) where losing power means disconnecting unexpectedly.
// Options
interface BatteryOptions {
onChange?: (state: BatteryState) => void;
onLowBattery?: (level: number) => void;
lowThreshold?: number; // Default: 0.2 (20%)
}
// Return
interface UseBatteryReturn {
isSupported: boolean;
fetched: boolean;
level: number | null; // e.g. 0.85 (85%)
charging: boolean | null;
dischargingTime: number | null; // Seconds until dead
chargingTime: number | null; // Seconds until full
refresh: () => Promise<void>;
}usePermissions(options): UsePermissionsReturn
Proactively query the hardware/browser permission state without unnecessarily popping up a prompt (getUserMedia typically requests permissions, while navigator.permissions just checks their current state passively).
// Options
interface PermissionsOptions {
name:
| "camera"
| "microphone"
| "clipboard-read"
| "clipboard-write"
| "display-capture"
| PermissionName;
onChange?: (state: PermissionsState) => void;
}
// Return
interface UsePermissionsReturn {
state: PermissionState | "unsupported" | "prompt"; // 'granted', 'denied', 'prompt'
isSupported: boolean;
refresh: () => Promise<void>;
}Context provider and selectors
PresenceProvider wraps usePresence and exposes granular selectors via useSyncExternalStore. Components only re-render when their selected slice changes.
<PresenceProvider idleTimeout={60_000} trackNetwork>
{children}
</PresenceProvider>PresenceProvider accepts all UsePresenceOptions as props.
Selector hooks
All require PresenceProvider ancestor. Throw if used outside.
// Full context
usePresenceContext(): UsePresenceReturn
// State slice selectors
usePresenceStatus(): PresenceStatus
useTabVisibilityState(): TabVisibilityState
useWindowFocusState(): WindowFocusState
useOnlineStatusState(): OnlineState
useIdleState(): IdleState
useNetworkInfoState(): NetworkInfoState | undefined
// Boolean selectors (most granular)
useIsTabVisible(): boolean
useIsWindowFocused(): boolean
useIsOnline(): boolean
useIsIdle(): boolean
useIsPrompted(): boolean
useIsSlowConnection(): booleanWorker timers
Secondary entry point for unthrottled timers in background tabs. Uses inline Web Worker via Blob URL.
import { createWorkerTimers } from "@classytic/react-presence/timers";
const timers = createWorkerTimers();
// Pass to hooks
useIdleDetection({ timers, idleTimeout: 60_000 });
usePresence({ timers, idleTimeout: 60_000 });
// Cleanup when done
timers.dispose();Debugging
import { enableDebug, disableDebug } from "@classytic/react-presence";
enableDebug(); // or localStorage.setItem('DEBUG_REACT_PRESENCE', 'true')Types
All types are exported from the main entry point:
import type {
PresenceStatus, // 'active' | 'prompted' | 'idle' | 'away' | 'offline'
PresenceState,
TabVisibilityState,
WindowFocusState,
OnlineState,
IdleState,
PageLeaveState,
WakeLockState,
NetworkInfoState,
HeartbeatState,
DevToolsState,
TimerFunctions,
CrossTabState,
SleepEvent,
} from "@classytic/react-presence";License
MIT
