Skip to content

Commit d083a8e

Browse files
committed
Implement inactivity timer for notification refresh
- Create useInactivityTimer hook to track user activity - Replace useInterval with useInactivityTimer for notification fetching - Only fetch notifications after 60 seconds of user inactivity - Add comprehensive tests for inactivity timer functionality - Maintain same 60-second interval but based on inactivity not schedule
1 parent ff46ac9 commit d083a8e

File tree

3 files changed

+251
-1
lines changed

3 files changed

+251
-1
lines changed

src/renderer/context/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ipcRenderer, webFrame } from 'electron';
1212
import { useTheme } from '@primer/react';
1313

1414
import { namespacedEvent } from '../../shared/events';
15+
import { useInactivityTimer } from '../hooks/useInactivityTimer';
1516
import { useInterval } from '../hooks/useInterval';
1617
import { useNotifications } from '../hooks/useNotifications';
1718
import {
@@ -196,7 +197,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
196197
settings.filterReasons,
197198
]);
198199

199-
useInterval(() => {
200+
useInactivityTimer(() => {
200201
fetchNotifications({ auth, settings });
201202
}, Constants.FETCH_NOTIFICATIONS_INTERVAL);
202203

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
3+
import { useInactivityTimer } from './useInactivityTimer';
4+
5+
// Mock timers for testing
6+
jest.useFakeTimers();
7+
8+
describe('hooks/useInactivityTimer.ts', () => {
9+
afterEach(() => {
10+
jest.clearAllTimers();
11+
// Clear any event listeners
12+
document.removeEventListener = jest.fn();
13+
document.addEventListener = jest.fn();
14+
});
15+
16+
afterAll(() => {
17+
jest.useRealTimers();
18+
});
19+
20+
it('should call callback after inactivity period', () => {
21+
const mockCallback = jest.fn();
22+
const delay = 60000; // 60 seconds
23+
24+
renderHook(() => useInactivityTimer(mockCallback, delay));
25+
26+
// Fast-forward time
27+
act(() => {
28+
jest.advanceTimersByTime(delay);
29+
});
30+
31+
expect(mockCallback).toHaveBeenCalledTimes(1);
32+
});
33+
34+
it('should not call callback before inactivity period', () => {
35+
const mockCallback = jest.fn();
36+
const delay = 60000; // 60 seconds
37+
38+
renderHook(() => useInactivityTimer(mockCallback, delay));
39+
40+
// Fast-forward time but not enough
41+
act(() => {
42+
jest.advanceTimersByTime(delay - 1000);
43+
});
44+
45+
expect(mockCallback).not.toHaveBeenCalled();
46+
});
47+
48+
it('should reset timer on user activity', () => {
49+
const mockCallback = jest.fn();
50+
const delay = 60000; // 60 seconds
51+
52+
// Mock document event handling
53+
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
54+
const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
55+
56+
const { unmount } = renderHook(() =>
57+
useInactivityTimer(mockCallback, delay),
58+
);
59+
60+
// Verify event listeners were added
61+
expect(addEventListenerSpy).toHaveBeenCalledWith(
62+
'mousedown',
63+
expect.any(Function),
64+
{ passive: true },
65+
);
66+
expect(addEventListenerSpy).toHaveBeenCalledWith(
67+
'mousemove',
68+
expect.any(Function),
69+
{ passive: true },
70+
);
71+
expect(addEventListenerSpy).toHaveBeenCalledWith(
72+
'keypress',
73+
expect.any(Function),
74+
{ passive: true },
75+
);
76+
expect(addEventListenerSpy).toHaveBeenCalledWith(
77+
'scroll',
78+
expect.any(Function),
79+
{ passive: true },
80+
);
81+
expect(addEventListenerSpy).toHaveBeenCalledWith(
82+
'touchstart',
83+
expect.any(Function),
84+
{ passive: true },
85+
);
86+
expect(addEventListenerSpy).toHaveBeenCalledWith(
87+
'click',
88+
expect.any(Function),
89+
{ passive: true },
90+
);
91+
92+
// Simulate time passing
93+
act(() => {
94+
jest.advanceTimersByTime(30000); // 30 seconds
95+
});
96+
97+
expect(mockCallback).not.toHaveBeenCalled();
98+
99+
// Simulate user activity (get the reset function from the event listener)
100+
const resetTimerFn = addEventListenerSpy.mock.calls.find(
101+
(call) => call[0] === 'click',
102+
)?.[1] as () => void;
103+
104+
act(() => {
105+
resetTimerFn(); // Simulate click
106+
});
107+
108+
// Continue time, but timer should be reset
109+
act(() => {
110+
jest.advanceTimersByTime(30000); // Another 30 seconds (total 60)
111+
});
112+
113+
// Callback should not have been called yet because timer was reset
114+
expect(mockCallback).not.toHaveBeenCalled();
115+
116+
// Now advance the full delay from the reset
117+
act(() => {
118+
jest.advanceTimersByTime(60000);
119+
});
120+
121+
expect(mockCallback).toHaveBeenCalledTimes(1);
122+
123+
// Cleanup
124+
unmount();
125+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
126+
'mousedown',
127+
expect.any(Function),
128+
);
129+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
130+
'mousemove',
131+
expect.any(Function),
132+
);
133+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
134+
'keypress',
135+
expect.any(Function),
136+
);
137+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
138+
'scroll',
139+
expect.any(Function),
140+
);
141+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
142+
'touchstart',
143+
expect.any(Function),
144+
);
145+
expect(removeEventListenerSpy).toHaveBeenCalledWith(
146+
'click',
147+
expect.any(Function),
148+
);
149+
});
150+
151+
it('should not set timer when delay is null', () => {
152+
const mockCallback = jest.fn();
153+
154+
renderHook(() => useInactivityTimer(mockCallback, null as any));
155+
156+
act(() => {
157+
jest.advanceTimersByTime(60000);
158+
});
159+
160+
expect(mockCallback).not.toHaveBeenCalled();
161+
});
162+
163+
it('should update callback when it changes', () => {
164+
const mockCallback1 = jest.fn();
165+
const mockCallback2 = jest.fn();
166+
const delay = 60000;
167+
168+
const { rerender } = renderHook(
169+
({ callback }) => useInactivityTimer(callback, delay),
170+
{ initialProps: { callback: mockCallback1 } },
171+
);
172+
173+
// Change the callback
174+
rerender({ callback: mockCallback2 });
175+
176+
act(() => {
177+
jest.advanceTimersByTime(delay);
178+
});
179+
180+
expect(mockCallback1).not.toHaveBeenCalled();
181+
expect(mockCallback2).toHaveBeenCalledTimes(1);
182+
});
183+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
/**
4+
* Hook that triggers a callback after a specified period of user inactivity.
5+
* User activity (mouse movement, clicks, key presses) resets the timer.
6+
*/
7+
export const useInactivityTimer = (callback: () => void, delay: number) => {
8+
const savedCallback = useRef<(() => void) | null>(null);
9+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
10+
11+
// Remember the latest callback
12+
useEffect(() => {
13+
savedCallback.current = callback;
14+
}, [callback]);
15+
16+
// Reset the inactivity timer
17+
const resetTimer = useCallback(() => {
18+
if (timeoutRef.current) {
19+
clearTimeout(timeoutRef.current);
20+
}
21+
22+
if (delay !== null && savedCallback.current) {
23+
timeoutRef.current = setTimeout(() => {
24+
savedCallback.current?.();
25+
}, delay);
26+
}
27+
}, [delay]);
28+
29+
// Set up event listeners for user activity
30+
useEffect(() => {
31+
if (delay === null) {
32+
return;
33+
}
34+
35+
// Events that indicate user activity
36+
const events = [
37+
'mousedown',
38+
'mousemove',
39+
'keypress',
40+
'scroll',
41+
'touchstart',
42+
'click',
43+
];
44+
45+
// Add event listeners to track activity
46+
events.forEach((event) => {
47+
document.addEventListener(event, resetTimer, { passive: true });
48+
});
49+
50+
// Start the initial timer
51+
resetTimer();
52+
53+
// Cleanup function
54+
return () => {
55+
if (timeoutRef.current) {
56+
clearTimeout(timeoutRef.current);
57+
}
58+
events.forEach((event) => {
59+
document.removeEventListener(event, resetTimer);
60+
});
61+
};
62+
}, [delay, resetTimer]);
63+
64+
// Return the reset function for manual timer resets if needed
65+
return resetTimer;
66+
};

0 commit comments

Comments
 (0)