diff --git a/src/hooks/useDetectInactivity.test.ts b/src/hooks/useDetectInactivity.test.ts new file mode 100644 index 0000000..8830e74 --- /dev/null +++ b/src/hooks/useDetectInactivity.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from '@testing-library/react'; +import useDetectInactivity, { isTouchDevice } from './useDetectInactivity'; +import useTimer from './useTimer'; + +jest.mock('./useTimer'); + +describe('useDetectInactivity', () => { + let startMock: jest.Mock; + let onInactivityMock: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + startMock = jest.fn(); + onInactivityMock = jest.fn(); + (useTimer as jest.Mock).mockImplementation((callback, time) => ({ + start: () => { + startMock(); + setTimeout(() => { + callback(); + }, time); + }, + })); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('타이머에 설정된 시간 후에는 비활동 상태가 감지된다.', () => { + const { result } = renderHook(() => + useDetectInactivity(5000, onInactivityMock) + ); + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(onInactivityMock).toHaveBeenCalled(); + expect(result.current).toBe(true); + }); + + test('비활동 상태일때 onInactivity콜백은 호출되지 않는다.', () => { + renderHook(() => useDetectInactivity(5000, onInactivityMock)); + + act(() => { + jest.advanceTimersByTime(4500); + }); + + expect(onInactivityMock).not.toHaveBeenCalled(); + }); + + test('활동(설정된 이벤트)이 감지되면 타이머는 리셋된 후 다시 실행된다.', () => { + const { result } = renderHook(() => + useDetectInactivity(5000, onInactivityMock) + ); + + act(() => { + jest.advanceTimersByTime(3000); + }); + + expect(startMock).toHaveBeenCalledTimes(1); + expect(result.current).toBe(false); + + act(() => { + window.dispatchEvent(new Event('keyup')); + }); + + expect(startMock).toHaveBeenCalledTimes(1); + expect(result.current).toBe(false); + + act(() => { + window.dispatchEvent(new Event('mousemove')); + }); + + expect(startMock).toHaveBeenCalledTimes(2); + expect(result.current).toBe(false); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(onInactivityMock).toHaveBeenCalled(); + expect(result.current).toBe(true); + }); + + test('환경에 맞게 이벤트 리스너가 추가/제거된다.', () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const { unmount } = renderHook(() => + useDetectInactivity(5000, onInactivityMock) + ); + + const expectedClientEvents = isTouchDevice() + ? ['touchstart'] + : ['mousemove', 'keydown', 'click', 'dblclick', 'scroll']; + + const addedEvents = addEventListenerSpy.mock.calls.map( + ([event, callback]) => ({ event, callback }) + ); + + expectedClientEvents.forEach((event) => { + expect(addedEvents.some((e) => e.event === event)).toBe(true); + }); + + act(() => { + unmount(); + }); + + const removedEvents = removeEventListenerSpy.mock.calls.map( + ([event, callback]) => ({ event, callback }) + ); + + expectedClientEvents.forEach((event) => { + expect(removedEvents.some((e) => e.event === event)).toBe(true); + }); + + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useDetectInactivity.ts b/src/hooks/useDetectInactivity.ts new file mode 100644 index 0000000..507106b --- /dev/null +++ b/src/hooks/useDetectInactivity.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useState } from 'react'; +import useTimer from './useTimer'; +import { Fn } from '../types'; +import { isTouchDevice, throttle } from '../utils'; + +/** + * 일정 시간(ms) 동안 활동이 없을 때 지정된 콜백 함수를 실행하는 훅. + * + * @param {number} time - 비활성 상태로 간주되기까지의 시간(밀리초). 양의 정수로 지정. (최소값 5000ms) + * @param {Fn} onInactivity - 비활성 상태가 감지되었을 때 호출되는 콜백 함수. + * + * @returns {boolean} - 현재 비활동 상태 여부를 나타내는 boolean 값. + * + * @description + * 사용자가 정의한 시간(time) 동안 활동이 없으면 비활성 상태로 간주하고, 지정된 콜백 함수(onInactivity)를 호출합니다. + * 해당 환경에 맞게 설정된 이벤트를 5초마다 리스닝하여, 활동이 감지될 시 타이머를 리셋합니다. + */ + +const useDetectInactivity = (time: number, onInactivity: Fn) => { + const [isInactive, setIsInactive] = useState(false); + const { start } = useTimer(() => setIsInactive(true), time); + + // 이벤트 리스너는 5초마다 감지 + const MIN_THROTTLE_TIME = 5000; + + if (time < MIN_THROTTLE_TIME) { + throw new Error( + `'time'은 최소 ${MIN_THROTTLE_TIME}ms 이상으로 설정되어야 합니다.` + ); + } + + const clientEvents = isTouchDevice() + ? ['touchstart'] + : ['mousemove', 'keydown', 'click', 'dblclick', 'scroll']; + + const resetTimer = useCallback(() => { + setIsInactive(false); + start(); + }, [start]); + + useEffect(() => { + start(); + + const throttledResetTimer = throttle(resetTimer, MIN_THROTTLE_TIME); + + clientEvents.forEach((event) => { + window.addEventListener(event, throttledResetTimer); + }); + + return () => { + clientEvents.forEach((event) => + window.removeEventListener(event, throttledResetTimer) + ); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetTimer]); + + useEffect(() => { + if (isInactive) { + onInactivity(); + } + }, [isInactive, onInactivity]); + + return isInactive; +}; + +export default useDetectInactivity; diff --git a/src/hooks/useMousePosition.ts b/src/hooks/useMousePosition.ts index 1568ea3..8eff2d5 100644 --- a/src/hooks/useMousePosition.ts +++ b/src/hooks/useMousePosition.ts @@ -6,6 +6,7 @@ import { useRef, useState, } from 'react'; +import { throttle } from '../utils'; interface MousePosOptions { delayTime?: number; @@ -160,21 +161,6 @@ const useMousePosition = ({ export default useMousePosition; -const throttle = ( - callbackFn: (event: T) => void, - delayTime: number -) => { - let lastTime = 0; - - return (event: T) => { - const now = Date.now(); - if (now - lastTime >= delayTime) { - lastTime = now; - callbackFn(event); - } - }; -}; - const animationFrameHandler = ( callbackFn: (event: T) => void ) => { diff --git a/src/types/index.ts b/src/types/index.ts index 8d59b53..1f255c1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,3 @@ export type Fn = () => void; + +export type GenericFn = (...args: T) => void; diff --git a/src/utils/index.ts b/src/utils/index.ts index f88a5ae..ff44333 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,4 @@ export { delayExecution, type CancelToken } from './delayExecution'; +export { throttle } from './throttle'; +export { isClient } from './isClient'; +export { isTouchDevice } from './isTouchDevice'; diff --git a/src/utils/isClient.ts b/src/utils/isClient.ts new file mode 100644 index 0000000..a656c39 --- /dev/null +++ b/src/utils/isClient.ts @@ -0,0 +1 @@ +export const isClient = typeof window !== 'undefined'; diff --git a/src/utils/isTouchDevice.ts b/src/utils/isTouchDevice.ts new file mode 100644 index 0000000..f5f1d80 --- /dev/null +++ b/src/utils/isTouchDevice.ts @@ -0,0 +1,8 @@ +import { isClient } from './isClient'; + +export const isTouchDevice = () => { + if (!isClient) { + return false; + } + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; +}; diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts new file mode 100644 index 0000000..0ee5622 --- /dev/null +++ b/src/utils/throttle.ts @@ -0,0 +1,18 @@ +import { GenericFn } from '../types'; + +export const throttle = ( + callbackFn: GenericFn, + delayTime: number +) => { + let lastTime = 0; + + const throttledFunction = (...args: T) => { + const now = Date.now(); + if (now - lastTime >= delayTime) { + lastTime = now; + callbackFn(...args); + } + }; + + return throttledFunction; +};