From ff46ac9d09e9465da9bbce153c03eb6d1d5ff942 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:18:37 +0000 Subject: [PATCH 1/4] Initial plan From d083a8ec4aa3082ed247244d7d12bee0d7fe16d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:30:20 +0000 Subject: [PATCH 2/4] 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 --- src/renderer/context/App.tsx | 3 +- src/renderer/hooks/useInactivityTimer.test.ts | 183 ++++++++++++++++++ src/renderer/hooks/useInactivityTimer.ts | 66 +++++++ 3 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/renderer/hooks/useInactivityTimer.test.ts create mode 100644 src/renderer/hooks/useInactivityTimer.ts 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..6c18b7880 --- /dev/null +++ b/src/renderer/hooks/useInactivityTimer.test.ts @@ -0,0 +1,183 @@ +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(); + + 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); + }); +}); diff --git a/src/renderer/hooks/useInactivityTimer.ts b/src/renderer/hooks/useInactivityTimer.ts new file mode 100644 index 000000000..cdebe9774 --- /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); + + // 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(() => { + savedCallback.current?.(); + }, delay); + } + }, [delay]); + + // Set up event listeners for user activity + useEffect(() => { + if (delay === null) { + return; + } + + // Events that indicate user activity + const events = [ + 'mousedown', + 'mousemove', + 'keypress', + 'scroll', + 'touchstart', + 'click', + ]; + + // Add event listeners to track activity + events.forEach((event) => { + document.addEventListener(event, resetTimer, { passive: true }); + }); + + // Start the 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; +}; From cfa695a8dcee174905976aecb42a43b6792f8fab Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 20 Aug 2025 10:48:55 -0400 Subject: [PATCH 3/4] feat: update logic Signed-off-by: Adam Setch --- src/renderer/hooks/useInactivityTimer.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/renderer/hooks/useInactivityTimer.ts b/src/renderer/hooks/useInactivityTimer.ts index cdebe9774..a81eaaef1 100644 --- a/src/renderer/hooks/useInactivityTimer.ts +++ b/src/renderer/hooks/useInactivityTimer.ts @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef } from 'react'; */ export const useInactivityTimer = (callback: () => void, delay: number) => { const savedCallback = useRef<(() => void) | null>(null); - const timeoutRef = useRef(null); + const timeoutRef = useRef | null>(null); // Remember the latest callback useEffect(() => { @@ -18,10 +18,12 @@ export const useInactivityTimer = (callback: () => void, delay: number) => { 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]); @@ -31,8 +33,6 @@ export const useInactivityTimer = (callback: () => void, delay: number) => { if (delay === null) { return; } - - // Events that indicate user activity const events = [ 'mousedown', 'mousemove', @@ -47,7 +47,7 @@ export const useInactivityTimer = (callback: () => void, delay: number) => { document.addEventListener(event, resetTimer, { passive: true }); }); - // Start the initial timer + // Start initial timer resetTimer(); // Cleanup function From 2d623089fb58eb0a46742b907fea483a8b0eed6d Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 20 Aug 2025 11:03:34 -0400 Subject: [PATCH 4/4] feat: update logic Signed-off-by: Adam Setch --- src/renderer/hooks/useInactivityTimer.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/renderer/hooks/useInactivityTimer.test.ts b/src/renderer/hooks/useInactivityTimer.test.ts index 6c18b7880..b1a2763cb 100644 --- a/src/renderer/hooks/useInactivityTimer.test.ts +++ b/src/renderer/hooks/useInactivityTimer.test.ts @@ -151,6 +151,8 @@ describe('hooks/useInactivityTimer.ts', () => { 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(() => { @@ -180,4 +182,65 @@ describe('hooks/useInactivityTimer.ts', () => { 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(); + }); });