app-tutor-ai-consumer 1.4.0 → 1.6.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 (102) hide show
  1. package/.github/workflows/staging-staging.yml +148 -0
  2. package/.github/workflows/staging.yml +1 -2
  3. package/CHANGELOG.md +19 -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 +9 -9
  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/eslint.config.mjs +27 -0
  12. package/package.json +8 -4
  13. package/public/index.html +3 -4
  14. package/src/@types/index.d.ts +5 -2
  15. package/src/config/styles/global.css +2 -2
  16. package/src/config/tanstack/query-client.ts +3 -2
  17. package/src/config/tests/utils.tsx +3 -2
  18. package/src/config/tests/wrappers.tsx +4 -1
  19. package/src/development-bootstrap.tsx +15 -15
  20. package/src/index.tsx +37 -5
  21. package/src/lib/components/icons/ai-color.svg +17 -0
  22. package/src/lib/components/icons/arrow-down.svg +5 -0
  23. package/src/lib/components/icons/chevron-down.svg +4 -0
  24. package/src/lib/components/icons/icon-names.d.ts +1 -1
  25. package/src/lib/components/markdownrenderer/markdownrenderer.tsx +7 -9
  26. package/src/lib/hooks/index.ts +3 -0
  27. package/src/lib/hooks/use-intersection-observer-reverse-scroll/index.ts +2 -0
  28. package/src/lib/hooks/use-intersection-observer-reverse-scroll/use-intersection-observer-reverse-scroll.tsx +147 -0
  29. package/src/lib/hooks/use-ref-client-height/index.ts +2 -0
  30. package/src/lib/hooks/use-ref-client-height/use-ref-client-height.tsx +38 -0
  31. package/src/lib/hooks/use-scroll-to-ref/index.ts +2 -0
  32. package/src/lib/hooks/use-scroll-to-ref/use-scroll-to-ref.tsx +14 -0
  33. package/src/lib/hooks/use-throttle/index.ts +3 -0
  34. package/src/lib/hooks/use-throttle/types.ts +13 -0
  35. package/src/lib/hooks/use-throttle/use-throttle.spec.tsx +296 -0
  36. package/src/lib/hooks/use-throttle/use-throttle.tsx +91 -0
  37. package/src/lib/utils/is-text-empty.ts +3 -0
  38. package/src/main/main.spec.tsx +7 -6
  39. package/src/modules/cursor/__tests__/icursor-update.builder.ts +42 -0
  40. package/src/modules/cursor/hooks/index.ts +1 -0
  41. package/src/modules/cursor/hooks/use-update-cursor/index.ts +2 -0
  42. package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.spec.tsx +23 -0
  43. package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.ts +11 -0
  44. package/src/modules/cursor/index.ts +2 -0
  45. package/src/modules/cursor/service.ts +15 -0
  46. package/src/modules/cursor/types.ts +9 -0
  47. package/src/modules/global-providers/index.ts +1 -0
  48. package/src/modules/messages/__tests__/parsed-message.builder.ts +164 -0
  49. package/src/modules/messages/components/chat-input/chat-input.spec.tsx +72 -0
  50. package/src/modules/messages/components/chat-input/chat-input.tsx +52 -6
  51. package/src/modules/messages/components/index.ts +1 -0
  52. package/src/modules/messages/components/message-item/message-item.spec.tsx +2 -2
  53. package/src/modules/messages/components/message-item/message-item.tsx +14 -1
  54. package/src/modules/messages/components/message-item-end-of-scroll/index.ts +2 -0
  55. package/src/modules/messages/components/message-item-end-of-scroll/message-item-end-of-scroll.tsx +14 -0
  56. package/src/modules/messages/components/message-item-error/index.ts +2 -0
  57. package/src/modules/messages/components/message-item-error/message-item-error.tsx +25 -0
  58. package/src/modules/messages/components/message-item-loading/index.ts +2 -0
  59. package/src/modules/messages/components/message-item-loading/message-item-loading.tsx +16 -0
  60. package/src/modules/messages/components/message-skeleton/index.ts +1 -0
  61. package/src/modules/messages/components/message-skeleton/message-skeleton.tsx +23 -0
  62. package/src/modules/messages/components/messages-list/index.ts +1 -1
  63. package/src/modules/messages/components/messages-list/messages-list.tsx +82 -39
  64. package/src/modules/messages/constants.ts +1 -0
  65. package/src/modules/messages/hooks/index.ts +5 -0
  66. package/src/modules/messages/hooks/use-all-messages/index.ts +2 -0
  67. package/src/modules/messages/hooks/use-all-messages/use-all-messages.tsx +30 -0
  68. package/src/modules/messages/hooks/use-infinite-get-messages/index.ts +2 -0
  69. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +65 -0
  70. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +81 -0
  71. package/src/modules/messages/hooks/use-manage-scroll/index.ts +2 -0
  72. package/src/modules/messages/hooks/use-manage-scroll/use-manage-scroll.tsx +70 -0
  73. package/src/modules/messages/hooks/use-send-text-message/index.ts +2 -0
  74. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.spec.tsx +86 -0
  75. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +60 -0
  76. package/src/modules/messages/hooks/use-skeleton-ref/index.ts +2 -0
  77. package/src/modules/messages/hooks/use-skeleton-ref/use-skeleton-ref.tsx +34 -0
  78. package/src/modules/messages/hooks/use-subscribe-message-received-event/index.ts +2 -0
  79. package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +80 -0
  80. package/src/modules/messages/service.ts +8 -7
  81. package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.spec.tsx +58 -0
  82. package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.ts +30 -0
  83. package/src/modules/messages/utils/has-to-update-cursor/index.ts +2 -0
  84. package/src/modules/sparkie/__tests__/sparkie.mock.ts +33 -0
  85. package/src/modules/sparkie/service.ts +182 -35
  86. package/src/modules/sparkie/types.ts +10 -2
  87. package/src/modules/widget/__tests__/widget-settings-props.builder.ts +29 -1
  88. package/src/modules/widget/components/ai-avatar/ai-avatar.tsx +11 -53
  89. package/src/modules/widget/components/chat-page/chat-page.spec.tsx +28 -0
  90. package/src/modules/widget/components/chat-page/chat-page.tsx +23 -4
  91. package/src/modules/widget/components/container/container.tsx +5 -19
  92. package/src/modules/widget/components/index.ts +1 -0
  93. package/src/modules/widget/components/scroll-to-bottom-button/index.ts +2 -0
  94. package/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button.tsx +32 -0
  95. package/src/modules/widget/events.ts +4 -0
  96. package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +8 -6
  97. package/src/modules/widget/store/index.ts +3 -0
  98. package/src/modules/widget/store/widget-container-intrinsic-height.atom.ts +13 -0
  99. package/src/modules/widget/store/widget-loading.atom.ts +11 -0
  100. package/src/modules/widget/store/widget-scrolling.atom.ts +11 -0
  101. package/src/modules/widget/store/widget-tabs.atom.ts +2 -1
  102. package/src/types.ts +4 -1
@@ -1,51 +1,94 @@
1
- import { useRef } from 'react'
1
+ import { lazy, useCallback, useRef } from 'react'
2
2
  import clsx from 'clsx'
3
3
 
4
- import { useGetProfile } from '@/src/modules/profile'
5
- import { useWidgetSettingsAtomValue } from '@/src/modules/widget'
6
- import { useFetchMessages } from '../../hooks'
4
+ import { useRefClientHeight } from '@/src/lib/hooks'
5
+ import { useWidgetLoadingAtomValue } from '@/src/modules/widget'
6
+ import { useAllMessages, useManageScroll } from '../../hooks'
7
+ import { useSkeletonRef } from '../../hooks/use-skeleton-ref'
7
8
  import { MessageItem } from '../message-item'
9
+ import { MessageSkeleton } from '../message-skeleton'
10
+
11
+ const MessageItemError = lazy(() => import('../message-item-error/message-item-error'))
12
+
13
+ const MessageItemLoading = lazy(() => import('../message-item-loading/message-item-loading'))
14
+
15
+ const MessageItemEndOfScroll = lazy(
16
+ () => import('../message-item-end-of-scroll/message-item-end-of-scroll')
17
+ )
18
+
19
+ const ScrollToBottomButton = lazy(
20
+ () => import('@/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button')
21
+ )
8
22
 
9
23
  function MessagesList() {
10
- const loadFirstPage = useRef(true)
11
- const currentMessages = useRef([])
12
- const settings = useWidgetSettingsAtomValue()
13
- const profileQuery = useGetProfile()
24
+ const scrollerRef = useRef<HTMLDivElement>(null)
25
+ const scrollerClientHeight = useRefClientHeight(scrollerRef)
26
+ const scrollToButtonRef = useRef<HTMLButtonElement>(null)
27
+ const { allMessages, messagesQuery } = useAllMessages()
28
+ const widgetIsLoading = useWidgetLoadingAtomValue()
29
+ const skeletonRef = useSkeletonRef()
30
+ const { showScrollButton } = useManageScroll(scrollerRef)
31
+
32
+ const scrollToBottom = useCallback(() => {
33
+ const { current: scroller } = scrollerRef
14
34
 
15
- const messagesQuery = useFetchMessages({
16
- conversationId: settings?.conversationId ?? '',
17
- currentMessages: currentMessages.current,
18
- loadFirstPage: loadFirstPage?.current ?? true,
19
- profileId: profileQuery.data?.id ?? '',
20
- enabled: Boolean(settings?.conversationId && profileQuery.data?.id)
21
- })
35
+ if (!scroller) return
22
36
 
23
- if (messagesQuery.isLoading) return <h3>Loading...</h3>
37
+ scroller.scrollTo({
38
+ top: scroller.scrollHeight,
39
+ behavior: 'smooth'
40
+ })
41
+ }, [])
24
42
 
25
43
  return (
26
- <div className='flex flex-1 flex-col justify-center gap-6'>
27
- {messagesQuery.data?.messages &&
28
- Array.from(messagesQuery.data.messages).map(([publishingDate, messages], i) => (
29
- <div key={i} className='flex flex-1 flex-col justify-center gap-6'>
30
- <span className='self-center rounded-full border border-neutral-700 bg-neutral-800 px-4 py-2 text-xs capitalize text-neutral-50'>
31
- {publishingDate}
32
- </span>
33
- {messages.map((msg, k) => (
34
- <div
35
- key={`${msg.id}-${k}`}
36
- className={clsx(
37
- 'max-w-[min(80%,52rem)] rounded-lg p-3 text-sm/normal text-neutral-0',
38
- {
39
- 'self-end bg-neutral-800': msg.from === 'me' || msg.metadata.author === 'user',
40
- 'bg-ai-chat-response':
41
- msg.from !== profileQuery?.data?.id || msg.metadata.author === 'ai'
42
- }
43
- )}>
44
- <MessageItem message={msg} />
45
- </div>
46
- ))}
47
- </div>
48
- ))}
44
+ <div
45
+ ref={scrollerRef}
46
+ className={clsx(
47
+ 'relative mx-2 flex flex-col gap-2 overflow-auto p-4',
48
+ `h-[${scrollerClientHeight}]`
49
+ )}>
50
+ <MessageItemLoading show={messagesQuery.isFetching} />
51
+
52
+ <MessageItemEndOfScroll
53
+ show={!messagesQuery.isFetching && !messagesQuery.hasNextPage && allMessages.length > 0}
54
+ />
55
+
56
+ <ScrollToBottomButton
57
+ ref={scrollToButtonRef}
58
+ top={Math.abs(
59
+ Number(scrollerRef.current?.clientHeight) -
60
+ Number(scrollToButtonRef.current?.clientHeight) -
61
+ 24
62
+ )}
63
+ show={showScrollButton}
64
+ onClick={scrollToBottom}
65
+ />
66
+
67
+ {allMessages?.map(([publishingDate, messages], i) => (
68
+ <div key={i} className='flex flex-1 flex-col justify-center gap-6'>
69
+ <span className='self-center rounded-full border border-neutral-700 bg-neutral-800 px-4 py-2 text-xs capitalize text-neutral-50'>
70
+ {publishingDate}
71
+ </span>
72
+ {messages.map((msg, k) => (
73
+ <MessageItem key={`${msg.id}-${k}`} message={msg} />
74
+ ))}
75
+ </div>
76
+ ))}
77
+
78
+ <MessageItemError
79
+ show={Boolean(messagesQuery.error)}
80
+ message={`❌ Error loading messages: ${messagesQuery.error?.message ?? ''}`}
81
+ retry={() => void messagesQuery.refetch()}
82
+ />
83
+
84
+ <div
85
+ className={clsx({
86
+ 'pointer-events-none h-0 overflow-hidden opacity-0': !widgetIsLoading,
87
+ 'pb-4': widgetIsLoading
88
+ })}
89
+ ref={skeletonRef}>
90
+ <MessageSkeleton />
91
+ </div>
49
92
  </div>
50
93
  )
51
94
  }
@@ -1 +1,2 @@
1
1
  export const MSG_MAX_COUNT = 20
2
+ export const MSG_MAX_PAGES = 20
@@ -1 +1,6 @@
1
+ export * from './use-all-messages'
1
2
  export * from './use-fetch-messages'
3
+ export * from './use-infinite-get-messages'
4
+ export * from './use-manage-scroll'
5
+ export * from './use-send-text-message'
6
+ export * from './use-subscribe-message-received-event'
@@ -0,0 +1,2 @@
1
+ export * from './use-all-messages'
2
+ export { default as useAllMessages } from './use-all-messages'
@@ -0,0 +1,30 @@
1
+ import { useMemo } from 'react'
2
+
3
+ import { useGetProfile } from '@/src/modules/profile'
4
+ import { useWidgetSettingsAtomValue } from '@/src/modules/widget'
5
+ import { useInfiniteGetMessages } from '../use-infinite-get-messages'
6
+
7
+ function useAllMessages() {
8
+ const settings = useWidgetSettingsAtomValue()
9
+ const profileQuery = useGetProfile()
10
+
11
+ const messagesQuery = useInfiniteGetMessages({
12
+ conversationId: settings?.conversationId ?? '',
13
+ profileId: profileQuery.data?.id ?? '',
14
+ enabled: Boolean(settings?.conversationId && profileQuery.data?.id)
15
+ })
16
+
17
+ const allMessages = useMemo(
18
+ () =>
19
+ messagesQuery.data && Number(messagesQuery.data?.size) > 0
20
+ ? Array.from(messagesQuery.data)
21
+ : [],
22
+ [messagesQuery.data]
23
+ )
24
+
25
+ const hasMessages = useMemo(() => Number(allMessages.length) > 0, [allMessages.length])
26
+
27
+ return { messagesQuery, allMessages, hasMessages }
28
+ }
29
+
30
+ export default useAllMessages
@@ -0,0 +1,2 @@
1
+ export * from './use-infinite-get-messages'
2
+ export { default as useInfiniteGetMessages } from './use-infinite-get-messages'
@@ -0,0 +1,65 @@
1
+ import type MessageService from '@hotmart/sparkie/dist/MessageService'
2
+
3
+ import { act, renderHook, waitFor } from '@/src/config/tests'
4
+ import { SparkieService } from '@/src/modules/sparkie'
5
+ import { SparkieMessageServiceMock } from '@/src/modules/sparkie/__tests__/sparkie.mock'
6
+ import IMessageWithSenderDataMock from '../../__tests__/imessage-with-sender-data.mock'
7
+ import type { IMessageWithSenderData } from '../../types'
8
+
9
+ import useInfiniteGetMessages from './use-infinite-get-messages'
10
+
11
+ describe('useInfiniteGetMessages', () => {
12
+ const mockConversationId = 'conversation-123'
13
+ const mockProfileId = 'profile-456'
14
+ const mockEnabled = true
15
+
16
+ beforeEach(() => {
17
+ vi.clearAllMocks()
18
+ vi.spyOn(SparkieService, 'getMessageService').mockResolvedValue(
19
+ SparkieMessageServiceMock as unknown as MessageService
20
+ )
21
+ SparkieMessageServiceMock.getAll.mockClear()
22
+ })
23
+
24
+ const createHook = (
25
+ props = {
26
+ conversationId: mockConversationId,
27
+ profileId: mockProfileId,
28
+ enabled: mockEnabled
29
+ }
30
+ ) => renderHook(() => useInfiniteGetMessages(props))
31
+
32
+ it('should return a map with the right size when fetch messages request is successful', async () => {
33
+ const { result } = createHook()
34
+
35
+ await waitFor(() => expect(result.current.data).toBeDefined())
36
+
37
+ expect(result.current.data?.size).toBe(10)
38
+ })
39
+
40
+ it('should be able to fetch next pages', async () => {
41
+ SparkieMessageServiceMock.getAll.mockRestore()
42
+ SparkieMessageServiceMock.getAll.mockReturnValueOnce(
43
+ new IMessageWithSenderDataMock().getMany(2) as IMessageWithSenderData[]
44
+ )
45
+
46
+ const { result } = createHook()
47
+
48
+ await waitFor(() => {
49
+ expect(result.current.isSuccess).toBe(true)
50
+ })
51
+
52
+ expect(result.current.data?.size).toBe(10)
53
+
54
+ act(() => {
55
+ void result.current.fetchNextPage()
56
+ })
57
+
58
+ await waitFor(() => {
59
+ expect(result.current.fetchStatus).toBe('idle')
60
+ })
61
+
62
+ expect(result.current.data?.size).toBe(2)
63
+ expect(result.current.hasNextPage).toBe(false)
64
+ })
65
+ })
@@ -0,0 +1,81 @@
1
+ import type { UndefinedInitialDataInfiniteOptions } from '@tanstack/react-query'
2
+ import { useInfiniteQuery } from '@tanstack/react-query'
3
+
4
+ import { formatTime } from '@/src/config/dayjs'
5
+ import { MessagesService } from '../..'
6
+ import { MSG_MAX_COUNT } from '../../constants'
7
+ import type { FetchMessagesResponse, ParsedMessage } from '../../types'
8
+ import { messagesParser } from '../../utils'
9
+ import type { UseFetchMessagesProps } from '../use-fetch-messages'
10
+
11
+ export type UseGetMessagesQueryProps = {
12
+ conversationId: string
13
+ profileId: string
14
+ enabled?: boolean
15
+ }
16
+
17
+ export const getMessagesInfiniteQuery = ({
18
+ conversationId,
19
+ profileId,
20
+ enabled
21
+ }: UseGetMessagesQueryProps) =>
22
+ ({
23
+ queryKey: ['sparkie:messageService:getAll', conversationId, profileId],
24
+ queryFn: async ({ pageParam }: { pageParam?: number }) => {
25
+ const messages = await MessagesService.getMessages({ conversationId, before: pageParam })
26
+
27
+ return {
28
+ messages,
29
+ hasMore: messages.length === MSG_MAX_COUNT
30
+ } as FetchMessagesResponse
31
+ },
32
+ initialPageParam: undefined,
33
+ getNextPageParam: (lastPage: FetchMessagesResponse) => {
34
+ if (!lastPage.hasMore) return undefined
35
+
36
+ const minSentAt = Math.min(...lastPage.messages.map((msg) => msg.sentAt))
37
+
38
+ return minSentAt
39
+ },
40
+ enabled,
41
+ select(data) {
42
+ const messages = data.pages?.flatMap((page) => page.messages) ?? []
43
+ return messagesParser({ messages, profileId }).reduce((messagesMap, currentMessage) => {
44
+ const timestamp = formatTime(currentMessage.timestamp, true)
45
+
46
+ if (!messagesMap.has(timestamp)) {
47
+ messagesMap.set(timestamp, [currentMessage])
48
+ return messagesMap
49
+ }
50
+
51
+ const existingTimestampValues = Array.from(messagesMap.get(timestamp) ?? [])
52
+
53
+ messagesMap.set(
54
+ timestamp,
55
+ [...existingTimestampValues, currentMessage].sort((a, b) => a.timestamp - b.timestamp)
56
+ )
57
+
58
+ return messagesMap
59
+ }, new Map<string, ParsedMessage[]>())
60
+ }
61
+ }) as UndefinedInitialDataInfiniteOptions<
62
+ FetchMessagesResponse,
63
+ Error,
64
+ Map<string, ParsedMessage[]>
65
+ >
66
+
67
+ function useInfiniteGetMessages({
68
+ conversationId,
69
+ profileId,
70
+ enabled = false
71
+ }: Omit<UseFetchMessagesProps, 'currentMessages' | 'loadFirstPage'>) {
72
+ const query = getMessagesInfiniteQuery({
73
+ conversationId,
74
+ profileId,
75
+ enabled
76
+ })
77
+
78
+ return useInfiniteQuery(query)
79
+ }
80
+
81
+ export default useInfiniteGetMessages
@@ -0,0 +1,2 @@
1
+ export * from './use-manage-scroll'
2
+ export { default as useManageScroll } from './use-manage-scroll'
@@ -0,0 +1,70 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import type { RefObject } from 'react'
3
+
4
+ import { useWidgetScrollingAtom } from '@/src/modules/widget'
5
+ import { useAllMessages } from '../use-all-messages'
6
+
7
+ const threshold = 250 // min scroll pos to show scroller button
8
+
9
+ function useManageScroll<T extends HTMLElement>(scrollerRef: RefObject<T | null>) {
10
+ const { messagesQuery, hasMessages } = useAllMessages()
11
+ const [heightBeforeRender, setHeightBeforeRender] = useState(0)
12
+ const [showScrollButton, setShowScrollButton] = useState(false)
13
+ const [, setWidgetScrolling] = useWidgetScrollingAtom()
14
+
15
+ const handleScroll = useCallback(() => {
16
+ const scroller = scrollerRef.current
17
+
18
+ if (!scroller) return
19
+
20
+ if (messagesQuery.hasNextPage && scroller && scroller.scrollTop < 50) {
21
+ setHeightBeforeRender(scroller.scrollHeight)
22
+ void messagesQuery.fetchNextPage()
23
+ }
24
+ }, [messagesQuery, scrollerRef])
25
+
26
+ useEffect(() => {
27
+ const scroller = scrollerRef.current
28
+
29
+ if (!scroller || !hasMessages || !messagesQuery.isSuccess) return
30
+
31
+ scroller.addEventListener('scrollend', handleScroll)
32
+
33
+ return () => {
34
+ scroller.removeEventListener('scrollend', handleScroll)
35
+ }
36
+ }, [handleScroll, hasMessages, messagesQuery.isSuccess, scrollerRef])
37
+
38
+ useEffect(() => {
39
+ const scroller = scrollerRef.current
40
+
41
+ if (!scroller || !hasMessages || !messagesQuery.isSuccess) return
42
+
43
+ const handleShowBtn = () => {
44
+ setWidgetScrolling(true)
45
+ const scrollPosition =
46
+ Number(scroller.scrollHeight) - Number(scroller.scrollTop) - Number(scroller.clientHeight)
47
+
48
+ setShowScrollButton(scrollPosition > threshold)
49
+ }
50
+
51
+ scroller.addEventListener('scroll', handleShowBtn)
52
+
53
+ return () => {
54
+ scroller.removeEventListener('scroll', handleShowBtn)
55
+ setWidgetScrolling(false)
56
+ }
57
+ }, [hasMessages, messagesQuery.isSuccess, scrollerRef, setWidgetScrolling])
58
+
59
+ useEffect(() => {
60
+ const scroller = scrollerRef.current
61
+
62
+ if (scroller && !messagesQuery.isFetching) {
63
+ scroller.scrollTop = scroller.scrollHeight - heightBeforeRender
64
+ }
65
+ }, [heightBeforeRender, messagesQuery.isFetching, scrollerRef])
66
+
67
+ return { showScrollButton }
68
+ }
69
+
70
+ export default useManageScroll
@@ -0,0 +1,2 @@
1
+ export * from './use-send-text-message'
2
+ export { default as useSendTextMessage } from './use-send-text-message'
@@ -0,0 +1,86 @@
1
+ import { act, chance, renderHook, waitFor } from '@/src/config/tests'
2
+ import { MessagesService } from '@/src/modules/messages'
3
+ import { useGetProfile } from '@/src/modules/profile'
4
+ import * as Store from '@/src/modules/widget'
5
+ import WidgetSettingPropsBuilder from '@/src/modules/widget/__tests__/widget-settings-props.builder'
6
+
7
+ import useSendTextMessage from './use-send-text-message'
8
+
9
+ vi.mock('@/src/modules/profile', () => ({
10
+ useGetProfile: vi.fn()
11
+ }))
12
+
13
+ describe('useSendTextMessage', () => {
14
+ const message = chance.animal()
15
+ const getProfileMock = { data: { userId: chance.integer() } }
16
+ const defaultSettings = new WidgetSettingPropsBuilder()
17
+ .withClassHashId(chance.guid())
18
+ .withOwner_id(chance.guid())
19
+ .withCurrent_media_codes(chance.profession())
20
+
21
+ const render = () => renderHook(useSendTextMessage)
22
+
23
+ beforeEach(() => {
24
+ vi.mocked(useGetProfile).mockReturnValue(getProfileMock as never)
25
+ vi.spyOn(Store, 'useWidgetSettingsAtomValue').mockReturnValue(defaultSettings)
26
+ })
27
+
28
+ it('should throw when conversationId is not defined', async () => {
29
+ vi.spyOn(Store, 'useWidgetSettingsAtomValue').mockReturnValueOnce({} as never)
30
+
31
+ const { result } = render()
32
+
33
+ await act(() =>
34
+ expect(result.current.mutateAsync(message)).rejects.toEqual(
35
+ new Error('Conversation Id must be defined')
36
+ )
37
+ )
38
+ })
39
+
40
+ it('should successfully send a text message', async () => {
41
+ const { result } = render()
42
+
43
+ await waitFor(() => result.current.mutateAsync(message))
44
+
45
+ expect(result.current.isSuccess).toBeTruthy()
46
+ })
47
+
48
+ it('should be properly able to handle question::<text> messages', async () => {
49
+ const txt = 'question::summary'
50
+ const text = txt.replace('question::', '')
51
+
52
+ vi.spyOn(MessagesService, 'sendTextMessage')
53
+
54
+ const { result } = render()
55
+
56
+ await waitFor(() => result.current.mutateAsync(txt))
57
+
58
+ expect(MessagesService.sendTextMessage).toHaveBeenCalledTimes(1)
59
+ expect(MessagesService.sendTextMessage).toHaveBeenNthCalledWith(1, {
60
+ content: {
61
+ type: 'text/plain',
62
+ text
63
+ },
64
+ conversationId: defaultSettings.conversationId,
65
+ metadata: {
66
+ author: 'user',
67
+ contactId: defaultSettings.contactId,
68
+ context: {
69
+ language: defaultSettings.locale,
70
+ clubName: defaultSettings.clubName,
71
+ productName: defaultSettings.productName,
72
+ productId: defaultSettings.productId,
73
+ classHashId: defaultSettings.classHashId,
74
+ owner_id: defaultSettings.owner_id,
75
+ current_media_codes: defaultSettings.current_media_codes,
76
+ question: text,
77
+ router: text
78
+ },
79
+ externalId: expect.any(String),
80
+ namespace: defaultSettings.namespace,
81
+ sessionId: defaultSettings.sessionId,
82
+ userId: String(getProfileMock.data.userId)
83
+ }
84
+ })
85
+ })
86
+ })
@@ -0,0 +1,60 @@
1
+ import { useMutation } from '@tanstack/react-query'
2
+ import { v4 } from 'uuid'
3
+
4
+ import { MessagesService } from '@/src/modules/messages'
5
+ import { useGetProfile } from '@/src/modules/profile'
6
+ import { useWidgetLoadingAtom, useWidgetSettingsAtomValue } from '@/src/modules/widget'
7
+
8
+ function useSendTextMessage() {
9
+ const settings = useWidgetSettingsAtomValue()
10
+ const profileQuery = useGetProfile()
11
+ const [, setWidgetLoading] = useWidgetLoadingAtom()
12
+
13
+ return useMutation({
14
+ mutationFn(message: string) {
15
+ let processedMessage = message
16
+
17
+ if (!settings?.conversationId) throw new Error('Conversation Id must be defined')
18
+
19
+ const questionRegex = /^question::\s*/i
20
+ let questionParam: string | undefined
21
+
22
+ if (questionRegex.test(message)) {
23
+ processedMessage = message.replace(questionRegex, '').trim()
24
+ questionParam = processedMessage
25
+ }
26
+
27
+ return MessagesService.sendTextMessage({
28
+ content: {
29
+ type: 'text/plain',
30
+ text: processedMessage
31
+ },
32
+ conversationId: settings?.conversationId,
33
+ metadata: {
34
+ author: 'user',
35
+ contactId: settings.contactId,
36
+ context: {
37
+ language: settings.locale,
38
+ clubName: settings.clubName,
39
+ productName: settings.productName,
40
+ productId: settings.productId,
41
+ classHashId: settings.classHashId,
42
+ owner_id: settings?.owner_id,
43
+ current_media_codes: settings?.current_media_codes,
44
+ question: questionParam,
45
+ router: questionParam ? 'summary' : undefined
46
+ },
47
+ externalId: v4(),
48
+ namespace: settings.namespace,
49
+ sessionId: settings.sessionId,
50
+ userId: profileQuery.data?.userId?.toString()
51
+ }
52
+ })
53
+ },
54
+ onMutate: () => {
55
+ setWidgetLoading(true)
56
+ }
57
+ })
58
+ }
59
+
60
+ export default useSendTextMessage
@@ -0,0 +1,2 @@
1
+ export * from './use-skeleton-ref'
2
+ export { default as useSkeletonRef } from './use-skeleton-ref'
@@ -0,0 +1,34 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import { useWidgetLoadingAtomValue } from '@/src/modules/widget'
4
+
5
+ export function useSkeletonRef() {
6
+ const skeletonRef = useRef<HTMLDivElement>(null)
7
+ const isLoading = useWidgetLoadingAtomValue()
8
+
9
+ useEffect(() => {
10
+ const skeleton = skeletonRef.current
11
+
12
+ if (!skeleton) return
13
+
14
+ const observer = new IntersectionObserver(
15
+ ([entry]) => {
16
+ if (!isLoading || !entry || entry.isIntersecting) return
17
+
18
+ entry.target.scrollIntoView({ behavior: 'smooth', block: 'end' })
19
+ },
20
+ { threshold: 1 }
21
+ )
22
+
23
+ const timeout = setTimeout(() => observer.observe(skeleton), 50)
24
+
25
+ return () => {
26
+ clearTimeout(timeout)
27
+ observer.disconnect()
28
+ }
29
+ }, [isLoading])
30
+
31
+ return skeletonRef
32
+ }
33
+
34
+ export default useSkeletonRef
@@ -0,0 +1,2 @@
1
+ export * from './use-subscribe-message-received-event'
2
+ export { default as useSubscribeMessageReceivedEvent } from './use-subscribe-message-received-event'