app-tutor-ai-consumer 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.github/workflows/staging-staging.yml +148 -0
  2. package/.github/workflows/staging.yml +1 -2
  3. package/CHANGELOG.md +13 -0
  4. package/config/rspack/rspack.config.js +5 -1
  5. package/config/vitest/__mocks__/icons.tsx +3 -0
  6. package/config/vitest/__mocks__/intersection-observer.ts +10 -0
  7. package/config/vitest/__mocks__/sparkie.tsx +2 -11
  8. package/config/vitest/__mocks__/use-init-sparkie.tsx +14 -0
  9. package/config/vitest/vitest.config.mts +13 -8
  10. package/environments/.env.test +2 -0
  11. package/package.json +3 -3
  12. package/public/index.html +3 -4
  13. package/src/config/styles/global.css +2 -2
  14. package/src/config/tanstack/query-client.ts +2 -1
  15. package/src/config/tests/utils.tsx +3 -2
  16. package/src/config/tests/wrappers.tsx +4 -1
  17. package/src/index.tsx +22 -0
  18. package/src/lib/components/icons/arrow-down.svg +5 -0
  19. package/src/lib/components/icons/chevron-down.svg +4 -0
  20. package/src/lib/components/icons/icon-names.d.ts +1 -1
  21. package/src/lib/components/markdownrenderer/markdownrenderer.tsx +7 -9
  22. package/src/lib/hooks/index.ts +3 -0
  23. package/src/lib/hooks/use-intersection-observer-reverse-scroll/index.ts +2 -0
  24. package/src/lib/hooks/use-intersection-observer-reverse-scroll/use-intersection-observer-reverse-scroll.tsx +147 -0
  25. package/src/lib/hooks/use-ref-client-height/index.ts +2 -0
  26. package/src/lib/hooks/use-ref-client-height/use-ref-client-height.tsx +38 -0
  27. package/src/lib/hooks/use-scroll-to-ref/index.ts +2 -0
  28. package/src/lib/hooks/use-scroll-to-ref/use-scroll-to-ref.tsx +14 -0
  29. package/src/lib/hooks/use-throttle/index.ts +3 -0
  30. package/src/lib/hooks/use-throttle/types.ts +13 -0
  31. package/src/lib/hooks/use-throttle/use-throttle.spec.tsx +296 -0
  32. package/src/lib/hooks/use-throttle/use-throttle.tsx +91 -0
  33. package/src/main/main.spec.tsx +9 -0
  34. package/src/modules/cursor/__tests__/icursor-update.builder.ts +42 -0
  35. package/src/modules/cursor/hooks/index.ts +1 -0
  36. package/src/modules/cursor/hooks/use-update-cursor/index.ts +2 -0
  37. package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.spec.tsx +23 -0
  38. package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.ts +11 -0
  39. package/src/modules/cursor/index.ts +2 -0
  40. package/src/modules/cursor/service.ts +15 -0
  41. package/src/modules/cursor/types.ts +9 -0
  42. package/src/modules/global-providers/index.ts +1 -0
  43. package/src/modules/messages/__tests__/parsed-message.builder.ts +164 -0
  44. package/src/modules/messages/components/message-item/message-item.spec.tsx +2 -2
  45. package/src/modules/messages/components/message-item/message-item.tsx +14 -1
  46. package/src/modules/messages/components/message-item-end-of-scroll/index.ts +2 -0
  47. package/src/modules/messages/components/message-item-end-of-scroll/message-item-end-of-scroll.tsx +14 -0
  48. package/src/modules/messages/components/message-item-error/index.ts +2 -0
  49. package/src/modules/messages/components/message-item-error/message-item-error.tsx +25 -0
  50. package/src/modules/messages/components/message-item-loading/index.ts +2 -0
  51. package/src/modules/messages/components/message-item-loading/message-item-loading.tsx +16 -0
  52. package/src/modules/messages/components/messages-list/index.ts +1 -1
  53. package/src/modules/messages/components/messages-list/messages-list.tsx +69 -39
  54. package/src/modules/messages/constants.ts +1 -0
  55. package/src/modules/messages/hooks/index.ts +3 -0
  56. package/src/modules/messages/hooks/use-all-messages/index.ts +2 -0
  57. package/src/modules/messages/hooks/use-all-messages/use-all-messages.tsx +30 -0
  58. package/src/modules/messages/hooks/use-infinite-get-messages/index.ts +2 -0
  59. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +58 -0
  60. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +97 -0
  61. package/src/modules/messages/hooks/use-manage-scroll/index.ts +2 -0
  62. package/src/modules/messages/hooks/use-manage-scroll/use-manage-scroll.tsx +66 -0
  63. package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.spec.tsx +58 -0
  64. package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.ts +30 -0
  65. package/src/modules/messages/utils/has-to-update-cursor/index.ts +2 -0
  66. package/src/modules/sparkie/__tests__/sparkie.mock.ts +33 -0
  67. package/src/modules/widget/__tests__/widget-settings-props.builder.ts +6 -0
  68. package/src/modules/widget/components/chat-page/chat-page.spec.tsx +28 -0
  69. package/src/modules/widget/components/chat-page/chat-page.tsx +1 -3
  70. package/src/modules/widget/components/container/container.tsx +20 -14
  71. package/src/modules/widget/components/index.ts +1 -0
  72. package/src/modules/widget/components/scroll-to-bottom-button/index.ts +2 -0
  73. package/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button.tsx +32 -0
  74. package/src/modules/widget/events.ts +4 -0
  75. package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +8 -6
  76. package/src/modules/widget/store/index.ts +1 -0
  77. package/src/modules/widget/store/widget-container-intrinsic-height.atom.ts +13 -0
@@ -0,0 +1,147 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ import { useThrottle } from '../use-throttle'
4
+
5
+ export type UseIntersectionObserverReverseScrollProps = {
6
+ hasMore: boolean
7
+ asyncCallback: () => Promise<void>
8
+ onScroll?: () => void
9
+ isLoading?: boolean
10
+ threshold?: number
11
+ rootMargin?: string
12
+ enabled?: boolean
13
+ }
14
+
15
+ function useIntersectionObserverReverseScroll({
16
+ hasMore,
17
+ asyncCallback,
18
+ onScroll,
19
+ enabled = true,
20
+ isLoading = false,
21
+ rootMargin = '100px 0px',
22
+ threshold = 0.5
23
+ }: UseIntersectionObserverReverseScrollProps) {
24
+ const [heightBeforeRender, setHeightBeforeRender] = useState(0)
25
+ const [shouldRestorePosition, setShouldRestorePosition] = useState(false)
26
+ const [hasUserScrolled, setHasUserScrolled] = useState(false)
27
+ const [isInitialLoad, setIsInitialLoad] = useState(true)
28
+
29
+ const scrollerRef = useRef<HTMLDivElement>(null)
30
+ const intersectionTargetRef = useRef<HTMLDivElement>(null)
31
+ const observerRef = useRef<IntersectionObserver | null>(null)
32
+
33
+ const { throttledCallback: throttledHandleScroll } = useThrottle({
34
+ callback: () => {
35
+ onScroll?.()
36
+ },
37
+ delay: 50
38
+ })
39
+
40
+ const handleScroll = useCallback(() => {
41
+ const { current: scroller } = scrollerRef
42
+
43
+ if (!scroller) return
44
+
45
+ if (!hasUserScrolled) setHasUserScrolled(true)
46
+
47
+ if (!isInitialLoad) setIsInitialLoad(true)
48
+
49
+ throttledHandleScroll()
50
+ }, [hasUserScrolled, isInitialLoad, throttledHandleScroll])
51
+
52
+ const handleIntersection = useCallback(
53
+ async (entries: IntersectionObserverEntry[]) => {
54
+ const [entry] = entries
55
+ const { current: scroller } = scrollerRef
56
+
57
+ if (!enabled || !scroller || !isInitialLoad || !hasUserScrolled || isLoading || !hasMore)
58
+ return
59
+
60
+ if (entry?.isIntersecting) {
61
+ setHeightBeforeRender(scroller.scrollHeight)
62
+ setShouldRestorePosition(true)
63
+ await asyncCallback()
64
+ }
65
+ },
66
+ [enabled, hasMore, hasUserScrolled, isInitialLoad, isLoading, asyncCallback]
67
+ )
68
+
69
+ const scrollToBottom = useCallback((smooth = false) => {
70
+ const { current: scroller } = scrollerRef
71
+
72
+ if (!scroller) return
73
+
74
+ scroller.scrollTo({
75
+ top: scroller.scrollHeight,
76
+ behavior: smooth ? 'smooth' : 'auto'
77
+ })
78
+ }, [])
79
+
80
+ const resetScrollState = useCallback(() => {
81
+ setHasUserScrolled(false)
82
+ setIsInitialLoad(true)
83
+ setShouldRestorePosition(false)
84
+ setHeightBeforeRender(0)
85
+ }, [])
86
+
87
+ useEffect(() => {
88
+ const { current: scroller } = scrollerRef
89
+ const { current: intersectionTarget } = intersectionTargetRef
90
+
91
+ if (!intersectionTarget || !scroller) return
92
+
93
+ observerRef.current = new IntersectionObserver((entries) => void handleIntersection(entries), {
94
+ root: scroller,
95
+ rootMargin,
96
+ threshold
97
+ })
98
+
99
+ observerRef.current.observe(intersectionTarget)
100
+
101
+ return () => {
102
+ observerRef.current?.disconnect()
103
+ }
104
+ }, [handleIntersection, rootMargin, threshold])
105
+
106
+ useEffect(() => {
107
+ const { current: scroller } = scrollerRef
108
+ const controller = new AbortController()
109
+
110
+ if (!scroller) return
111
+
112
+ scroller.addEventListener('scroll', handleScroll, { passive: true, signal: controller.signal })
113
+
114
+ return () => {
115
+ controller.abort()
116
+ }
117
+ }, [handleScroll])
118
+
119
+ useEffect(() => {
120
+ const { current: scroller } = scrollerRef
121
+
122
+ if (!shouldRestorePosition || isLoading || !scroller) return
123
+
124
+ const frameId = requestAnimationFrame(() => {
125
+ const newScrollTop = scroller.scrollHeight - heightBeforeRender
126
+
127
+ scroller.scrollTop = newScrollTop
128
+ setShouldRestorePosition(false)
129
+ setHeightBeforeRender(0)
130
+ })
131
+
132
+ return () => {
133
+ cancelAnimationFrame(frameId)
134
+ }
135
+ }, [heightBeforeRender, isLoading, shouldRestorePosition])
136
+
137
+ return {
138
+ scrollerRef,
139
+ intersectionTargetRef,
140
+ scrollToBottom,
141
+ resetScrollState,
142
+ hasUserScrolled,
143
+ isInitialLoad
144
+ }
145
+ }
146
+
147
+ export default useIntersectionObserverReverseScroll
@@ -0,0 +1,2 @@
1
+ export * from './use-ref-client-height'
2
+ export { default as useRefClientHeight } from './use-ref-client-height'
@@ -0,0 +1,38 @@
1
+ import { type RefObject, useEffect, useState } from 'react'
2
+
3
+ import { useThrottle } from '@/src/lib/hooks'
4
+
5
+ function useRefClientHeight<T extends HTMLElement>(
6
+ refElement: RefObject<T | null>,
7
+ defaultHeight = '100vh'
8
+ ) {
9
+ const [clientHeight, setClientHeight] = useState(defaultHeight)
10
+
11
+ const { throttledCallback: resizeHandler } = useThrottle({
12
+ callback: () => {
13
+ if (!refElement?.current?.clientHeight) return
14
+
15
+ setClientHeight(`${refElement?.current.clientHeight}px`)
16
+ },
17
+ delay: 650
18
+ })
19
+
20
+ useEffect(resizeHandler, [resizeHandler])
21
+
22
+ useEffect(() => {
23
+ if (!refElement?.current?.clientHeight) return
24
+
25
+ const abortController = new AbortController()
26
+ const signal = abortController.signal
27
+
28
+ window.addEventListener('resize', resizeHandler, { signal })
29
+
30
+ return () => {
31
+ abortController.abort()
32
+ }
33
+ }, [refElement, resizeHandler])
34
+
35
+ return clientHeight
36
+ }
37
+
38
+ export default useRefClientHeight
@@ -0,0 +1,2 @@
1
+ export * from './use-scroll-to-ref'
2
+ export { default as useScrollToRef } from './use-scroll-to-ref'
@@ -0,0 +1,14 @@
1
+ import { useCallback, useRef } from 'react'
2
+
3
+ function useScrollToRef<R extends HTMLElement>() {
4
+ const ref = useRef<R>(null)
5
+
6
+ const scrollToRef = useCallback(() => {
7
+ const { current: element } = ref
8
+ element?.scrollIntoView({ behavior: 'smooth', block: 'end' })
9
+ }, [])
10
+
11
+ return { scrollTargetRef: ref, scrollToRef }
12
+ }
13
+
14
+ export default useScrollToRef
@@ -0,0 +1,3 @@
1
+ export * from './types'
2
+ export * from './use-throttle'
3
+ export { default as useThrottle } from './use-throttle'
@@ -0,0 +1,13 @@
1
+ export type UseThrottleProps<T extends (...args: unknown[]) => unknown> = {
2
+ callback: T
3
+ delay: number
4
+ }
5
+
6
+ export interface ThrottleControls {
7
+ cancel: () => void
8
+ pending: () => boolean
9
+ }
10
+
11
+ export type ThrottledFunction<T extends (...args: unknown[]) => unknown> = {
12
+ throttledCallback: T
13
+ } & ThrottleControls
@@ -0,0 +1,296 @@
1
+ import type { Mock } from 'vitest'
2
+
3
+ import { act, renderHook } from '@/src/config/tests'
4
+
5
+ import type { UseThrottleProps } from './types'
6
+ import useThrottle from './use-throttle'
7
+
8
+ describe('useThrottle', () => {
9
+ const mockCallback = vi.fn()
10
+ const mockDelay = 1000
11
+
12
+ const createHook = (
13
+ { callback, delay }: UseThrottleProps<ReturnType<typeof vi.fn>> = {
14
+ callback: mockCallback,
15
+ delay: mockDelay
16
+ }
17
+ ) =>
18
+ renderHook(() =>
19
+ useThrottle({
20
+ callback,
21
+ delay
22
+ })
23
+ )
24
+
25
+ beforeEach(() => {
26
+ vi.useFakeTimers()
27
+ })
28
+
29
+ afterEach(() => {
30
+ vi.runOnlyPendingTimers()
31
+ vi.useRealTimers()
32
+ vi.clearAllMocks()
33
+ })
34
+
35
+ it('should execute callback immediately on first call', () => {
36
+ const { result } = createHook()
37
+
38
+ result.current.throttledCallback('test')
39
+
40
+ expect(mockCallback).toHaveBeenCalledTimes(1)
41
+ expect(mockCallback).toHaveBeenCalledWith('test')
42
+ })
43
+
44
+ it('should throttle subsequent calls within delay period', () => {
45
+ const { result } = createHook()
46
+
47
+ result.current.throttledCallback('call1')
48
+ result.current.throttledCallback('call2')
49
+ result.current.throttledCallback('call3')
50
+
51
+ expect(mockCallback).toHaveBeenCalledTimes(1)
52
+ expect(mockCallback).toHaveBeenCalledWith('call1')
53
+
54
+ act(() => {
55
+ vi.advanceTimersByTime(1000)
56
+ })
57
+
58
+ expect(mockCallback).toHaveBeenCalledTimes(2)
59
+ expect(mockCallback).toHaveBeenLastCalledWith('call3')
60
+ })
61
+
62
+ it('should allow execution after delay period has passed', () => {
63
+ const { result } = createHook()
64
+
65
+ result.current.throttledCallback('call1')
66
+
67
+ act(() => {
68
+ vi.advanceTimersByTime(1000)
69
+ })
70
+
71
+ result.current.throttledCallback('call2')
72
+
73
+ expect(mockCallback).toHaveBeenCalledTimes(2)
74
+ expect(mockCallback).toHaveBeenNthCalledWith(1, 'call1')
75
+ expect(mockCallback).toHaveBeenNthCalledWith(2, 'call2')
76
+ })
77
+
78
+ it('should handle multiple arguments correctly', () => {
79
+ const multiArgCallback: Mock<(a: string, b: number, c: boolean) => void> = vi.fn()
80
+
81
+ const { result } = createHook({ callback: multiArgCallback, delay: mockDelay })
82
+
83
+ result.current.throttledCallback('hello', 42, true)
84
+
85
+ expect(multiArgCallback).toHaveBeenCalledWith('hello', 42, true)
86
+ })
87
+
88
+ describe('cancel()', () => {
89
+ it('should cancel pending execution', () => {
90
+ const { result } = createHook()
91
+
92
+ act(() => {
93
+ result.current.throttledCallback('call1')
94
+ })
95
+
96
+ act(() => {
97
+ result.current.throttledCallback('call2')
98
+ })
99
+
100
+ expect(mockCallback).toHaveBeenCalledTimes(1)
101
+ expect(result.current.pending()).toBe(true)
102
+
103
+ act(() => {
104
+ result.current.cancel()
105
+ })
106
+
107
+ expect(result.current.pending()).toBe(false)
108
+
109
+ act(() => {
110
+ vi.advanceTimersByTime(1000)
111
+ })
112
+
113
+ expect(mockCallback).toHaveBeenCalledTimes(1)
114
+ })
115
+
116
+ it('should be safe to call cancel when no execution is pending', () => {
117
+ const { result } = createHook()
118
+
119
+ expect(() => {
120
+ act(() => {
121
+ result.current.cancel()
122
+ })
123
+ }).not.toThrow()
124
+
125
+ expect(result.current.pending()).toBe(false)
126
+ })
127
+ })
128
+
129
+ describe('pending()', () => {
130
+ it('should return false when no execution is pending', () => {
131
+ const { result } = createHook()
132
+
133
+ expect(result.current.pending()).toBe(false)
134
+ })
135
+
136
+ it('should return true when execution is pending', () => {
137
+ const { result } = createHook()
138
+
139
+ result.current.throttledCallback('call1')
140
+
141
+ expect(result.current.pending()).toBe(false)
142
+
143
+ result.current.throttledCallback('call2')
144
+
145
+ expect(result.current.pending()).toBe(true)
146
+
147
+ act(() => {
148
+ vi.advanceTimersByTime(1000)
149
+ })
150
+
151
+ expect(result.current.pending()).toBe(false)
152
+ })
153
+ })
154
+
155
+ it('should use the latest callback reference', () => {
156
+ let callbackVersion = 1
157
+ const initialCallback = vi.fn(() => `version-${callbackVersion}`)
158
+
159
+ const { result, rerender } = renderHook(
160
+ ({ callback }) => useThrottle({ callback, delay: 1000 }),
161
+ { initialProps: { callback: initialCallback } }
162
+ )
163
+
164
+ result.current.throttledCallback()
165
+
166
+ expect(initialCallback).toHaveBeenCalledTimes(1)
167
+
168
+ callbackVersion = 2
169
+ const updatedCallback = vi.fn(() => `version-${callbackVersion}`)
170
+
171
+ rerender({ callback: updatedCallback })
172
+
173
+ result.current.throttledCallback()
174
+
175
+ act(() => {
176
+ vi.advanceTimersByTime(1000)
177
+ })
178
+
179
+ expect(updatedCallback).toHaveBeenCalledTimes(1)
180
+ expect(initialCallback).toHaveBeenCalledTimes(1)
181
+ })
182
+
183
+ it('should handle callback changes during pending execution', () => {
184
+ const callback1 = vi.fn()
185
+ const callback2 = vi.fn()
186
+
187
+ const { result, rerender } = renderHook(
188
+ ({ callback }) => useThrottle({ callback, delay: 1000 }),
189
+ { initialProps: { callback: callback1 } }
190
+ )
191
+
192
+ result.current.throttledCallback('immediate')
193
+ result.current.throttledCallback('throttled')
194
+
195
+ expect(callback1).toHaveBeenCalledTimes(1)
196
+ expect(result.current.pending()).toBe(true)
197
+
198
+ rerender({ callback: callback2 })
199
+
200
+ act(() => {
201
+ vi.advanceTimersByTime(1000)
202
+ })
203
+
204
+ expect(callback1).toHaveBeenCalledTimes(1)
205
+ expect(callback2).toHaveBeenCalledTimes(1)
206
+ expect(callback2).toHaveBeenCalledWith('throttled')
207
+ })
208
+
209
+ it('should handle zero delay', () => {
210
+ const { result } = createHook({ callback: mockCallback, delay: 0 })
211
+
212
+ result.current.throttledCallback('call1')
213
+ result.current.throttledCallback('call2')
214
+ result.current.throttledCallback('call3')
215
+
216
+ expect(mockCallback).toHaveBeenCalledTimes(3)
217
+ })
218
+
219
+ it('should handle rapid successive calls correctly', () => {
220
+ const { result } = createHook()
221
+
222
+ for (let i = 0; i < 100; i++) {
223
+ result.current.throttledCallback(`call-${i}`)
224
+ }
225
+
226
+ expect(mockCallback).toHaveBeenCalledTimes(1)
227
+ expect(mockCallback).toHaveBeenCalledWith('call-0')
228
+
229
+ act(() => {
230
+ vi.advanceTimersByTime(1000)
231
+ })
232
+
233
+ expect(mockCallback).toHaveBeenCalledTimes(2)
234
+ expect(mockCallback).toHaveBeenLastCalledWith('call-99')
235
+ })
236
+
237
+ it('should cleanup timeout on unmount', () => {
238
+ const { result, unmount } = createHook()
239
+
240
+ result.current.throttledCallback('call1')
241
+ result.current.throttledCallback('call2')
242
+
243
+ expect(result.current.pending()).toBe(true)
244
+
245
+ unmount()
246
+
247
+ act(() => {
248
+ vi.advanceTimersByTime(1000)
249
+ })
250
+
251
+ expect(mockCallback).toHaveBeenCalledTimes(1)
252
+ })
253
+
254
+ it('should not cause memory leaks with multiple calls', () => {
255
+ const { result } = createHook({
256
+ callback: mockCallback,
257
+ delay: 100
258
+ })
259
+
260
+ for (let i = 0; i < 1000; i++) {
261
+ result.current.throttledCallback(`call-${i}`)
262
+
263
+ if (i % 10 === 0) {
264
+ result.current.cancel()
265
+ }
266
+ }
267
+
268
+ expect(() => {
269
+ act(() => {
270
+ vi.advanceTimersByTime(1000)
271
+ })
272
+ }).not.toThrow()
273
+ })
274
+
275
+ it('should maintain stable reference when dependencies do not change', () => {
276
+ const { result, rerender } = createHook()
277
+
278
+ const firstReference = result.current
279
+
280
+ rerender()
281
+
282
+ expect(result.current).toBe(firstReference)
283
+ })
284
+
285
+ it('should maintain stable control methods', () => {
286
+ const { result, rerender } = createHook()
287
+
288
+ const initialCancel = result.current.cancel
289
+ const initialPending = result.current.pending
290
+
291
+ rerender()
292
+
293
+ expect(result.current.cancel).toBe(initialCancel)
294
+ expect(result.current.pending).toBe(initialPending)
295
+ })
296
+ })
@@ -0,0 +1,91 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
2
+
3
+ import type { ThrottledFunction, UseThrottleProps } from './types'
4
+
5
+ /**
6
+ * Creates a throttled version of the provided function that only executes at most once per specified delay.
7
+ *
8
+ * @template T - The function type to throttle
9
+ * @param params - Configuration object
10
+ * @param params.callback - The function to throttle
11
+ * @param params.delay - Minimum time between executions in milliseconds
12
+ * @returns Throttled function with control methods (cancel, pending)
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const throttledSearch = useThrottle({
17
+ * callback: (query: string) => searchAPI(query),
18
+ * delay: 300
19
+ * })
20
+ *
21
+ * // Use it
22
+ * throttledSearch('hello')
23
+ *
24
+ * // Check if pending
25
+ * if (throttledSearch.pending()) {
26
+ * console.log('Search is pending...')
27
+ * }
28
+ *
29
+ * // Cancel if needed
30
+ * throttledSearch.cancel()
31
+ * ```
32
+ */
33
+ function useThrottle<T extends (...args: any[]) => any>({
34
+ callback,
35
+ delay
36
+ }: UseThrottleProps<T>): ThrottledFunction<T> {
37
+ const lastExecTime = useRef<number>(0)
38
+ const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
39
+ const callbackRef = useRef(callback)
40
+
41
+ const cancel = useCallback(() => {
42
+ if (timeoutRef.current) {
43
+ clearTimeout(timeoutRef.current)
44
+ timeoutRef.current = undefined
45
+ }
46
+ }, [])
47
+
48
+ const pending = useCallback(() => {
49
+ return timeoutRef.current !== undefined
50
+ }, [])
51
+
52
+ const throttledCallback = useCallback(
53
+ (...args: Parameters<T>) => {
54
+ const now = Date.now()
55
+ const timeSinceLastExec = now - lastExecTime.current
56
+
57
+ cancel()
58
+
59
+ if (timeSinceLastExec >= delay) {
60
+ lastExecTime.current = now
61
+ callbackRef.current(...args)
62
+ return
63
+ }
64
+
65
+ timeoutRef.current = setTimeout(() => {
66
+ lastExecTime.current = Date.now()
67
+ callbackRef.current(...args)
68
+ timeoutRef.current = undefined
69
+ }, delay - timeSinceLastExec)
70
+ },
71
+ [cancel, delay]
72
+ )
73
+
74
+ useEffect(() => {
75
+ callbackRef.current = callback
76
+ }, [callback])
77
+
78
+ useEffect(() => {
79
+ return () => cancel()
80
+ }, [cancel])
81
+
82
+ return useMemo(() => {
83
+ return {
84
+ throttledCallback,
85
+ cancel,
86
+ pending
87
+ } as ThrottledFunction<T>
88
+ }, [cancel, pending, throttledCallback])
89
+ }
90
+
91
+ export default useThrottle
@@ -1,11 +1,18 @@
1
1
  import { chance, render, screen, waitFor } from '@/config/tests'
2
2
  import WidgetSettingPropsBuilder from '../modules/widget/__tests__/widget-settings-props.builder'
3
+ import { useWidgetSettingsAtom } from '../modules/widget/store/widget-settings.atom'
3
4
  import { Main } from '.'
4
5
 
6
+ vi.mock('../modules/widget/store/widget-settings.atom', () => ({ useWidgetSettingsAtom: vi.fn() }))
7
+
5
8
  describe('Main', () => {
6
9
  const defaultProps = new WidgetSettingPropsBuilder()
7
10
  const renderComponent = (props = { settings: defaultProps }) => render(<Main {...props} />)
8
11
 
12
+ beforeEach(() => {
13
+ vi.mocked(useWidgetSettingsAtom).mockReturnValue([null, vi.fn()])
14
+ })
15
+
9
16
  it('should render empty element when settings.tutorName is not defined', async () => {
10
17
  const { container } = renderComponent()
11
18
 
@@ -16,6 +23,8 @@ describe('Main', () => {
16
23
 
17
24
  it('should render without errors', async () => {
18
25
  const props = new WidgetSettingPropsBuilder().withTutorName(chance.name())
26
+ vi.mocked(useWidgetSettingsAtom).mockReturnValue([props, vi.fn()])
27
+
19
28
  renderComponent({ settings: props })
20
29
 
21
30
  await waitFor(() => {
@@ -0,0 +1,42 @@
1
+ import { chance } from '@/src/config/tests'
2
+ import type { ICursorUpdate } from '../types'
3
+
4
+ class ICursorUpdateBuilder implements ICursorUpdate {
5
+ threadId: string
6
+ contactId: string
7
+ cursor: number
8
+ conversationId?: string
9
+
10
+ constructor() {
11
+ this.threadId = chance.guid()
12
+ this.contactId = chance.guid()
13
+ this.cursor = chance.integer()
14
+ this.conversationId = chance.guid()
15
+ }
16
+
17
+ withThreadId(threadId: typeof this.threadId) {
18
+ this.threadId = threadId
19
+
20
+ return this
21
+ }
22
+
23
+ withContactId(contactId: typeof this.contactId) {
24
+ this.contactId = contactId
25
+
26
+ return this
27
+ }
28
+
29
+ withCursor(cursor: typeof this.cursor) {
30
+ this.cursor = cursor
31
+
32
+ return this
33
+ }
34
+
35
+ withConversationId(conversationId: typeof this.conversationId) {
36
+ this.conversationId = conversationId
37
+
38
+ return this
39
+ }
40
+ }
41
+
42
+ export default ICursorUpdateBuilder
@@ -0,0 +1 @@
1
+ export * from './use-update-cursor'
@@ -0,0 +1,2 @@
1
+ export * from './use-update-cursor'
2
+ export { default as useUpdateCursor } from './use-update-cursor'