app-tutor-ai-consumer 1.5.0 → 1.7.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 +12 -0
- package/config/vitest/__mocks__/sparkie.tsx +9 -0
- package/eslint.config.mjs +27 -0
- package/package.json +7 -2
- package/src/@types/index.d.ts +5 -2
- package/src/config/styles/global.css +3 -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/components/icons/stop.svg +4 -0
- 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 +76 -0
- package/src/modules/messages/components/chat-input/chat-input.tsx +100 -23
- package/src/modules/messages/components/chat-input/styles.module.css +3 -0
- package/src/modules/messages/components/chat-input/types.ts +3 -0
- 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 +31 -3
- 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/components/starter-page/starter-page.tsx +3 -3
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
# [1.7.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.6.0...v1.7.0) (2025-07-11)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- add send text message validation ([4f242ca](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/4f242caf92ec3cdab2ae5866d67abd6f1b5d864f))
|
|
6
|
+
|
|
7
|
+
# [1.6.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.5.0...v1.6.0) (2025-07-11)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- add send text message support ([6526d55](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/6526d55fe2781cd9842be805f3406bf2176c825e))
|
|
12
|
+
|
|
1
13
|
# [1.5.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.4.0...v1.5.0) (2025-07-10)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
import SparkieMock from '@/src/modules/sparkie/__tests__/sparkie.mock'
|
|
2
|
+
import { SparkieService } from '@/src/modules/sparkie'
|
|
3
|
+
import { SparkieMessageServiceMock } from '@/src/modules/sparkie/__tests__/sparkie.mock'
|
|
4
|
+
import MessageService from '@hotmart/sparkie/dist/MessageService'
|
|
2
5
|
|
|
3
6
|
vi.mock('@hotmart/sparkie', () => ({ default: SparkieMock }))
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.spyOn(SparkieService, 'getMessageService').mockResolvedValue(
|
|
10
|
+
SparkieMessageServiceMock as unknown as MessageService
|
|
11
|
+
)
|
|
12
|
+
})
|
package/eslint.config.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import globals from 'globals'
|
|
|
8
8
|
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
|
9
9
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
|
10
10
|
import pluginQuery from '@tanstack/eslint-plugin-query'
|
|
11
|
+
import vitest from '@vitest/eslint-plugin'
|
|
11
12
|
|
|
12
13
|
export default tseslint.config(
|
|
13
14
|
{
|
|
@@ -89,4 +90,30 @@ export default tseslint.config(
|
|
|
89
90
|
'@typescript-eslint/no-explicit-any': 'off',
|
|
90
91
|
},
|
|
91
92
|
},
|
|
93
|
+
{
|
|
94
|
+
files: [
|
|
95
|
+
'**/*.test.ts',
|
|
96
|
+
'**/*.test.tsx',
|
|
97
|
+
'**/*.spec.ts',
|
|
98
|
+
'**/*.spec.tsx'
|
|
99
|
+
],
|
|
100
|
+
plugins: {
|
|
101
|
+
vitest,
|
|
102
|
+
},
|
|
103
|
+
languageOptions: {
|
|
104
|
+
globals: {
|
|
105
|
+
...vitest.environments.env.globals,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
rules: {
|
|
109
|
+
...vitest.configs.recommended.rules,
|
|
110
|
+
'@typescript-eslint/unbound-method': 'off',
|
|
111
|
+
'@typescript-eslint/no-unsafe-assignment': 'off',
|
|
112
|
+
'@typescript-eslint/no-unsafe-member-access': 'off',
|
|
113
|
+
'@typescript-eslint/no-unsafe-call': 'off',
|
|
114
|
+
'@typescript-eslint/no-unsafe-return': 'off',
|
|
115
|
+
'@typescript-eslint/no-explicit-any': 'off',
|
|
116
|
+
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
117
|
+
},
|
|
118
|
+
}
|
|
92
119
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app-tutor-ai-consumer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "rspack serve --env=development --config config/rspack/rspack.config.js",
|
|
@@ -60,9 +60,11 @@
|
|
|
60
60
|
"@types/react-dom": "~19.1.6",
|
|
61
61
|
"@types/react-router-dom": "~5.3.3",
|
|
62
62
|
"@types/ua-parser-js": "~0.7.39",
|
|
63
|
+
"@types/uuid": "~10.0.0",
|
|
63
64
|
"@vitejs/plugin-react": "~4.5.2",
|
|
64
65
|
"@vitest/coverage-istanbul": "~3.2.3",
|
|
65
66
|
"@vitest/coverage-v8": "~3.2.3",
|
|
67
|
+
"@vitest/eslint-plugin": "~1.3.4",
|
|
66
68
|
"autoprefixer": "~10.4.21",
|
|
67
69
|
"chance": "~1.1.13",
|
|
68
70
|
"compression-webpack-plugin": "~11.1.0",
|
|
@@ -111,6 +113,7 @@
|
|
|
111
113
|
"dayjs": "~1.11.13",
|
|
112
114
|
"i18next": "~25.2.1",
|
|
113
115
|
"i18next-resources-to-backend": "~1.2.1",
|
|
116
|
+
"immer": "~10.1.1",
|
|
114
117
|
"jotai": "~2.12.5",
|
|
115
118
|
"linkify-it": "~5.0.0",
|
|
116
119
|
"prism-react-renderer": "~2.4.1",
|
|
@@ -118,10 +121,12 @@
|
|
|
118
121
|
"react-dom": "~19.1.0",
|
|
119
122
|
"react-i18next": "~15.5.2",
|
|
120
123
|
"react-markdown": "~10.1.0",
|
|
124
|
+
"react-textarea-autosize": "~8.5.9",
|
|
121
125
|
"rehype-raw": "~7.0.0",
|
|
122
126
|
"rehype-sanitize": "~6.0.0",
|
|
123
127
|
"remark-breaks": "~4.0.0",
|
|
124
|
-
"remark-gfm": "~4.0.1"
|
|
128
|
+
"remark-gfm": "~4.0.1",
|
|
129
|
+
"uuid": "~11.1.0"
|
|
125
130
|
},
|
|
126
131
|
"optionalDependencies": {
|
|
127
132
|
"@rollup/rollup-linux-x64-gnu": "4.6.1",
|
package/src/@types/index.d.ts
CHANGED
|
@@ -9,8 +9,11 @@ declare global {
|
|
|
9
9
|
|
|
10
10
|
interface Window {
|
|
11
11
|
__INJECT_CSS_MODULE__?: (cssText: string, id: string) => void
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
startChatWidget: (
|
|
13
|
+
elementId: StartTutorWidgetProps['elementId'],
|
|
14
|
+
settings: StartTutorWidgetProps['settings']
|
|
15
|
+
) => Promise<void>
|
|
16
|
+
closeChatWidget: () => void
|
|
14
17
|
TOKEN: string
|
|
15
18
|
}
|
|
16
19
|
}
|
|
@@ -79,14 +79,15 @@
|
|
|
79
79
|
--ai-color-chat-response: #1e1926;
|
|
80
80
|
|
|
81
81
|
/* Size */
|
|
82
|
+
--hc-size-spacing-1: 0.25rem;
|
|
82
83
|
--hc-size-spacing-2: 0.5rem;
|
|
83
84
|
--hc-size-border-medium: 0.5rem;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
#hotmart-app-tutor-ai-consumer-root {
|
|
87
88
|
& *::-webkit-scrollbar {
|
|
88
|
-
width: var(--hc-size-spacing-2);
|
|
89
|
-
height: var(--hc-size-spacing-2);
|
|
89
|
+
width: var(--custom-scrollbar-width, var(--hc-size-spacing-2));
|
|
90
|
+
height: var(--custom-scrollbar-width, var(--hc-size-spacing-2));
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
& *::-webkit-scrollbar-track {
|
|
@@ -14,7 +14,7 @@ export const queryClient = new QueryClient({
|
|
|
14
14
|
retry(failureCount, error) {
|
|
15
15
|
if (!api?.defaults?.headers?.common?.['Authorization']) return false
|
|
16
16
|
|
|
17
|
-
const maxRetries =
|
|
17
|
+
const maxRetries = 3
|
|
18
18
|
const statusCode = (error as ApiError).statusCode
|
|
19
19
|
|
|
20
20
|
return (
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
import './index'
|
|
2
2
|
|
|
3
|
+
import { v4 } from 'uuid'
|
|
4
|
+
|
|
3
5
|
import { LANGUAGES } from './config/i18n'
|
|
4
6
|
import { devMode } from './lib/utils'
|
|
5
7
|
|
|
6
8
|
if (devMode) {
|
|
7
9
|
window.TOKEN = process.env.TOKEN ?? ''
|
|
8
10
|
void (async () => {
|
|
9
|
-
await window.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
productId: 4266504
|
|
23
|
-
}
|
|
11
|
+
await window.startChatWidget('root', {
|
|
12
|
+
hotmartToken: window.TOKEN,
|
|
13
|
+
locale: LANGUAGES.PT_BR,
|
|
14
|
+
conversationId: '21506473-a93c-4b38-9c32-68a5ca37ce73', // OWNER
|
|
15
|
+
tutorName: 'Prof Jou Robots',
|
|
16
|
+
contactId: '38138170-6009-40cd-be50-001249e80a0d',
|
|
17
|
+
membershipId: '6297a4efa488cc775ac5e1dd',
|
|
18
|
+
namespace: 'tutor_v1-2',
|
|
19
|
+
author: 'user',
|
|
20
|
+
clubName: 'comofazerumvideodeteste',
|
|
21
|
+
productName: 'Curso de Assinatura',
|
|
22
|
+
productId: 4266504,
|
|
23
|
+
sessionId: v4()
|
|
24
24
|
})
|
|
25
25
|
})()
|
|
26
26
|
}
|
package/src/index.tsx
CHANGED
|
@@ -9,8 +9,9 @@ import { initLanguage } from './config/i18n'
|
|
|
9
9
|
import { initAxios } from './config/request/api'
|
|
10
10
|
import { productionMode } from './lib/utils'
|
|
11
11
|
import { Main } from './main'
|
|
12
|
+
import { SparkieService } from './modules/sparkie'
|
|
12
13
|
import { TutorWidgetEvents, TutorWidgetEventTypes } from './modules/widget'
|
|
13
|
-
import type {
|
|
14
|
+
import type { WidgetSettingProps } from './types'
|
|
14
15
|
|
|
15
16
|
const loadMainStyles = () => {
|
|
16
17
|
const isProduction = productionMode
|
|
@@ -28,15 +29,24 @@ const loadMainStyles = () => {
|
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
window.
|
|
32
|
+
window.startChatWidget = async (
|
|
32
33
|
elementId = 'tutor-chat-app-widget',
|
|
33
|
-
settings
|
|
34
|
-
|
|
34
|
+
settings: WidgetSettingProps
|
|
35
|
+
) => {
|
|
35
36
|
loadMainStyles()
|
|
36
37
|
|
|
37
38
|
const rootElement = document.getElementById(elementId) as HTMLElement
|
|
38
39
|
const root = createRoot(rootElement)
|
|
39
40
|
|
|
41
|
+
await SparkieService.initSparkie({
|
|
42
|
+
token: settings.hotmartToken,
|
|
43
|
+
skipPresenceSetup: true,
|
|
44
|
+
retryOptions: {
|
|
45
|
+
maxRetries: 5,
|
|
46
|
+
retryDelay: 2000,
|
|
47
|
+
backoffMultiplier: 1.5
|
|
48
|
+
}
|
|
49
|
+
})
|
|
40
50
|
initAxios(settings.hotmartToken)
|
|
41
51
|
await initLanguage(settings.locale)
|
|
42
52
|
await initDayjs(settings.locale)
|
|
@@ -49,4 +59,4 @@ window.startTutorWidget = async ({
|
|
|
49
59
|
)
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
window.
|
|
62
|
+
window.closeChatWidget = () => TutorWidgetEvents.get(TutorWidgetEventTypes.CLOSE)?.dispatch()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path
|
|
3
|
+
d="M21.5813 16.7843L24.9086 18.5499L21.5813 20.3154L19.8068 23.6263L18.0313 20.3154L14.7041 18.5499L18.0313 16.7843L19.8068 13.4734L21.5813 16.7843ZM11.9119 9.10141L17.2354 11.9261L11.9119 14.7508L9.07199 20.0485L6.23301 14.7508L0.908569 11.9261L6.23301 9.10141L9.07199 3.80376L11.9119 9.10141ZM20.1457 3.24642L22.7786 4.64367L20.1457 6.03994L18.7413 8.66002L17.337 6.03994L14.7041 4.64367L17.337 3.24642L18.7413 0.626343L20.1457 3.24642Z"
|
|
4
|
+
fill="url(#paint0_linear_18592_52336)" />
|
|
5
|
+
<defs>
|
|
6
|
+
<linearGradient
|
|
7
|
+
id="paint0_linear_18592_52336"
|
|
8
|
+
x1="0.908569"
|
|
9
|
+
y1="12.1263"
|
|
10
|
+
x2="24.9086"
|
|
11
|
+
y2="12.1263"
|
|
12
|
+
gradientUnits="userSpaceOnUse">
|
|
13
|
+
<stop stop-color="#44D0FF" />
|
|
14
|
+
<stop offset="1" stop-color="#B48EFF" />
|
|
15
|
+
</linearGradient>
|
|
16
|
+
</defs>
|
|
17
|
+
</svg>
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated file - DO NOT EDIT
|
|
2
|
-
export type ValidIconNames = 'arrow-down' | 'chevron-down' | 'send'
|
|
2
|
+
export type ValidIconNames = 'ai-color' | 'arrow-down' | 'chevron-down' | 'send' | 'stop'
|
package/src/main/main.spec.tsx
CHANGED
|
@@ -13,14 +13,6 @@ describe('Main', () => {
|
|
|
13
13
|
vi.mocked(useWidgetSettingsAtom).mockReturnValue([null, vi.fn()])
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
-
it('should render empty element when settings.tutorName is not defined', async () => {
|
|
17
|
-
const { container } = renderComponent()
|
|
18
|
-
|
|
19
|
-
await waitFor(() => {
|
|
20
|
-
expect(container).toBeEmptyDOMElement()
|
|
21
|
-
})
|
|
22
|
-
})
|
|
23
|
-
|
|
24
16
|
it('should render without errors', async () => {
|
|
25
17
|
const props = new WidgetSettingPropsBuilder().withTutorName(chance.name())
|
|
26
18
|
vi.mocked(useWidgetSettingsAtom).mockReturnValue([props, vi.fn()])
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import { chance, fireEvent, render, screen } from '@/src/config/tests'
|
|
4
|
+
|
|
5
|
+
import ChatInput from './chat-input'
|
|
6
|
+
import { useChatInputValueAtom } from './chat-input.atom'
|
|
7
|
+
|
|
8
|
+
vi.mock('./chat-input.atom', () => ({ useChatInputValueAtom: vi.fn() }))
|
|
9
|
+
|
|
10
|
+
describe('ChatInput', () => {
|
|
11
|
+
const ref = createRef<HTMLTextAreaElement>()
|
|
12
|
+
const chatInputValueAtomMock = { val: '', setVal: vi.fn() }
|
|
13
|
+
const defaultProps = { name: chance.name() }
|
|
14
|
+
|
|
15
|
+
const renderComponent = (props = defaultProps) => render(<ChatInput {...props} ref={ref} />)
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.mocked(useChatInputValueAtom).mockReturnValue([
|
|
19
|
+
chatInputValueAtomMock.val,
|
|
20
|
+
chatInputValueAtomMock.setVal
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should call focus when rendering the input', () => {
|
|
25
|
+
renderComponent()
|
|
26
|
+
|
|
27
|
+
expect(ref.current).toHaveFocus()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should call setValue when ref change event is called', () => {
|
|
31
|
+
renderComponent()
|
|
32
|
+
|
|
33
|
+
expect(ref.current).not.toBeNull()
|
|
34
|
+
|
|
35
|
+
const event = { target: { value: 'Test message' } }
|
|
36
|
+
|
|
37
|
+
fireEvent.change(ref.current!, event)
|
|
38
|
+
|
|
39
|
+
expect(chatInputValueAtomMock.setVal).toHaveBeenCalledTimes(1)
|
|
40
|
+
expect(chatInputValueAtomMock.setVal).toHaveBeenNthCalledWith(1, event.target.value)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should call onSend prop when it is defined and user presses the EnterKey', () => {
|
|
44
|
+
const onSend = vi.fn()
|
|
45
|
+
renderComponent({ ...defaultProps, onSend } as never)
|
|
46
|
+
|
|
47
|
+
expect(ref.current).not.toBeNull()
|
|
48
|
+
|
|
49
|
+
const event = { code: 'Enter' }
|
|
50
|
+
|
|
51
|
+
fireEvent.keyDown(ref.current!, event)
|
|
52
|
+
|
|
53
|
+
expect(onSend).toHaveBeenCalledTimes(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should disable the button when buttonDisabled prop is true', () => {
|
|
57
|
+
renderComponent({ buttonDisabled: true } as never)
|
|
58
|
+
|
|
59
|
+
expect(screen.getByRole('button', { name: /Submit Button/i })).toBeDisabled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should enable the button when buttonDisabled prop is falsy', () => {
|
|
63
|
+
renderComponent()
|
|
64
|
+
|
|
65
|
+
expect(screen.getByRole('button', { name: /Submit Button/i })).toBeEnabled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should set textarea value correctly', () => {
|
|
69
|
+
const name = chance.name()
|
|
70
|
+
vi.mocked(useChatInputValueAtom).mockReturnValue([name, vi.fn()])
|
|
71
|
+
|
|
72
|
+
renderComponent()
|
|
73
|
+
|
|
74
|
+
expect(ref.current?.value).toBe(name)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -1,32 +1,109 @@
|
|
|
1
|
-
import { forwardRef } from 'react'
|
|
1
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
|
2
|
+
import clsx from 'clsx'
|
|
3
|
+
import type { ChangeEvent, KeyboardEvent } from 'react'
|
|
2
4
|
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import TextareaAutosize from 'react-textarea-autosize'
|
|
3
6
|
|
|
4
|
-
import { Icon } from '@/src/lib/components'
|
|
7
|
+
import { Icon, Spinner } from '@/src/lib/components'
|
|
5
8
|
|
|
6
9
|
import { useChatInputValueAtom } from './chat-input.atom'
|
|
7
10
|
import type { ChatInputProps } from './types'
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
12
|
+
import styles from './styles.module.css'
|
|
13
|
+
|
|
14
|
+
const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(
|
|
15
|
+
(
|
|
16
|
+
{ name, onSend, loading = false, inputDisabled = false, buttonDisabled = false },
|
|
17
|
+
forwardedRef
|
|
18
|
+
) => {
|
|
19
|
+
const { t } = useTranslation()
|
|
20
|
+
const [value, setValue] = useChatInputValueAtom()
|
|
21
|
+
const ref = useRef<HTMLTextAreaElement>(null)
|
|
22
|
+
|
|
23
|
+
useImperativeHandle(forwardedRef, () => ref?.current as HTMLTextAreaElement)
|
|
24
|
+
|
|
25
|
+
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
26
|
+
if (inputDisabled) return
|
|
27
|
+
|
|
28
|
+
setValue(e.target.value)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
32
|
+
if (loading || buttonDisabled) return
|
|
33
|
+
|
|
34
|
+
const isEnterKey = e.code === 'Enter'
|
|
35
|
+
const isShiftKey = e.shiftKey
|
|
36
|
+
|
|
37
|
+
if (isEnterKey && !isShiftKey) {
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
onSend?.()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (inputDisabled) return
|
|
45
|
+
|
|
46
|
+
const input = ref?.current
|
|
47
|
+
|
|
48
|
+
if (input) {
|
|
49
|
+
input.focus()
|
|
50
|
+
|
|
51
|
+
const position = input.textLength ?? 0
|
|
52
|
+
input.setSelectionRange(position, position)
|
|
53
|
+
}
|
|
54
|
+
}, [inputDisabled])
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={clsx(
|
|
59
|
+
'flex items-center rounded-full border border-neutral-800 bg-neutral-900 px-4 py-2',
|
|
60
|
+
{ 'cursor-not-allowed opacity-40': inputDisabled }
|
|
61
|
+
)}>
|
|
62
|
+
<TextareaAutosize
|
|
63
|
+
id={name}
|
|
64
|
+
name={name}
|
|
65
|
+
ref={ref}
|
|
66
|
+
className={clsx(
|
|
67
|
+
clsx(
|
|
68
|
+
'max-h-12 w-full resize-none border-none bg-transparent text-neutral-100 outline-none outline-0 placeholder:text-neutral-400',
|
|
69
|
+
styles.textArea
|
|
70
|
+
),
|
|
71
|
+
{ 'cursor-not-allowed': inputDisabled, 'opacity-40': inputDisabled || loading }
|
|
72
|
+
)}
|
|
73
|
+
placeholder={t('send_message.field.placeholder')}
|
|
74
|
+
value={value}
|
|
75
|
+
onChange={handleChange}
|
|
76
|
+
onKeyDown={handleKeyDown}
|
|
77
|
+
disabled={inputDisabled}
|
|
78
|
+
/>
|
|
79
|
+
<button
|
|
80
|
+
onClick={onSend}
|
|
81
|
+
disabled={buttonDisabled || loading}
|
|
82
|
+
className={clsx(
|
|
83
|
+
'flex size-8 flex-col items-center justify-center rounded-full outline-none transition-colors duration-300 ease-in',
|
|
84
|
+
{
|
|
85
|
+
'cursor-pointer hover:scale-110 hover:bg-neutral-600 focus:bg-neutral-700 focus:outline-none focus:ring-1 focus:ring-neutral-500 focus:ring-offset-2':
|
|
86
|
+
!buttonDisabled,
|
|
87
|
+
'cursor-not-allowed': buttonDisabled
|
|
88
|
+
}
|
|
89
|
+
)}
|
|
90
|
+
aria-label='Submit Button'>
|
|
91
|
+
{loading ? (
|
|
92
|
+
<Spinner className='h-5 w-5 text-neutral-500' />
|
|
93
|
+
) : (
|
|
94
|
+
<Icon
|
|
95
|
+
name='send'
|
|
96
|
+
className={clsx('h-4 w-4 pr-0.5 pt-0.5 transition-colors duration-150', {
|
|
97
|
+
'text-neutral-50': !buttonDisabled,
|
|
98
|
+
'text-neutral-500': buttonDisabled
|
|
99
|
+
})}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
)
|
|
30
107
|
|
|
31
108
|
ChatInput.displayName = 'ChatInput'
|
|
32
109
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as MessageSkeleton } from './message-skeleton'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import { AIAvatarIcon } from '@/src/modules/widget'
|
|
4
|
+
|
|
5
|
+
const MessageSkeleton = forwardRef<HTMLDivElement>((_, ref) => {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
ref={ref}
|
|
9
|
+
className='flex max-w-[86%] flex-col items-start gap-2'
|
|
10
|
+
aria-label='Loading Component'>
|
|
11
|
+
<AIAvatarIcon className='rounded-lg bg-ai-chat-response' />
|
|
12
|
+
<div className='flex w-full flex-col items-start gap-2'>
|
|
13
|
+
<div className='h-3 w-full animate-pulse rounded-full bg-neutral-800 transition-colors' />
|
|
14
|
+
<div className='h-3 w-[83%] animate-pulse rounded-full bg-neutral-800 transition-colors delay-75' />
|
|
15
|
+
<div className='h-3 w-[56%] animate-pulse rounded-full bg-neutral-800 transition-colors delay-100' />
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
MessageSkeleton.displayName = 'MessageSkeleton'
|
|
22
|
+
|
|
23
|
+
export default MessageSkeleton
|
|
@@ -2,8 +2,11 @@ import { lazy, useCallback, useRef } from 'react'
|
|
|
2
2
|
import clsx from 'clsx'
|
|
3
3
|
|
|
4
4
|
import { useRefClientHeight } from '@/src/lib/hooks'
|
|
5
|
+
import { useWidgetLoadingAtomValue } from '@/src/modules/widget'
|
|
5
6
|
import { useAllMessages, useManageScroll } from '../../hooks'
|
|
7
|
+
import { useSkeletonRef } from '../../hooks/use-skeleton-ref'
|
|
6
8
|
import { MessageItem } from '../message-item'
|
|
9
|
+
import { MessageSkeleton } from '../message-skeleton'
|
|
7
10
|
|
|
8
11
|
const MessageItemError = lazy(() => import('../message-item-error/message-item-error'))
|
|
9
12
|
|
|
@@ -22,7 +25,8 @@ function MessagesList() {
|
|
|
22
25
|
const scrollerClientHeight = useRefClientHeight(scrollerRef)
|
|
23
26
|
const scrollToButtonRef = useRef<HTMLButtonElement>(null)
|
|
24
27
|
const { allMessages, messagesQuery } = useAllMessages()
|
|
25
|
-
|
|
28
|
+
const widgetIsLoading = useWidgetLoadingAtomValue()
|
|
29
|
+
const skeletonRef = useSkeletonRef()
|
|
26
30
|
const { showScrollButton } = useManageScroll(scrollerRef)
|
|
27
31
|
|
|
28
32
|
const scrollToBottom = useCallback(() => {
|
|
@@ -76,6 +80,15 @@ function MessagesList() {
|
|
|
76
80
|
message={`❌ Error loading messages: ${messagesQuery.error?.message ?? ''}`}
|
|
77
81
|
retry={() => void messagesQuery.refetch()}
|
|
78
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>
|
|
79
92
|
</div>
|
|
80
93
|
)
|
|
81
94
|
}
|
package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import type MessageService from '@hotmart/sparkie/dist/MessageService'
|
|
2
|
+
|
|
1
3
|
import { act, renderHook, waitFor } from '@/src/config/tests'
|
|
4
|
+
import { SparkieService } from '@/src/modules/sparkie'
|
|
2
5
|
import { SparkieMessageServiceMock } from '@/src/modules/sparkie/__tests__/sparkie.mock'
|
|
3
6
|
import IMessageWithSenderDataMock from '../../__tests__/imessage-with-sender-data.mock'
|
|
4
7
|
import type { IMessageWithSenderData } from '../../types'
|
|
@@ -11,6 +14,10 @@ describe('useInfiniteGetMessages', () => {
|
|
|
11
14
|
const mockEnabled = true
|
|
12
15
|
|
|
13
16
|
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks()
|
|
18
|
+
vi.spyOn(SparkieService, 'getMessageService').mockResolvedValue(
|
|
19
|
+
SparkieMessageServiceMock as unknown as MessageService
|
|
20
|
+
)
|
|
14
21
|
SparkieMessageServiceMock.getAll.mockClear()
|
|
15
22
|
})
|
|
16
23
|
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import { useCallback, useEffect } from 'react'
|
|
2
1
|
import type { UndefinedInitialDataInfiniteOptions } from '@tanstack/react-query'
|
|
3
|
-
import { useInfiniteQuery
|
|
2
|
+
import { useInfiniteQuery } from '@tanstack/react-query'
|
|
4
3
|
|
|
5
4
|
import { formatTime } from '@/src/config/dayjs'
|
|
6
|
-
import { SparkieService } from '@/src/modules/sparkie'
|
|
7
5
|
import { MessagesService } from '../..'
|
|
8
6
|
import { MSG_MAX_COUNT } from '../../constants'
|
|
9
|
-
import type { FetchMessagesResponse,
|
|
7
|
+
import type { FetchMessagesResponse, ParsedMessage } from '../../types'
|
|
10
8
|
import { messagesParser } from '../../utils'
|
|
11
9
|
import type { UseFetchMessagesProps } from '../use-fetch-messages'
|
|
12
10
|
|
|
@@ -71,25 +69,11 @@ function useInfiniteGetMessages({
|
|
|
71
69
|
profileId,
|
|
72
70
|
enabled = false
|
|
73
71
|
}: Omit<UseFetchMessagesProps, 'currentMessages' | 'loadFirstPage'>) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (data.conversationId !== conversationId) return
|
|
80
|
-
|
|
81
|
-
void queryClient.invalidateQueries({ queryKey: query.queryKey })
|
|
82
|
-
},
|
|
83
|
-
[conversationId, queryClient, query.queryKey]
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
SparkieService.subscribeEvents({ messageReceived })
|
|
88
|
-
|
|
89
|
-
return () => {
|
|
90
|
-
SparkieService.removeEventSubscription({ messageReceived })
|
|
91
|
-
}
|
|
92
|
-
}, [messageReceived])
|
|
72
|
+
const query = getMessagesInfiniteQuery({
|
|
73
|
+
conversationId,
|
|
74
|
+
profileId,
|
|
75
|
+
enabled
|
|
76
|
+
})
|
|
93
77
|
|
|
94
78
|
return useInfiniteQuery(query)
|
|
95
79
|
}
|