Skip to content

Commit 32ab241

Browse files
authored
Merge pull request #36 from frontend-opensource-project/URH-42/use-detect-inactivity
[URH-42] useDetectInactivity 신규
2 parents 96c20b6 + fcdc103 commit 32ab241

File tree

8 files changed

+225
-15
lines changed

8 files changed

+225
-15
lines changed

src/hooks/useDetectInactivity.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import useDetectInactivity, { isTouchDevice } from './useDetectInactivity';
3+
import useTimer from './useTimer';
4+
5+
jest.mock('./useTimer');
6+
7+
describe('useDetectInactivity', () => {
8+
let startMock: jest.Mock;
9+
let onInactivityMock: jest.Mock;
10+
11+
beforeEach(() => {
12+
jest.useFakeTimers();
13+
startMock = jest.fn();
14+
onInactivityMock = jest.fn();
15+
(useTimer as jest.Mock).mockImplementation((callback, time) => ({
16+
start: () => {
17+
startMock();
18+
setTimeout(() => {
19+
callback();
20+
}, time);
21+
},
22+
}));
23+
});
24+
25+
afterEach(() => {
26+
jest.clearAllTimers();
27+
jest.clearAllMocks();
28+
});
29+
30+
afterAll(() => {
31+
jest.useRealTimers();
32+
});
33+
34+
test('타이머에 설정된 시간 후에는 비활동 상태가 감지된다.', () => {
35+
const { result } = renderHook(() =>
36+
useDetectInactivity(5000, onInactivityMock)
37+
);
38+
act(() => {
39+
jest.advanceTimersByTime(5000);
40+
});
41+
42+
expect(onInactivityMock).toHaveBeenCalled();
43+
expect(result.current).toBe(true);
44+
});
45+
46+
test('비활동 상태일때 onInactivity콜백은 호출되지 않는다.', () => {
47+
renderHook(() => useDetectInactivity(5000, onInactivityMock));
48+
49+
act(() => {
50+
jest.advanceTimersByTime(4500);
51+
});
52+
53+
expect(onInactivityMock).not.toHaveBeenCalled();
54+
});
55+
56+
test('활동(설정된 이벤트)이 감지되면 타이머는 리셋된 후 다시 실행된다.', () => {
57+
const { result } = renderHook(() =>
58+
useDetectInactivity(5000, onInactivityMock)
59+
);
60+
61+
act(() => {
62+
jest.advanceTimersByTime(3000);
63+
});
64+
65+
expect(startMock).toHaveBeenCalledTimes(1);
66+
expect(result.current).toBe(false);
67+
68+
act(() => {
69+
window.dispatchEvent(new Event('keyup'));
70+
});
71+
72+
expect(startMock).toHaveBeenCalledTimes(1);
73+
expect(result.current).toBe(false);
74+
75+
act(() => {
76+
window.dispatchEvent(new Event('mousemove'));
77+
});
78+
79+
expect(startMock).toHaveBeenCalledTimes(2);
80+
expect(result.current).toBe(false);
81+
82+
act(() => {
83+
jest.advanceTimersByTime(5000);
84+
});
85+
86+
expect(onInactivityMock).toHaveBeenCalled();
87+
expect(result.current).toBe(true);
88+
});
89+
90+
test('환경에 맞게 이벤트 리스너가 추가/제거된다.', () => {
91+
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
92+
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
93+
94+
const { unmount } = renderHook(() =>
95+
useDetectInactivity(5000, onInactivityMock)
96+
);
97+
98+
const expectedClientEvents = isTouchDevice()
99+
? ['touchstart']
100+
: ['mousemove', 'keydown', 'click', 'dblclick', 'scroll'];
101+
102+
const addedEvents = addEventListenerSpy.mock.calls.map(
103+
([event, callback]) => ({ event, callback })
104+
);
105+
106+
expectedClientEvents.forEach((event) => {
107+
expect(addedEvents.some((e) => e.event === event)).toBe(true);
108+
});
109+
110+
act(() => {
111+
unmount();
112+
});
113+
114+
const removedEvents = removeEventListenerSpy.mock.calls.map(
115+
([event, callback]) => ({ event, callback })
116+
);
117+
118+
expectedClientEvents.forEach((event) => {
119+
expect(removedEvents.some((e) => e.event === event)).toBe(true);
120+
});
121+
122+
addEventListenerSpy.mockRestore();
123+
removeEventListenerSpy.mockRestore();
124+
});
125+
});

src/hooks/useDetectInactivity.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useCallback, useEffect, useState } from 'react';
2+
import useTimer from './useTimer';
3+
import { Fn } from '../types';
4+
import { isTouchDevice, throttle } from '../utils';
5+
6+
/**
7+
* 일정 시간(ms) 동안 활동이 없을 때 지정된 콜백 함수를 실행하는 훅.
8+
*
9+
* @param {number} time - 비활성 상태로 간주되기까지의 시간(밀리초). 양의 정수로 지정. (최소값 5000ms)
10+
* @param {Fn} onInactivity - 비활성 상태가 감지되었을 때 호출되는 콜백 함수.
11+
*
12+
* @returns {boolean} - 현재 비활동 상태 여부를 나타내는 boolean 값.
13+
*
14+
* @description
15+
* 사용자가 정의한 시간(time) 동안 활동이 없으면 비활성 상태로 간주하고, 지정된 콜백 함수(onInactivity)를 호출합니다.
16+
* 해당 환경에 맞게 설정된 이벤트를 5초마다 리스닝하여, 활동이 감지될 시 타이머를 리셋합니다.
17+
*/
18+
19+
const useDetectInactivity = (time: number, onInactivity: Fn) => {
20+
const [isInactive, setIsInactive] = useState(false);
21+
const { start } = useTimer(() => setIsInactive(true), time);
22+
23+
// 이벤트 리스너는 5초마다 감지
24+
const MIN_THROTTLE_TIME = 5000;
25+
26+
if (time < MIN_THROTTLE_TIME) {
27+
throw new Error(
28+
`'time'은 최소 ${MIN_THROTTLE_TIME}ms 이상으로 설정되어야 합니다.`
29+
);
30+
}
31+
32+
const clientEvents = isTouchDevice()
33+
? ['touchstart']
34+
: ['mousemove', 'keydown', 'click', 'dblclick', 'scroll'];
35+
36+
const resetTimer = useCallback(() => {
37+
setIsInactive(false);
38+
start();
39+
}, [start]);
40+
41+
useEffect(() => {
42+
start();
43+
44+
const throttledResetTimer = throttle(resetTimer, MIN_THROTTLE_TIME);
45+
46+
clientEvents.forEach((event) => {
47+
window.addEventListener(event, throttledResetTimer);
48+
});
49+
50+
return () => {
51+
clientEvents.forEach((event) =>
52+
window.removeEventListener(event, throttledResetTimer)
53+
);
54+
};
55+
// eslint-disable-next-line react-hooks/exhaustive-deps
56+
}, [resetTimer]);
57+
58+
useEffect(() => {
59+
if (isInactive) {
60+
onInactivity();
61+
}
62+
}, [isInactive, onInactivity]);
63+
64+
return isInactive;
65+
};
66+
67+
export default useDetectInactivity;

src/hooks/useMousePosition.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useRef,
77
useState,
88
} from 'react';
9+
import { throttle } from '../utils';
910

1011
interface MousePosOptions {
1112
delayTime?: number;
@@ -160,21 +161,6 @@ const useMousePosition = ({
160161

161162
export default useMousePosition;
162163

163-
const throttle = <T extends Event>(
164-
callbackFn: (event: T) => void,
165-
delayTime: number
166-
) => {
167-
let lastTime = 0;
168-
169-
return (event: T) => {
170-
const now = Date.now();
171-
if (now - lastTime >= delayTime) {
172-
lastTime = now;
173-
callbackFn(event);
174-
}
175-
};
176-
};
177-
178164
const animationFrameHandler = <T extends Event>(
179165
callbackFn: (event: T) => void
180166
) => {

src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export type Fn = () => void;
2+
3+
export type GenericFn<T extends unknown[]> = (...args: T) => void;

src/utils/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
export { delayExecution, type CancelToken } from './delayExecution';
2+
export { throttle } from './throttle';
3+
export { isClient } from './isClient';
4+
export { isTouchDevice } from './isTouchDevice';

src/utils/isClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const isClient = typeof window !== 'undefined';

src/utils/isTouchDevice.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { isClient } from './isClient';
2+
3+
export const isTouchDevice = () => {
4+
if (!isClient) {
5+
return false;
6+
}
7+
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
8+
};

src/utils/throttle.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { GenericFn } from '../types';
2+
3+
export const throttle = <T extends unknown[]>(
4+
callbackFn: GenericFn<T>,
5+
delayTime: number
6+
) => {
7+
let lastTime = 0;
8+
9+
const throttledFunction = (...args: T) => {
10+
const now = Date.now();
11+
if (now - lastTime >= delayTime) {
12+
lastTime = now;
13+
callbackFn(...args);
14+
}
15+
};
16+
17+
return throttledFunction;
18+
};

0 commit comments

Comments
 (0)