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
@@ -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
- private get sparkieMessageService() {
18
+ async getSparkieMessageService() {
19
19
  try {
20
- const messageService = SparkieService.sparkieInstance.messageService
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 data = await this.sparkieMessageService.getAll(conversationId, {
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 data = await this.sparkieMessageService.post(conversationId, {
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 data = await this.sparkieMessageService.post(conversationId, {
79
- content
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,2 @@
1
+ export * from './has-to-update-cursor'
2
+ export { default as hasToUpdateCursor } from './has-to-update-cursor'
@@ -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 { InitSparkiePayload, SparkieActions, TrackTypingPayload } from './types'
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
- async initSparkie({ token, skipPresenceSetup = false }: InitSparkiePayload): Promise<boolean> {
33
- const sparkie = this.sparkieInstance
41
+ get isInitialized(): boolean {
42
+ return (
43
+ this.initializationState === 'initialized' &&
44
+ this.sparkie?.messageService?.getAll !== undefined
45
+ )
46
+ }
34
47
 
35
- try {
36
- if (!token || !token.trim())
37
- throw new Error('Invalid or missing token for Sparkie initialization')
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
- await sparkie.init(token, { skipPresenceSetup })
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
- } catch (error) {
42
- console.error('Error initializing realtime engine', error)
43
-
44
- if (error instanceof Error) {
45
- console.error('Error details:', {
46
- message: error.message,
47
- stack: error.stack,
48
- token: token ? 'provided' : 'missing',
49
- skipPresenceSetup
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
- return false
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
- updateToken(token: string): void {
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
- subscribeEvents({
71
- messageReceived,
72
- threadClosed,
73
- threadJoined,
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
- if (threadClosed) this.sparkieInstance.on(ACTION_EVENT_MAP.threadClosed, threadClosed)
217
+ async reinitialize(): Promise<boolean> {
218
+ if (!this.lastInitToken) throw new Error('No token available for reinitialization')
79
219
 
80
- if (threadJoined) this.sparkieInstance.on(ACTION_EVENT_MAP.threadJoined, threadJoined)
220
+ await this.destroySparkie()
221
+ this.initializationState = 'idle'
81
222
 
82
- if (contactTyping) this.sparkieInstance.on(ACTION_EVENT_MAP.contactTyping, contactTyping)
223
+ return this.initSparkie({ token: this.lastInitToken })
83
224
  }
84
225
 
85
- trackTyping({ contactIds, conversationId }: TrackTypingPayload): void {
86
- this.sparkieInstance.listener?.trackTyping(conversationId, contactIds)
87
- }
88
-
89
- destroySparkie(): Promise<void> {
90
- return this.sparkieInstance.destroy({ skipSignOut: true })
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 { IMessage } from '../messages'
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: IMessage) => void
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?: string
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
- function AIAvatarIcon() {
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 rounded-full border-4 border-neutral-900 bg-neutral-800'>
4
- <svg
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
+ })