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
|
@@ -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
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { chance } from '@/src/config/tests'
|
|
2
|
+
import ParsedMessageBuilder from '../../__tests__/parsed-message.builder'
|
|
3
|
+
import type { ParsedMessage } from '../../types'
|
|
4
|
+
|
|
5
|
+
import hasToUpdateCursor from './has-to-update-cursor'
|
|
6
|
+
|
|
7
|
+
describe('hasToUpdateCursor', () => {
|
|
8
|
+
const messages = [new ParsedMessageBuilder()]
|
|
9
|
+
const currentCursor = chance.timestamp()
|
|
10
|
+
const profileId = messages?.at?.(0)?.contact.id ?? ''
|
|
11
|
+
|
|
12
|
+
it('should return false when given a empty profileId', () => {
|
|
13
|
+
expect(hasToUpdateCursor({ messages, currentCursor, profileId: '' })).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should return false when given a empty messages list', () => {
|
|
17
|
+
expect(hasToUpdateCursor({ messages: [], currentCursor, profileId })).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should return false when the given messages list last item contact.id is equals to `hotmartContactId`', () => {
|
|
21
|
+
expect(
|
|
22
|
+
hasToUpdateCursor({
|
|
23
|
+
messages: [new ParsedMessageBuilder().withContact({ id: 'hotmartContactId' })],
|
|
24
|
+
currentCursor,
|
|
25
|
+
profileId
|
|
26
|
+
})
|
|
27
|
+
).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should return false when the given messages list last item is mine', () => {
|
|
31
|
+
expect(
|
|
32
|
+
hasToUpdateCursor({
|
|
33
|
+
messages: [
|
|
34
|
+
new ParsedMessageBuilder().withContact({ id: profileId }).withMetadata({
|
|
35
|
+
...((messages.at(0)?.metadata ?? {}) as ParsedMessage['metadata']),
|
|
36
|
+
author: 'user'
|
|
37
|
+
})
|
|
38
|
+
],
|
|
39
|
+
currentCursor,
|
|
40
|
+
profileId
|
|
41
|
+
})
|
|
42
|
+
).toBe(false)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should return false when current cursor timestamp is greater than the last message item timestamp', () => {
|
|
46
|
+
expect(
|
|
47
|
+
hasToUpdateCursor({
|
|
48
|
+
messages: [new ParsedMessageBuilder().withTimestamp(currentCursor - 1)],
|
|
49
|
+
currentCursor,
|
|
50
|
+
profileId
|
|
51
|
+
})
|
|
52
|
+
).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should return true when it has to update the cursor', () => {
|
|
56
|
+
expect(hasToUpdateCursor({ messages, currentCursor, profileId })).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ParsedMessage } from '../../types'
|
|
2
|
+
|
|
3
|
+
export type HasToUpdateCursorProps = {
|
|
4
|
+
messages: Array<ParsedMessage>
|
|
5
|
+
currentCursor: number
|
|
6
|
+
profileId: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hasToUpdateCursor({
|
|
10
|
+
currentCursor,
|
|
11
|
+
messages,
|
|
12
|
+
profileId
|
|
13
|
+
}: HasToUpdateCursorProps): boolean {
|
|
14
|
+
if (!profileId || !messages?.length) return false
|
|
15
|
+
|
|
16
|
+
const lastMessage = messages
|
|
17
|
+
.slice()
|
|
18
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
19
|
+
?.at(0)
|
|
20
|
+
|
|
21
|
+
if (!lastMessage || lastMessage.contact.id === 'hotmartContactId') return false
|
|
22
|
+
|
|
23
|
+
const isMine = lastMessage.metadata.author === 'user' && lastMessage.contact.id === profileId
|
|
24
|
+
|
|
25
|
+
if (isMine || currentCursor > lastMessage.timestamp) return false
|
|
26
|
+
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default hasToUpdateCursor
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import ICursorUpdateBuilder from '../../cursor/__tests__/icursor-update.builder'
|
|
2
|
+
import type { IMessageWithSenderData } from '../../messages'
|
|
3
|
+
import IMessageWithSenderDataMock from '../../messages/__tests__/imessage-with-sender-data.mock'
|
|
4
|
+
|
|
5
|
+
export const SparkieMessageServiceMock = {
|
|
6
|
+
getAll: vi
|
|
7
|
+
.fn()
|
|
8
|
+
.mockReturnValue(new IMessageWithSenderDataMock().getMany(10) as IMessageWithSenderData[]),
|
|
9
|
+
post: vi.fn(),
|
|
10
|
+
postDirect: vi.fn(),
|
|
11
|
+
remove: vi.fn()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const SparkieCursorServiceMock = {
|
|
15
|
+
update: vi.fn(() => new ICursorUpdateBuilder())
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SparkieDefaultMethodsMock = {
|
|
19
|
+
destroy: vi.fn(),
|
|
20
|
+
init: vi.fn(),
|
|
21
|
+
off: vi.fn(),
|
|
22
|
+
on: vi.fn(),
|
|
23
|
+
listener: { trackTyping: vi.fn() },
|
|
24
|
+
setAPIToken: vi.fn()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SparkieMock = vi.fn(() => ({
|
|
28
|
+
...SparkieDefaultMethodsMock,
|
|
29
|
+
messageService: SparkieMessageServiceMock,
|
|
30
|
+
cursorService: SparkieCursorServiceMock
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
export default SparkieMock
|
|
@@ -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,13 @@ 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()
|
|
32
|
+
this.conversationId = chance.guid()
|
|
33
|
+
this.contactId = chance.guid()
|
|
34
|
+
this.clubName = 'Test Standard Club'
|
|
35
|
+
this.membershipId = 'test-standard-club'
|
|
36
|
+
this.tutorName = 'Professor Test'
|
|
37
|
+
this.userId = chance.guid()
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
withHotmartToken(hotmartToken: typeof this.hotmartToken) {
|
|
@@ -116,6 +126,24 @@ class WidgetSettingPropsBuilder implements WidgetSettingProps {
|
|
|
116
126
|
|
|
117
127
|
return this
|
|
118
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
|
+
}
|
|
119
147
|
}
|
|
120
148
|
|
|
121
149
|
export default WidgetSettingPropsBuilder
|
|
@@ -1,57 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
import clsx from 'clsx'
|
|
2
|
+
|
|
3
|
+
import { Icon } from '@/src/lib/components'
|
|
4
|
+
|
|
5
|
+
function AIAvatarIcon({
|
|
6
|
+
className = 'rounded-full border-4 border-neutral-900 bg-neutral-800'
|
|
7
|
+
}: {
|
|
8
|
+
className?: string
|
|
9
|
+
}) {
|
|
2
10
|
return (
|
|
3
|
-
<div className='flex h-11 w-11 items-center justify-center
|
|
4
|
-
<
|
|
5
|
-
width='25'
|
|
6
|
-
height='24'
|
|
7
|
-
viewBox='0 0 25 24'
|
|
8
|
-
fill='none'
|
|
9
|
-
xmlns='http://www.w3.org/2000/svg'>
|
|
10
|
-
<path
|
|
11
|
-
d='M8.21062 3.77518L11.05 9.08404L16.3739 11.9154L11.05 14.7469L8.21062 20.0557L5.37122 14.7469L0.0473633 11.9154L5.37122 9.08404L8.21062 3.77518Z'
|
|
12
|
-
fill='url(#paint0_linear_18316_181)'
|
|
13
|
-
/>
|
|
14
|
-
<path
|
|
15
|
-
d='M17.8806 0.590271L19.2848 3.21586L21.9178 4.61617L19.2848 6.01648L17.8806 8.64206L16.4762 6.01648L13.8433 4.61617L16.4762 3.21586L17.8806 0.590271Z'
|
|
16
|
-
fill='url(#paint1_linear_18316_181)'
|
|
17
|
-
/>
|
|
18
|
-
<path
|
|
19
|
-
d='M20.7199 16.7845L18.9453 13.4665L17.1707 16.7845L13.8433 18.5541L17.1707 20.3237L18.9453 23.6418L20.7199 20.3237L24.0473 18.5541L20.7199 16.7845Z'
|
|
20
|
-
fill='url(#paint2_linear_18316_181)'
|
|
21
|
-
/>
|
|
22
|
-
<defs>
|
|
23
|
-
<linearGradient
|
|
24
|
-
id='paint0_linear_18316_181'
|
|
25
|
-
x1='0.0473633'
|
|
26
|
-
y1='11.9154'
|
|
27
|
-
x2='16.3739'
|
|
28
|
-
y2='11.9154'
|
|
29
|
-
gradientUnits='userSpaceOnUse'>
|
|
30
|
-
<stop stopColor='#44D0FF' />
|
|
31
|
-
<stop offset='1' stopColor='#B48EFF' />
|
|
32
|
-
</linearGradient>
|
|
33
|
-
<linearGradient
|
|
34
|
-
id='paint1_linear_18316_181'
|
|
35
|
-
x1='13.8433'
|
|
36
|
-
y1='4.61617'
|
|
37
|
-
x2='21.9178'
|
|
38
|
-
y2='4.61617'
|
|
39
|
-
gradientUnits='userSpaceOnUse'>
|
|
40
|
-
<stop stopColor='#44D0FF' />
|
|
41
|
-
<stop offset='1' stopColor='#B48EFF' />
|
|
42
|
-
</linearGradient>
|
|
43
|
-
<linearGradient
|
|
44
|
-
id='paint2_linear_18316_181'
|
|
45
|
-
x1='13.8433'
|
|
46
|
-
y1='18.5541'
|
|
47
|
-
x2='24.0473'
|
|
48
|
-
y2='18.5541'
|
|
49
|
-
gradientUnits='userSpaceOnUse'>
|
|
50
|
-
<stop stopColor='#44D0FF' />
|
|
51
|
-
<stop offset='1' stopColor='#B48EFF' />
|
|
52
|
-
</linearGradient>
|
|
53
|
-
</defs>
|
|
54
|
-
</svg>
|
|
11
|
+
<div className={clsx('flex h-11 w-11 items-center justify-center', className)}>
|
|
12
|
+
<Icon name='ai-color' className='h-6 w-6' aria-label='AI avatar Icon' />
|
|
55
13
|
</div>
|
|
56
14
|
)
|
|
57
15
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@/src/config/tests'
|
|
2
|
+
import type { IMessageWithSenderData } from '@/src/modules/messages'
|
|
3
|
+
import IMessageWithSenderDataMock from '@/src/modules/messages/__tests__/imessage-with-sender-data.mock'
|
|
4
|
+
import WidgetSettingPropsBuilder from '../../__tests__/widget-settings-props.builder'
|
|
5
|
+
import * as Store from '../../store'
|
|
6
|
+
|
|
7
|
+
import ChatPage from './chat-page'
|
|
8
|
+
|
|
9
|
+
describe('ChatPage', () => {
|
|
10
|
+
const defaultSettings = new WidgetSettingPropsBuilder()
|
|
11
|
+
const getMessagesMock = new IMessageWithSenderDataMock().getMany(10) as IMessageWithSenderData[]
|
|
12
|
+
|
|
13
|
+
const renderComponent = () => render(<ChatPage />)
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.spyOn(Store, 'useWidgetSettingsAtomValue').mockReturnValue(defaultSettings)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should render each fetched message item from API', async () => {
|
|
20
|
+
renderComponent()
|
|
21
|
+
|
|
22
|
+
await waitFor(() =>
|
|
23
|
+
expect(screen.getAllByTestId('messages-item')).toHaveLength(getMessagesMock.length)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
expect(screen.getByPlaceholderText(/send_message.field.placeholder/)).toBeInTheDocument()
|
|
27
|
+
})
|
|
28
|
+
})
|