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.
- package/.github/workflows/staging-staging.yml +148 -0
- package/.github/workflows/staging.yml +1 -2
- package/CHANGELOG.md +19 -0
- package/config/rspack/rspack.config.js +5 -1
- package/config/vitest/__mocks__/icons.tsx +3 -0
- package/config/vitest/__mocks__/intersection-observer.ts +10 -0
- package/config/vitest/__mocks__/sparkie.tsx +9 -9
- package/config/vitest/__mocks__/use-init-sparkie.tsx +14 -0
- package/config/vitest/vitest.config.mts +13 -8
- package/environments/.env.test +2 -0
- package/eslint.config.mjs +27 -0
- package/package.json +8 -4
- package/public/index.html +3 -4
- package/src/@types/index.d.ts +5 -2
- package/src/config/styles/global.css +2 -2
- package/src/config/tanstack/query-client.ts +3 -2
- package/src/config/tests/utils.tsx +3 -2
- package/src/config/tests/wrappers.tsx +4 -1
- package/src/development-bootstrap.tsx +15 -15
- package/src/index.tsx +37 -5
- package/src/lib/components/icons/ai-color.svg +17 -0
- package/src/lib/components/icons/arrow-down.svg +5 -0
- package/src/lib/components/icons/chevron-down.svg +4 -0
- package/src/lib/components/icons/icon-names.d.ts +1 -1
- package/src/lib/components/markdownrenderer/markdownrenderer.tsx +7 -9
- package/src/lib/hooks/index.ts +3 -0
- package/src/lib/hooks/use-intersection-observer-reverse-scroll/index.ts +2 -0
- package/src/lib/hooks/use-intersection-observer-reverse-scroll/use-intersection-observer-reverse-scroll.tsx +147 -0
- package/src/lib/hooks/use-ref-client-height/index.ts +2 -0
- package/src/lib/hooks/use-ref-client-height/use-ref-client-height.tsx +38 -0
- package/src/lib/hooks/use-scroll-to-ref/index.ts +2 -0
- package/src/lib/hooks/use-scroll-to-ref/use-scroll-to-ref.tsx +14 -0
- package/src/lib/hooks/use-throttle/index.ts +3 -0
- package/src/lib/hooks/use-throttle/types.ts +13 -0
- package/src/lib/hooks/use-throttle/use-throttle.spec.tsx +296 -0
- package/src/lib/hooks/use-throttle/use-throttle.tsx +91 -0
- package/src/lib/utils/is-text-empty.ts +3 -0
- package/src/main/main.spec.tsx +7 -6
- package/src/modules/cursor/__tests__/icursor-update.builder.ts +42 -0
- package/src/modules/cursor/hooks/index.ts +1 -0
- package/src/modules/cursor/hooks/use-update-cursor/index.ts +2 -0
- package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.spec.tsx +23 -0
- package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.ts +11 -0
- package/src/modules/cursor/index.ts +2 -0
- package/src/modules/cursor/service.ts +15 -0
- package/src/modules/cursor/types.ts +9 -0
- package/src/modules/global-providers/index.ts +1 -0
- package/src/modules/messages/__tests__/parsed-message.builder.ts +164 -0
- package/src/modules/messages/components/chat-input/chat-input.spec.tsx +72 -0
- package/src/modules/messages/components/chat-input/chat-input.tsx +52 -6
- package/src/modules/messages/components/index.ts +1 -0
- package/src/modules/messages/components/message-item/message-item.spec.tsx +2 -2
- package/src/modules/messages/components/message-item/message-item.tsx +14 -1
- package/src/modules/messages/components/message-item-end-of-scroll/index.ts +2 -0
- package/src/modules/messages/components/message-item-end-of-scroll/message-item-end-of-scroll.tsx +14 -0
- package/src/modules/messages/components/message-item-error/index.ts +2 -0
- package/src/modules/messages/components/message-item-error/message-item-error.tsx +25 -0
- package/src/modules/messages/components/message-item-loading/index.ts +2 -0
- package/src/modules/messages/components/message-item-loading/message-item-loading.tsx +16 -0
- package/src/modules/messages/components/message-skeleton/index.ts +1 -0
- package/src/modules/messages/components/message-skeleton/message-skeleton.tsx +23 -0
- package/src/modules/messages/components/messages-list/index.ts +1 -1
- package/src/modules/messages/components/messages-list/messages-list.tsx +82 -39
- package/src/modules/messages/constants.ts +1 -0
- package/src/modules/messages/hooks/index.ts +5 -0
- package/src/modules/messages/hooks/use-all-messages/index.ts +2 -0
- package/src/modules/messages/hooks/use-all-messages/use-all-messages.tsx +30 -0
- package/src/modules/messages/hooks/use-infinite-get-messages/index.ts +2 -0
- package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +65 -0
- package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +81 -0
- package/src/modules/messages/hooks/use-manage-scroll/index.ts +2 -0
- package/src/modules/messages/hooks/use-manage-scroll/use-manage-scroll.tsx +70 -0
- package/src/modules/messages/hooks/use-send-text-message/index.ts +2 -0
- package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.spec.tsx +86 -0
- package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +60 -0
- package/src/modules/messages/hooks/use-skeleton-ref/index.ts +2 -0
- package/src/modules/messages/hooks/use-skeleton-ref/use-skeleton-ref.tsx +34 -0
- package/src/modules/messages/hooks/use-subscribe-message-received-event/index.ts +2 -0
- package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +80 -0
- package/src/modules/messages/service.ts +8 -7
- package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.spec.tsx +58 -0
- package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.ts +30 -0
- package/src/modules/messages/utils/has-to-update-cursor/index.ts +2 -0
- package/src/modules/sparkie/__tests__/sparkie.mock.ts +33 -0
- package/src/modules/sparkie/service.ts +182 -35
- package/src/modules/sparkie/types.ts +10 -2
- package/src/modules/widget/__tests__/widget-settings-props.builder.ts +29 -1
- package/src/modules/widget/components/ai-avatar/ai-avatar.tsx +11 -53
- package/src/modules/widget/components/chat-page/chat-page.spec.tsx +28 -0
- package/src/modules/widget/components/chat-page/chat-page.tsx +23 -4
- package/src/modules/widget/components/container/container.tsx +5 -19
- package/src/modules/widget/components/index.ts +1 -0
- package/src/modules/widget/components/scroll-to-bottom-button/index.ts +2 -0
- package/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button.tsx +32 -0
- package/src/modules/widget/events.ts +4 -0
- package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +8 -6
- package/src/modules/widget/store/index.ts +3 -0
- package/src/modules/widget/store/widget-container-intrinsic-height.atom.ts +13 -0
- package/src/modules/widget/store/widget-loading.atom.ts +11 -0
- package/src/modules/widget/store/widget-scrolling.atom.ts +11 -0
- package/src/modules/widget/store/widget-tabs.atom.ts +2 -1
- 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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
scroller.scrollTo({
|
|
38
|
+
top: scroller.scrollHeight,
|
|
39
|
+
behavior: 'smooth'
|
|
40
|
+
})
|
|
41
|
+
}, [])
|
|
24
42
|
|
|
25
43
|
return (
|
|
26
|
-
<div
|
|
27
|
-
{
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
}
|
|
@@ -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
|
package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx
ADDED
|
@@ -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,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,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,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
|