app-tutor-ai-consumer 1.5.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/CHANGELOG.md +6 -0
- package/config/vitest/__mocks__/sparkie.tsx +9 -0
- package/eslint.config.mjs +27 -0
- package/package.json +6 -2
- package/src/@types/index.d.ts +5 -2
- package/src/config/tanstack/query-client.ts +1 -1
- package/src/development-bootstrap.tsx +15 -15
- package/src/index.tsx +15 -5
- package/src/lib/components/icons/ai-color.svg +17 -0
- package/src/lib/components/icons/icon-names.d.ts +1 -1
- package/src/lib/utils/is-text-empty.ts +3 -0
- package/src/main/main.spec.tsx +0 -8
- 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-skeleton/index.ts +1 -0
- package/src/modules/messages/components/message-skeleton/message-skeleton.tsx +23 -0
- package/src/modules/messages/components/messages-list/messages-list.tsx +14 -1
- package/src/modules/messages/hooks/index.ts +2 -0
- package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +7 -0
- package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +7 -23
- package/src/modules/messages/hooks/use-manage-scroll/use-manage-scroll.tsx +5 -1
- 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/sparkie/service.ts +182 -35
- package/src/modules/sparkie/types.ts +10 -2
- package/src/modules/widget/__tests__/widget-settings-props.builder.ts +23 -1
- package/src/modules/widget/components/ai-avatar/ai-avatar.tsx +11 -53
- package/src/modules/widget/components/chat-page/chat-page.tsx +22 -1
- package/src/modules/widget/components/container/container.tsx +4 -24
- package/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button.tsx +1 -1
- package/src/modules/widget/store/index.ts +2 -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
|
@@ -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
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react'
|
|
2
|
+
import type { InfiniteData } from '@tanstack/react-query'
|
|
3
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { produce } from 'immer'
|
|
5
|
+
|
|
6
|
+
import { useGetProfile } from '@/src/modules/profile'
|
|
7
|
+
import { SparkieService } from '@/src/modules/sparkie'
|
|
8
|
+
import { useWidgetLoadingAtom, useWidgetSettingsAtom } from '@/src/modules/widget'
|
|
9
|
+
import type { FetchMessagesResponse, IMessageWithSenderData } from '../../types'
|
|
10
|
+
import { getMessagesInfiniteQuery } from '../use-infinite-get-messages'
|
|
11
|
+
|
|
12
|
+
const useSubscribeMessageReceivedEvent = () => {
|
|
13
|
+
const [settings] = useWidgetSettingsAtom()
|
|
14
|
+
const profileQuery = useGetProfile()
|
|
15
|
+
const queryClient = useQueryClient()
|
|
16
|
+
const [, setWidgetLoading] = useWidgetLoadingAtom()
|
|
17
|
+
|
|
18
|
+
const conversationId = useMemo(() => String(settings?.conversationId), [settings?.conversationId])
|
|
19
|
+
const profileId = useMemo(() => String(profileQuery?.data?.id), [profileQuery?.data?.id])
|
|
20
|
+
|
|
21
|
+
const query = getMessagesInfiniteQuery({
|
|
22
|
+
conversationId,
|
|
23
|
+
profileId,
|
|
24
|
+
enabled: true
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const messageReceived = (data: IMessageWithSenderData) => {
|
|
29
|
+
const queryKey = query.queryKey
|
|
30
|
+
|
|
31
|
+
if (!queryKey || !queryClient) return
|
|
32
|
+
|
|
33
|
+
const previousData = queryClient.getQueryData<InfiniteData<FetchMessagesResponse>>(
|
|
34
|
+
query.queryKey
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if (!previousData) return
|
|
38
|
+
|
|
39
|
+
const pages = previousData?.pages
|
|
40
|
+
|
|
41
|
+
if (!(Number(pages?.length) > 0)) return
|
|
42
|
+
|
|
43
|
+
const currentMsgIds = new Set(pages.flatMap((page) => page.messages.map((msg) => msg.id)))
|
|
44
|
+
|
|
45
|
+
if (currentMsgIds.has(data.id)) return
|
|
46
|
+
|
|
47
|
+
queryClient.setQueryData<InfiniteData<FetchMessagesResponse>>(queryKey, (prev) => {
|
|
48
|
+
if (!prev) return prev
|
|
49
|
+
|
|
50
|
+
const result = produce(prev, (draft) => {
|
|
51
|
+
if (!(Number(draft?.pages?.at?.(-1)?.messages?.length) > 0)) return draft
|
|
52
|
+
|
|
53
|
+
draft.pages.at(-1)?.messages.push(data)
|
|
54
|
+
|
|
55
|
+
return draft
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const isMine = data.contactId === profileId
|
|
62
|
+
|
|
63
|
+
if (!isMine) {
|
|
64
|
+
setTimeout(() => setWidgetLoading(false), 100)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
SparkieService.subscribeEvents({
|
|
69
|
+
messageReceived
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
SparkieService.removeEventSubscription({
|
|
74
|
+
messageReceived
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}, [profileId, query.queryKey, queryClient, setWidgetLoading])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default useSubscribeMessageReceivedEvent
|
|
@@ -15,9 +15,9 @@ import type {
|
|
|
15
15
|
} from './types'
|
|
16
16
|
|
|
17
17
|
class MessagesService {
|
|
18
|
-
|
|
18
|
+
async getSparkieMessageService() {
|
|
19
19
|
try {
|
|
20
|
-
const messageService = SparkieService.
|
|
20
|
+
const messageService = await SparkieService.getMessageService()
|
|
21
21
|
|
|
22
22
|
if (!messageService) throw new Error()
|
|
23
23
|
|
|
@@ -35,7 +35,8 @@ class MessagesService {
|
|
|
35
35
|
conversationId,
|
|
36
36
|
before
|
|
37
37
|
}: IGetMessagesPayload): Promise<Array<IMessageWithSenderData>> {
|
|
38
|
-
const
|
|
38
|
+
const sparkieMessageService = await this.getSparkieMessageService()
|
|
39
|
+
const data = await sparkieMessageService.getAll(conversationId, {
|
|
39
40
|
before
|
|
40
41
|
})
|
|
41
42
|
|
|
@@ -66,7 +67,8 @@ class MessagesService {
|
|
|
66
67
|
conversationId,
|
|
67
68
|
metadata
|
|
68
69
|
}: ISendTextMessagePayload): Promise<Message> {
|
|
69
|
-
const
|
|
70
|
+
const sparkieMessageService = await this.getSparkieMessageService()
|
|
71
|
+
const data = await sparkieMessageService.post(conversationId, {
|
|
70
72
|
content,
|
|
71
73
|
metadata
|
|
72
74
|
})
|
|
@@ -75,9 +77,8 @@ class MessagesService {
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
async sendImageMessage({ content, conversationId }: ISendImageMessagePayload): Promise<Message> {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
})
|
|
80
|
+
const sparkieMessageService = await this.getSparkieMessageService()
|
|
81
|
+
const data = await sparkieMessageService.post(conversationId, { content })
|
|
81
82
|
|
|
82
83
|
return data
|
|
83
84
|
}
|
|
@@ -3,11 +3,20 @@ import Sparkie from '@hotmart/sparkie'
|
|
|
3
3
|
import { MSG_MAX_COUNT } from '../messages'
|
|
4
4
|
|
|
5
5
|
import { ACTION_EVENT_MAP, firebaseConfig } from './constants'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
InitializationState,
|
|
8
|
+
InitSparkiePayload,
|
|
9
|
+
RetryOptions,
|
|
10
|
+
SparkieActions,
|
|
11
|
+
TrackTypingPayload
|
|
12
|
+
} from './types'
|
|
7
13
|
import { validateFirebaseConfig } from './utils/validate-firebase-config'
|
|
8
14
|
|
|
9
15
|
class SparkieService {
|
|
10
16
|
private sparkie: Sparkie | null = null
|
|
17
|
+
private initializationState: InitializationState = 'idle'
|
|
18
|
+
private initializationPromise: Promise<boolean> | null = null
|
|
19
|
+
private lastInitToken: string | null = null
|
|
11
20
|
|
|
12
21
|
constructor() {
|
|
13
22
|
if (validateFirebaseConfig()) {
|
|
@@ -29,33 +38,166 @@ class SparkieService {
|
|
|
29
38
|
return this.sparkie
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
get isInitialized(): boolean {
|
|
42
|
+
return (
|
|
43
|
+
this.initializationState === 'initialized' &&
|
|
44
|
+
this.sparkie?.messageService?.getAll !== undefined
|
|
45
|
+
)
|
|
46
|
+
}
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
get initState(): InitializationState {
|
|
49
|
+
return this.initializationState
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get status() {
|
|
53
|
+
return {
|
|
54
|
+
state: this.initializationState,
|
|
55
|
+
isInitialized: this.isInitialized,
|
|
56
|
+
hasToken: !!this.lastInitToken,
|
|
57
|
+
hasMessageService: !!this.sparkie?.messageService?.getAll
|
|
58
|
+
}
|
|
59
|
+
}
|
|
38
60
|
|
|
39
|
-
|
|
61
|
+
async initSparkie({
|
|
62
|
+
token,
|
|
63
|
+
skipPresenceSetup = false,
|
|
64
|
+
retryOptions = {}
|
|
65
|
+
}: InitSparkiePayload & { retryOptions?: RetryOptions }): Promise<boolean> {
|
|
66
|
+
const { maxRetries = 3, backoffMultiplier = 2, retryDelay = 1000 } = retryOptions
|
|
67
|
+
|
|
68
|
+
if (this.initializationPromise && this.lastInitToken === token) {
|
|
69
|
+
return this.initializationPromise
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// if already initialized with a different token, reinitialize
|
|
73
|
+
if (this.isInitialized && this.lastInitToken !== token) {
|
|
74
|
+
await this.destroySparkie()
|
|
75
|
+
this.initializationState = 'idle'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// already initialized with the current token
|
|
79
|
+
if (this.isInitialized && this.lastInitToken === token) {
|
|
40
80
|
return true
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.lastInitToken = token
|
|
84
|
+
this.initializationState = 'initializing'
|
|
85
|
+
|
|
86
|
+
this.initializationPromise = this.performInitializationWithRetry({
|
|
87
|
+
token,
|
|
88
|
+
skipPresenceSetup,
|
|
89
|
+
maxRetries,
|
|
90
|
+
retryDelay,
|
|
91
|
+
backoffMultiplier
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const result = await this.initializationPromise
|
|
95
|
+
|
|
96
|
+
this.initializationPromise = null
|
|
97
|
+
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async performInitializationWithRetry({
|
|
102
|
+
token,
|
|
103
|
+
skipPresenceSetup,
|
|
104
|
+
maxRetries,
|
|
105
|
+
retryDelay,
|
|
106
|
+
backoffMultiplier
|
|
107
|
+
}: InitSparkiePayload & Required<RetryOptions>): Promise<boolean> {
|
|
108
|
+
let currentDelay = retryDelay
|
|
109
|
+
let attempt = 1
|
|
110
|
+
|
|
111
|
+
while (attempt <= maxRetries) {
|
|
112
|
+
try {
|
|
113
|
+
if (!token || !token.trim())
|
|
114
|
+
throw new Error('Invalid or missing token for Sparkie initialization')
|
|
115
|
+
|
|
116
|
+
const sparkie = this.sparkieInstance
|
|
117
|
+
const service = await sparkie.init(token, { skipPresenceSetup })
|
|
118
|
+
const isServiceReady = typeof service.messageService?.getAll === 'function'
|
|
119
|
+
|
|
120
|
+
if (!isServiceReady) throw new Error('MessageService not properly initialized after init')
|
|
121
|
+
|
|
122
|
+
this.initializationState = 'initialized'
|
|
123
|
+
|
|
124
|
+
return true
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error('Error initializing realtime engine', error)
|
|
127
|
+
|
|
128
|
+
if (error instanceof Error) {
|
|
129
|
+
console.error('Error details:', {
|
|
130
|
+
message: error.message,
|
|
131
|
+
stack: error.stack,
|
|
132
|
+
token: token ? 'provided' : 'missing',
|
|
133
|
+
skipPresenceSetup
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (attempt === maxRetries) {
|
|
138
|
+
this.initializationState = 'failed'
|
|
139
|
+
console.error(
|
|
140
|
+
`❌ Sparkie initialization attempts reached the maxRetries: $${maxRetries} amount`,
|
|
141
|
+
error
|
|
142
|
+
)
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, currentDelay))
|
|
147
|
+
currentDelay *= backoffMultiplier
|
|
148
|
+
} finally {
|
|
149
|
+
attempt += 1
|
|
51
150
|
}
|
|
151
|
+
}
|
|
52
152
|
|
|
53
|
-
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async ensureInitialized(): Promise<void> {
|
|
157
|
+
if (this.isInitialized) return
|
|
158
|
+
|
|
159
|
+
switch (this.initializationState) {
|
|
160
|
+
case 'failed':
|
|
161
|
+
case 'idle':
|
|
162
|
+
throw new Error('Sparkie initialization failed. Please call initSparkie() first.')
|
|
163
|
+
case 'initializing':
|
|
164
|
+
if (this.initializationPromise) {
|
|
165
|
+
const success = await this.initializationPromise
|
|
166
|
+
if (!success) throw new Error('Sparkie initialization failed')
|
|
167
|
+
}
|
|
54
168
|
}
|
|
55
169
|
}
|
|
56
170
|
|
|
57
|
-
|
|
171
|
+
async getMessageService() {
|
|
172
|
+
await this.ensureInitialized()
|
|
173
|
+
|
|
174
|
+
const messageService = this.sparkieInstance.messageService
|
|
175
|
+
|
|
176
|
+
if (!messageService) throw new Error('MessageService not available after initialization')
|
|
177
|
+
|
|
178
|
+
return messageService
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async updateToken(token: string, reinitialize = true): Promise<boolean> {
|
|
58
182
|
this.sparkieInstance.setAPIToken(token)
|
|
183
|
+
|
|
184
|
+
if (reinitialize && token !== this.lastInitToken) {
|
|
185
|
+
return this.initSparkie({ token })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
subscribeEvents(actions: SparkieActions): void {
|
|
192
|
+
const { messageReceived, threadClosed, threadJoined, contactTyping } = actions
|
|
193
|
+
|
|
194
|
+
if (messageReceived) this.sparkieInstance.on(ACTION_EVENT_MAP.messageReceived, messageReceived)
|
|
195
|
+
|
|
196
|
+
if (threadClosed) this.sparkieInstance.on(ACTION_EVENT_MAP.threadClosed, threadClosed)
|
|
197
|
+
|
|
198
|
+
if (threadJoined) this.sparkieInstance.on(ACTION_EVENT_MAP.threadJoined, threadJoined)
|
|
199
|
+
|
|
200
|
+
if (contactTyping) this.sparkieInstance.on(ACTION_EVENT_MAP.contactTyping, contactTyping)
|
|
59
201
|
}
|
|
60
202
|
|
|
61
203
|
removeEventSubscription(actions: SparkieActions): void {
|
|
@@ -67,27 +209,32 @@ class SparkieService {
|
|
|
67
209
|
})
|
|
68
210
|
}
|
|
69
211
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
contactTyping
|
|
75
|
-
}: SparkieActions): void {
|
|
76
|
-
if (messageReceived) this.sparkieInstance.on(ACTION_EVENT_MAP.messageReceived, messageReceived)
|
|
212
|
+
async trackTyping({ contactIds, conversationId }: TrackTypingPayload): Promise<void> {
|
|
213
|
+
await this.ensureInitialized()
|
|
214
|
+
this.sparkieInstance.listener?.trackTyping(conversationId, contactIds)
|
|
215
|
+
}
|
|
77
216
|
|
|
78
|
-
|
|
217
|
+
async reinitialize(): Promise<boolean> {
|
|
218
|
+
if (!this.lastInitToken) throw new Error('No token available for reinitialization')
|
|
79
219
|
|
|
80
|
-
|
|
220
|
+
await this.destroySparkie()
|
|
221
|
+
this.initializationState = 'idle'
|
|
81
222
|
|
|
82
|
-
|
|
223
|
+
return this.initSparkie({ token: this.lastInitToken })
|
|
83
224
|
}
|
|
84
225
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
226
|
+
async destroySparkie(): Promise<void> {
|
|
227
|
+
try {
|
|
228
|
+
if (this.sparkie && this.initializationState !== 'idle') {
|
|
229
|
+
await this.sparkieInstance.destroy({ skipSignOut: true })
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error('Error destroying Sparkie:', error)
|
|
233
|
+
} finally {
|
|
234
|
+
this.initializationState = 'idle'
|
|
235
|
+
this.initializationPromise = null
|
|
236
|
+
this.lastInitToken = null
|
|
237
|
+
}
|
|
91
238
|
}
|
|
92
239
|
}
|
|
93
240
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { IMessageWithSenderData } from '../messages'
|
|
2
2
|
|
|
3
3
|
export type InitSparkiePayload = {
|
|
4
4
|
token: string
|
|
@@ -7,6 +7,14 @@ export type InitSparkiePayload = {
|
|
|
7
7
|
includeSupportEvents?: boolean
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export type InitializationState = 'idle' | 'initializing' | 'initialized' | 'failed'
|
|
11
|
+
|
|
12
|
+
export interface RetryOptions {
|
|
13
|
+
maxRetries?: number
|
|
14
|
+
retryDelay?: number
|
|
15
|
+
backoffMultiplier?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
type ThreadMetadata = Record<string, unknown>
|
|
11
19
|
|
|
12
20
|
export type IContact = {
|
|
@@ -35,7 +43,7 @@ export type ITyping = {
|
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export type SparkieActions = {
|
|
38
|
-
messageReceived?: (data:
|
|
46
|
+
messageReceived?: (data: IMessageWithSenderData) => void
|
|
39
47
|
threadClosed?: (data: IThread) => void
|
|
40
48
|
threadJoined?: (data: IThread) => void
|
|
41
49
|
contactTyping?: (data: ITyping) => void
|
|
@@ -12,12 +12,15 @@ class WidgetSettingPropsBuilder implements WidgetSettingProps {
|
|
|
12
12
|
clubName?: string
|
|
13
13
|
contactId?: string
|
|
14
14
|
namespace?: string
|
|
15
|
-
sessionId
|
|
15
|
+
sessionId: string
|
|
16
16
|
membershipId?: string
|
|
17
17
|
membershipSlug?: string
|
|
18
18
|
userId?: string
|
|
19
19
|
tutorName?: string
|
|
20
20
|
user?: User
|
|
21
|
+
classHashId?: string
|
|
22
|
+
owner_id?: string
|
|
23
|
+
current_media_codes?: string
|
|
21
24
|
|
|
22
25
|
constructor() {
|
|
23
26
|
this.hotmartToken = chance.guid()
|
|
@@ -25,6 +28,7 @@ class WidgetSettingPropsBuilder implements WidgetSettingProps {
|
|
|
25
28
|
this.conversationId = chance.guid()
|
|
26
29
|
this.productId = 4234
|
|
27
30
|
this.productName = 'Berim CDs'
|
|
31
|
+
this.sessionId = chance.guid()
|
|
28
32
|
this.conversationId = chance.guid()
|
|
29
33
|
this.contactId = chance.guid()
|
|
30
34
|
this.clubName = 'Test Standard Club'
|
|
@@ -122,6 +126,24 @@ class WidgetSettingPropsBuilder implements WidgetSettingProps {
|
|
|
122
126
|
|
|
123
127
|
return this
|
|
124
128
|
}
|
|
129
|
+
|
|
130
|
+
withClassHashId(classHashId: typeof this.classHashId) {
|
|
131
|
+
this.classHashId = classHashId
|
|
132
|
+
|
|
133
|
+
return this
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
withOwner_id(owner_id: typeof this.owner_id) {
|
|
137
|
+
this.owner_id = owner_id
|
|
138
|
+
|
|
139
|
+
return this
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
withCurrent_media_codes(current_media_codes: typeof this.current_media_codes) {
|
|
143
|
+
this.current_media_codes = current_media_codes
|
|
144
|
+
|
|
145
|
+
return this
|
|
146
|
+
}
|
|
125
147
|
}
|
|
126
148
|
|
|
127
149
|
export default WidgetSettingPropsBuilder
|