Skip to main content

logicaffeine_web/ui/hooks/
use_inactivity_timer.rs

1//! Inactivity timer hook for detecting when users need help.
2//!
3//! This hook monitors user activity and triggers a callback when the user
4//! has been inactive for a specified duration. Used to trigger Socratic hints
5//! when students are struggling.
6
7use dioxus::prelude::*;
8
9/// Default inactivity threshold (5 seconds)
10pub const DEFAULT_INACTIVITY_THRESHOLD_MS: u64 = 5000;
11
12/// State returned by the inactivity timer hook
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub struct InactivityState {
15    /// Whether the user is currently inactive (threshold exceeded)
16    pub is_inactive: bool,
17    /// Duration of inactivity in milliseconds
18    pub inactive_duration_ms: u64,
19}
20
21impl Default for InactivityState {
22    fn default() -> Self {
23        Self {
24            is_inactive: false,
25            inactive_duration_ms: 0,
26        }
27    }
28}
29
30/// Hook that tracks user inactivity and triggers when threshold is exceeded.
31///
32/// # Arguments
33/// * `threshold_ms` - Milliseconds of inactivity before triggering
34/// * `on_inactive` - Callback invoked when user becomes inactive
35///
36/// # Returns
37/// A tuple of:
38/// - `InactivityState` - Current inactivity state
39/// - `reset_fn` - Function to call when user performs an action
40///
41/// # Example
42/// ```ignore
43/// let (inactivity, reset_activity) = use_inactivity_timer(5000, move || {
44///     // Show hint to user
45/// });
46///
47/// // In input handler:
48/// oninput: move |_| reset_activity(),
49/// ```
50#[cfg(target_arch = "wasm32")]
51pub fn use_inactivity_timer(
52    threshold_ms: u64,
53    on_inactive: impl Fn() + 'static,
54) -> (Signal<InactivityState>, impl FnMut()) {
55    use gloo_timers::callback::Interval;
56
57    let mut state = use_signal(InactivityState::default);
58    let mut last_activity_time = use_signal(|| js_sys::Date::now());
59    let mut callback_triggered = use_signal(|| false);
60
61    // Store the callback in a resource that lives for the component lifetime
62    let on_inactive = std::rc::Rc::new(on_inactive);
63    let on_inactive_clone = on_inactive.clone();
64
65    // Set up interval to check inactivity
66    use_effect(move || {
67        let on_inactive = on_inactive_clone.clone();
68
69        let interval = Interval::new(1000, move || {
70            let now = js_sys::Date::now();
71            let last = *last_activity_time.read();
72            let elapsed_ms = (now - last) as u64;
73
74            let is_inactive = elapsed_ms >= threshold_ms;
75
76            // Update state
77            state.set(InactivityState {
78                is_inactive,
79                inactive_duration_ms: elapsed_ms,
80            });
81
82            // Trigger callback once when becoming inactive
83            if is_inactive && !*callback_triggered.read() {
84                callback_triggered.set(true);
85                on_inactive();
86            }
87        });
88
89        // Keep interval alive by forgetting it (cleanup handled by component unmount)
90        std::mem::forget(interval);
91    });
92
93    // Reset function to call when user is active
94    let reset_activity = move || {
95        #[cfg(target_arch = "wasm32")]
96        {
97            last_activity_time.set(js_sys::Date::now());
98        }
99        callback_triggered.set(false);
100        state.set(InactivityState::default());
101    };
102
103    (state, reset_activity)
104}
105
106/// Non-WASM fallback that does nothing (for testing)
107#[cfg(not(target_arch = "wasm32"))]
108pub fn use_inactivity_timer(
109    _threshold_ms: u64,
110    _on_inactive: impl Fn() + 'static,
111) -> (Signal<InactivityState>, impl FnMut()) {
112    let state = use_signal(InactivityState::default);
113    let reset_activity = || {};
114    (state, reset_activity)
115}
116
117/// Simpler version that just returns whether user is inactive
118#[cfg(target_arch = "wasm32")]
119pub fn use_is_inactive(threshold_ms: u64) -> Signal<bool> {
120    use gloo_timers::callback::Interval;
121
122    let mut is_inactive = use_signal(|| false);
123    let mut last_activity_time = use_signal(|| js_sys::Date::now());
124
125    use_effect(move || {
126        let interval = Interval::new(1000, move || {
127            let now = js_sys::Date::now();
128            let last = *last_activity_time.read();
129            let elapsed_ms = (now - last) as u64;
130
131            is_inactive.set(elapsed_ms >= threshold_ms);
132        });
133
134        // Keep interval alive by forgetting it (cleanup handled by component unmount)
135        std::mem::forget(interval);
136    });
137
138    is_inactive
139}
140
141#[cfg(not(target_arch = "wasm32"))]
142pub fn use_is_inactive(_threshold_ms: u64) -> Signal<bool> {
143    use_signal(|| false)
144}
145
146/// Hook to get a function that resets the activity timer
147/// Call this in oninput, onclick, onkeydown handlers
148#[cfg(target_arch = "wasm32")]
149pub fn use_activity_resetter() -> (Signal<f64>, impl FnMut()) {
150    let last_activity = use_signal(|| js_sys::Date::now());
151
152    let reset = {
153        let mut last_activity = last_activity;
154        move || {
155            last_activity.set(js_sys::Date::now());
156        }
157    };
158
159    (last_activity, reset)
160}
161
162#[cfg(not(target_arch = "wasm32"))]
163pub fn use_activity_resetter() -> (Signal<f64>, impl FnMut()) {
164    let last_activity = use_signal(|| 0.0);
165    let reset = || {};
166    (last_activity, reset)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_inactivity_state_default() {
175        let state = InactivityState::default();
176        assert!(!state.is_inactive);
177        assert_eq!(state.inactive_duration_ms, 0);
178    }
179
180    #[test]
181    fn test_default_threshold() {
182        assert_eq!(DEFAULT_INACTIVITY_THRESHOLD_MS, 5000);
183    }
184}