Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -196,7 +197,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
settings.filterReasons,
]);

useInterval(() => {
useInactivityTimer(() => {
fetchNotifications({ auth, settings });
}, Constants.FETCH_NOTIFICATIONS_INTERVAL);

Expand Down
246 changes: 246 additions & 0 deletions src/renderer/hooks/useInactivityTimer.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
66 changes: 66 additions & 0 deletions src/renderer/hooks/useInactivityTimer.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | 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;
};
Loading