/** * useUserActivity — tracks typing, scrolling, mouse, idle, battery, network, * orientation, visibility. Ported from agi-diy/context-injector.js. */ import { useEffect, useRef, useState } from 'react' export interface UserActivityState { isActive: boolean isTyping: boolean isScrolling: boolean isMovingMouse: boolean idleSeconds: number mousePosition: { x: number; y: number } scrollPosition: { x: number; y: number } focusedElement: string | null visibilityState: 'visible' | 'hidden' | 'prerender' | 'unloaded' windowFocused: boolean screenSize: { width: number; height: number } touchDevice: boolean batteryLevel: number | null batteryCharging: boolean | null networkType: string | null networkDownlink: number | null orientation: { alpha: number; beta: number; gamma: number } | null } const initial: UserActivityState = { isActive: true, isTyping: false, isScrolling: false, isMovingMouse: false, idleSeconds: 0, mousePosition: { x: 0, y: 0 }, scrollPosition: { x: 0, y: 0 }, focusedElement: null, visibilityState: typeof document !== 'undefined' ? document.visibilityState : 'visible', windowFocused: typeof document !== 'undefined' ? document.hasFocus() : true, screenSize: typeof window !== 'undefined' ? { width: window.innerWidth, height: window.innerHeight } : { width: 0, height: 0 }, touchDevice: typeof window !== 'undefined' && 'ontouchstart' in window, batteryLevel: null, batteryCharging: null, networkType: null, networkDownlink: null, orientation: null, } export function useUserActivity(enabled = true): UserActivityState { const [state, setState] = useState(initial) const lastActivityRef = useRef(Date.now()) const timersRef = useRef | undefined>>({}) useEffect(() => { if (!enabled) return const update = (patch: Partial) => { setState(prev => ({ ...prev, ...patch })) } const touchActivity = () => { lastActivityRef.current = Date.now() } const debounce = (key: string, fn: () => void, ms: number) => { clearTimeout(timersRef.current[key]) timersRef.current[key] = setTimeout(fn, ms) } const onMouse = (e: MouseEvent) => { update({ mousePosition: { x: e.clientX, y: e.clientY }, isMovingMouse: true }) touchActivity() debounce('mouse', () => setState(s => ({ ...s, isMovingMouse: false })), 500) } const onKey = () => { update({ isTyping: true }) touchActivity() debounce('type', () => setState(s => ({ ...s, isTyping: false })), 1000) } const onScroll = () => { update({ scrollPosition: { x: window.scrollX, y: window.scrollY }, isScrolling: true }) touchActivity() debounce('scroll', () => setState(s => ({ ...s, isScrolling: false })), 500) } const onClick = () => touchActivity() const onTouch = () => touchActivity() const onFocusIn = (e: FocusEvent) => { const target = e.target as HTMLElement update({ focusedElement: target?.tagName?.toLowerCase() || null }) touchActivity() } const onVis = () => update({ visibilityState: document.visibilityState }) const onFocus = () => { update({ windowFocused: true }); touchActivity() } const onBlur = () => update({ windowFocused: false }) const onResize = () => update({ screenSize: { width: window.innerWidth, height: window.innerHeight } }) const onOrient = (e: DeviceOrientationEvent) => { update({ orientation: { alpha: Math.round(e.alpha || 0), beta: Math.round(e.beta || 0), gamma: Math.round(e.gamma || 0) } }) } document.addEventListener('mousemove', onMouse, { passive: true }) document.addEventListener('keydown', onKey, { passive: true }) window.addEventListener('scroll', onScroll, { passive: true }) document.addEventListener('click', onClick, { passive: true }) document.addEventListener('touchstart', onTouch, { passive: true }) document.addEventListener('focusin', onFocusIn, { passive: true }) document.addEventListener('visibilitychange', onVis) window.addEventListener('focus', onFocus) window.addEventListener('blur', onBlur) window.addEventListener('resize', onResize, { passive: true }) window.addEventListener('deviceorientation', onOrient, { passive: true }) // Battery (tracked for cleanup) interface BatteryManager { level: number charging: boolean addEventListener(event: string, cb: () => void): void removeEventListener(event: string, cb: () => void): void } interface NetInfo { effectiveType?: string downlink?: number addEventListener(event: string, cb: () => void): void removeEventListener(event: string, cb: () => void): void } const nav = navigator as Navigator & { getBattery?: () => Promise connection?: NetInfo } let batteryRef: BatteryManager | null = null const onBatteryLevel = () => batteryRef && update({ batteryLevel: Math.round(batteryRef.level * 100) }) const onBatteryCharging = () => batteryRef && update({ batteryCharging: batteryRef.charging }) nav.getBattery?.().then((battery: BatteryManager) => { batteryRef = battery update({ batteryLevel: Math.round(battery.level * 100), batteryCharging: battery.charging }) battery.addEventListener('levelchange', onBatteryLevel) battery.addEventListener('chargingchange', onBatteryCharging) }).catch(() => {}) // Network (tracked for cleanup) const conn = nav.connection const onConn = () => conn && update({ networkType: conn.effectiveType, networkDownlink: conn.downlink }) if (conn) { update({ networkType: conn.effectiveType, networkDownlink: conn.downlink }) conn.addEventListener('change', onConn) } // Idle ticker const idleInterval = setInterval(() => { const idleSeconds = Math.round((Date.now() - lastActivityRef.current) / 1000) setState(s => ({ ...s, idleSeconds, isActive: idleSeconds < 30 })) }, 1000) return () => { document.removeEventListener('mousemove', onMouse) document.removeEventListener('keydown', onKey) window.removeEventListener('scroll', onScroll) document.removeEventListener('click', onClick) document.removeEventListener('touchstart', onTouch) document.removeEventListener('focusin', onFocusIn) document.removeEventListener('visibilitychange', onVis) window.removeEventListener('focus', onFocus) window.removeEventListener('blur', onBlur) window.removeEventListener('resize', onResize) window.removeEventListener('deviceorientation', onOrient) clearInterval(idleInterval) Object.values(timersRef.current).forEach(clearTimeout) if (batteryRef) { batteryRef.removeEventListener('levelchange', onBatteryLevel) batteryRef.removeEventListener('chargingchange', onBatteryCharging) } if (conn) conn.removeEventListener('change', onConn) } }, [enabled]) return state } /** Format for system prompt injection. */ export function activityToContextString(s: UserActivityState): string { const lines: string[] = ['### User Activity'] lines.push(`- Active: ${s.isActive ? 'yes' : 'no'} (idle ${s.idleSeconds}s)`) const actions = [ s.isTyping && 'typing', s.isScrolling && 'scrolling', s.isMovingMouse && 'moving mouse', ].filter(Boolean).join(', ') if (actions) lines.push(`- Doing: ${actions}`) lines.push(`- Window: ${s.windowFocused ? 'focused' : 'unfocused'}, ${s.visibilityState}`) lines.push(`- Screen: ${s.screenSize.width}×${s.screenSize.height}${s.touchDevice ? ' (touch)' : ''}`) if (s.batteryLevel !== null) lines.push(`- Battery: ${s.batteryLevel}%${s.batteryCharging ? ' charging' : ''}`) if (s.networkType) lines.push(`- Network: ${s.networkType}${s.networkDownlink ? ` (${s.networkDownlink}Mbps)` : ''}`) if (s.orientation) lines.push(`- Orient: α${s.orientation.alpha}° β${s.orientation.beta}° γ${s.orientation.gamma}°`) return lines.join('\n') }