diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index f1b041d77..a5cd154f4 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -12,6 +12,7 @@ import { ipcRenderer, webFrame } from 'electron'; import { useTheme } from '@primer/react'; import { namespacedEvent } from '../../shared/events'; +import { useInactivityTimer } from '../hooks/useInactivityTimer'; import { useInterval } from '../hooks/useInterval'; import { useNotifications } from '../hooks/useNotifications'; import { @@ -196,7 +197,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { settings.filterReasons, ]); - useInterval(() => { + useInactivityTimer(() => { fetchNotifications({ auth, settings }); }, Constants.FETCH_NOTIFICATIONS_INTERVAL); diff --git a/src/renderer/hooks/useInactivityTimer.test.ts b/src/renderer/hooks/useInactivityTimer.test.ts new file mode 100644 index 000000000..b1a2763cb --- /dev/null +++ b/src/renderer/hooks/useInactivityTimer.test.ts @@ -0,0 +1,246 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useInactivityTimer } from './useInactivityTimer'; + +// Mock timers for testing +jest.useFakeTimers(); + +describe('hooks/useInactivityTimer.ts', () => { + afterEach(() => { + jest.clearAllTimers(); + // Clear any event listeners + document.removeEventListener = jest.fn(); + document.addEventListener = jest.fn(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should call callback after inactivity period', () => { + const mockCallback = jest.fn(); + const delay = 60000; // 60 seconds + + renderHook(() => useInactivityTimer(mockCallback, delay)); + + // Fast-forward time + act(() => { + jest.advanceTimersByTime(delay); + }); + + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it('should not call callback before inactivity period', () => { + const mockCallback = jest.fn(); + const delay = 60000; // 60 seconds + + renderHook(() => useInactivityTimer(mockCallback, delay)); + + // Fast-forward time but not enough + act(() => { + jest.advanceTimersByTime(delay - 1000); + }); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it('should reset timer on user activity', () => { + const mockCallback = jest.fn(); + const delay = 60000; // 60 seconds + + // Mock document event handling + const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + + const { unmount } = renderHook(() => + useInactivityTimer(mockCallback, delay), + ); + + // Verify event listeners were added + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'mousedown', + expect.any(Function), + { passive: true }, + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function), + { passive: true }, + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'keypress', + expect.any(Function), + { passive: true }, + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'scroll', + expect.any(Function), + { passive: true }, + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'touchstart', + expect.any(Function), + { passive: true }, + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'click', + expect.any(Function), + { passive: true }, + ); + + // Simulate time passing + act(() => { + jest.advanceTimersByTime(30000); // 30 seconds + }); + + expect(mockCallback).not.toHaveBeenCalled(); + + // Simulate user activity (get the reset function from the event listener) + const resetTimerFn = addEventListenerSpy.mock.calls.find( + (call) => call[0] === 'click', + )?.[1] as () => void; + + act(() => { + resetTimerFn(); // Simulate click + }); + + // Continue time, but timer should be reset + act(() => { + jest.advanceTimersByTime(30000); // Another 30 seconds (total 60) + }); + + // Callback should not have been called yet because timer was reset + expect(mockCallback).not.toHaveBeenCalled(); + + // Now advance the full delay from the reset + act(() => { + jest.advanceTimersByTime(60000); + }); + + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Cleanup + unmount(); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mousedown', + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'keypress', + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'scroll', + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'touchstart', + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'click', + expect.any(Function), + ); + }); + + it('should not set timer when delay is null', () => { + const mockCallback = jest.fn(); + + // Intentional: passing null to validate hook ignores timer + // biome-ignore lint/suspicious/noExplicitAny: test intentionally passes invalid value + renderHook(() => useInactivityTimer(mockCallback, null as any)); + + act(() => { + jest.advanceTimersByTime(60000); + }); + + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it('should update callback when it changes', () => { + const mockCallback1 = jest.fn(); + const mockCallback2 = jest.fn(); + const delay = 60000; + + const { rerender } = renderHook( + ({ callback }) => useInactivityTimer(callback, delay), + { initialProps: { callback: mockCallback1 } }, + ); + + // Change the callback + rerender({ callback: mockCallback2 }); + + act(() => { + jest.advanceTimersByTime(delay); + }); + + expect(mockCallback1).not.toHaveBeenCalled(); + expect(mockCallback2).toHaveBeenCalledTimes(1); + }); + + it('should fire repeatedly after each inactivity interval', () => { + const mockCallback = jest.fn(); + const delay = 1000; + + renderHook(() => useInactivityTimer(mockCallback, delay)); + + act(() => { + jest.advanceTimersByTime(delay); // 1st + jest.advanceTimersByTime(delay); // 2nd + jest.advanceTimersByTime(delay); // 3rd + }); + + expect(mockCallback).toHaveBeenCalledTimes(3); + }); + + it('returned reset function should manually restart timer', () => { + const mockCallback = jest.fn(); + const delay = 1000; + + const { result } = renderHook(() => + useInactivityTimer(mockCallback, delay), + ); + + act(() => { + jest.advanceTimersByTime(delay); // first fire + }); + expect(mockCallback).toHaveBeenCalledTimes(1); + + act(() => { + result.current(); // manual reset + jest.advanceTimersByTime(500); // half way + }); + expect(mockCallback).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(500); // complete second interval + }); + expect(mockCallback).toHaveBeenCalledTimes(2); + }); + + it('should clear timers on unmount and not fire afterward', () => { + const mockCallback = jest.fn(); + const delay = 1000; + + const { unmount } = renderHook(() => + useInactivityTimer(mockCallback, delay), + ); + + act(() => { + jest.advanceTimersByTime(500); // not yet fired + }); + expect(mockCallback).not.toHaveBeenCalled(); + + unmount(); + + act(() => { + jest.advanceTimersByTime(2000); // would have fired twice if mounted + }); + expect(mockCallback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/hooks/useInactivityTimer.ts b/src/renderer/hooks/useInactivityTimer.ts new file mode 100644 index 000000000..a81eaaef1 --- /dev/null +++ b/src/renderer/hooks/useInactivityTimer.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Hook that triggers a callback after a specified period of user inactivity. + * User activity (mouse movement, clicks, key presses) resets the timer. + */ +export const useInactivityTimer = (callback: () => void, delay: number) => { + const savedCallback = useRef<(() => void) | null>(null); + const timeoutRef = useRef | null>(null); + + // Remember the latest callback + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // Reset the inactivity timer + const resetTimer = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + if (delay !== null && savedCallback.current) { + timeoutRef.current = setTimeout(() => { + // Fire callback once inactivity threshold reached + savedCallback.current?.(); + // Schedule next run while still inactive + resetTimer(); + }, delay); + } + }, [delay]); + + // Set up event listeners for user activity + useEffect(() => { + if (delay === null) { + return; + } + const events = [ + 'mousedown', + 'mousemove', + 'keypress', + 'scroll', + 'touchstart', + 'click', + ]; + + // Add event listeners to track activity + events.forEach((event) => { + document.addEventListener(event, resetTimer, { passive: true }); + }); + + // Start initial timer + resetTimer(); + + // Cleanup function + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + events.forEach((event) => { + document.removeEventListener(event, resetTimer); + }); + }; + }, [delay, resetTimer]); + + // Return the reset function for manual timer resets if needed + return resetTimer; +};