app-tutor-ai-consumer 1.38.0 → 1.39.1

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 (37) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/package.json +1 -1
  3. package/src/bootstrap.ts +1 -1
  4. package/src/config/styles/global.css +3 -0
  5. package/src/config/tests/handlers.ts +3 -3
  6. package/src/modules/messages/__tests__/{signed-urls.builder.ts → files-signed-urls.builder.ts} +3 -3
  7. package/src/modules/messages/components/chat-file-preview/chat-file-preview.tsx +0 -1
  8. package/src/modules/messages/components/chat-file-preview/components/document-preview/document-preview.tsx +4 -3
  9. package/src/modules/messages/components/chat-file-preview/components/document-preview/types.ts +0 -3
  10. package/src/modules/messages/components/chat-file-preview/utils.ts +17 -0
  11. package/src/modules/messages/components/chat-file-uploader/chat-file-uploader.builder.ts +4 -7
  12. package/src/modules/messages/components/chat-file-uploader/chat-file-uploader.spec.tsx +0 -20
  13. package/src/modules/messages/components/chat-file-uploader/chat-file-uploader.tsx +16 -14
  14. package/src/modules/messages/components/chat-file-uploader-wrapper/chat-file-uploader-wrapper.tsx +34 -0
  15. package/src/modules/messages/components/chat-file-uploader-wrapper/index.ts +1 -0
  16. package/src/modules/messages/components/message-item/message-item.tsx +10 -0
  17. package/src/modules/messages/constants.ts +1 -1
  18. package/src/{lib → modules/messages}/hooks/use-chat-file-upload/constants.ts +2 -0
  19. package/src/modules/messages/hooks/use-chat-file-upload/use-chat-file-upload.spec.ts +19 -0
  20. package/src/modules/messages/hooks/use-chat-file-upload/use-chat-file-upload.ts +102 -0
  21. package/src/modules/messages/hooks/use-get-files-signed-urls/index.ts +1 -0
  22. package/src/modules/messages/hooks/{use-get-signed-urls/use-get-signed-urls.spec.tsx → use-get-files-signed-urls/use-get-files-signed-urls.spec.ts} +7 -7
  23. package/src/modules/messages/hooks/use-get-files-signed-urls/use-get-files-signed-urls.ts +25 -0
  24. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +20 -2
  25. package/src/modules/messages/hooks/use-upload-file/index.ts +1 -0
  26. package/src/modules/messages/hooks/use-upload-file/use-upload-file.ts +15 -0
  27. package/src/modules/messages/service.direct.ts +27 -10
  28. package/src/modules/messages/store/attached-file.atom.ts +7 -0
  29. package/src/modules/messages/types.ts +30 -2
  30. package/src/modules/widget/components/chat-page/chat-page.tsx +23 -9
  31. package/src/lib/hooks/use-chat-file-upload/types.ts +0 -14
  32. package/src/lib/hooks/use-chat-file-upload/use-chat-file-upload.spec.ts +0 -59
  33. package/src/lib/hooks/use-chat-file-upload/use-chat-file-upload.ts +0 -28
  34. package/src/modules/messages/components/chat-file-uploader/types.ts +0 -4
  35. package/src/modules/messages/hooks/use-get-signed-urls/index.ts +0 -1
  36. package/src/modules/messages/hooks/use-get-signed-urls/use-get-signed-urls.tsx +0 -38
  37. /package/src/{lib → modules/messages}/hooks/use-chat-file-upload/index.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## [1.39.1](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.39.0...v1.39.1) (2025-11-17)
2
+
3
+ ### Bug Fixes
4
+
5
+ - add sessionId fallback ([635a6fc](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/635a6fccc750668657f26aa9d6f738a4a35d5ba0))
6
+
7
+ # [1.39.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.38.0...v1.39.0) (2025-11-11)
8
+
9
+ ### Features
10
+
11
+ - changing file extension ([60d395b](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/60d395b9a34a49c1e7935dec0593ee75d23be8fe))
12
+ - code improvement ([e539ba3](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/e539ba3882c06dba661a7056476497fb929cd5be))
13
+ - code improvement ([b17de7b](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/b17de7b39bc5bfc13f7dbffbb322934e4c8d1973))
14
+ - code improvements ([ecf54e6](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/ecf54e6be8676890b67f8b4cfecb21dbc6f2b981))
15
+ - error i18n messages ([823ea93](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/823ea93bb38d3777fda686a47e60ab8c94738dfd))
16
+ - fixing productId parameter ([45a1d8c](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/45a1d8c277d6e43f698f90b32f53a87ade1b234d))
17
+ - fixing test ([6309da8](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/6309da8a97cce96543488f8689f1851677bf6a0d))
18
+ - fixing toast type ([71a23e8](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/71a23e8c1d1bfd48d2581d0425a525fb57fe7e38))
19
+ - integrate ChatFileUploaderWrapper with ChatFilePreview ([1b8cf1a](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/1b8cf1a5492b7b57f7303ec4172cc555c6f7fbbc))
20
+ - linking the ChatFileUploaderWrapper display to the showFileUpload parameter ([7826d85](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/7826d8590d304dc30c016ef4e0849ba74e8a6160))
21
+ - preview file on chat history ([aa09c31](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/aa09c317b2299cd9ced9cbe65822838778aabdae))
22
+ - refactoring ([890838b](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/890838b2f79b31797fa593eb5f2218b0d8c5115c))
23
+ - refactoring ([665ff74](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/665ff741df81e2b4dcecedbddc0e0912dac40f89))
24
+ - removing temporary component ([e4bfbf0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/e4bfbf08b44a20d744b7f0e7c0ab18e2ff5b2519))
25
+ - removing tests ([fa4b836](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/fa4b83631f0aa7542e12f39cbe06c17007a6cc18))
26
+ - removing tooltip ([8e4450e](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/8e4450e87d89cca21855e3270e046817187defda))
27
+ - sending file data to conversation api ([40fe90e](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/40fe90edc4c5f67cd3f527cc33738e1dea0839bc))
28
+ - tooltip refactoring ([df0a66a](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/df0a66ac6d469aea33dbede90fbccce24f6e631a))
29
+ - typing fix ([4ab00a0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/4ab00a082dbb29d5d848350231f38f79d44d1684))
30
+ - undoing tooltip refactoring ([26c1072](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/26c10722dab2d5669ff972cfbe1aefaeb05b8850))
31
+ - update file size ([bf92561](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/bf92561945896a01dc5032e1b9c2dbcc78110d7d))
32
+ - wip ([3160ab4](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/3160ab4c6d2ea5417213fbbe8ce031b97de853c4))
33
+
1
34
  # [1.38.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.37.0...v1.38.0) (2025-11-10)
2
35
 
3
36
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-tutor-ai-consumer",
3
- "version": "1.38.0",
3
+ "version": "1.39.1",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "dev": "rspack serve --env=development --config config/rspack/rspack.config.js",
package/src/bootstrap.ts CHANGED
@@ -30,7 +30,7 @@ export async function bootstrap({ queryClient, settings }: BootstrapProps) {
30
30
  ucode: settings.user?.ucode ?? '',
31
31
  membershipId: settings.membershipId ?? '',
32
32
  membershipSlug: settings.membershipSlug ?? '',
33
- sessionId: settings.sessionId,
33
+ sessionId: settings.sessionId ?? 'X',
34
34
  userId: Number(settings.userId),
35
35
  product: {
36
36
  id: Number(settings.productId) || 0,
@@ -88,6 +88,9 @@
88
88
  --hc-size-spacing-1: 0.25rem;
89
89
  --hc-size-spacing-2: 0.5rem;
90
90
  --hc-size-border-medium: 0.5rem;
91
+
92
+ /* Tooltip */
93
+ --tooltip-bg-color: #191c1f;
91
94
  }
92
95
 
93
96
  #hotmart-app-tutor-ai-consumer-root {
@@ -1,8 +1,8 @@
1
1
  import { http, HttpResponse } from 'msw'
2
2
 
3
3
  import { MessagesEndpoints, MSG_MAX_COUNT } from '@/src/modules/messages'
4
+ import SignedFilesUrlsResponseBuilder from '@/src/modules/messages/__tests__/files-signed-urls.builder'
4
5
  import IMessageWithSenderDataMock from '@/src/modules/messages/__tests__/imessage-with-sender-data.mock'
5
- import SignedUrlsResponseBuilder from '@/src/modules/messages/__tests__/signed-urls.builder'
6
6
  import { ProfileEndpoints } from '@/src/modules/profile'
7
7
  import ProfileAPIPropsBuilder from '@/src/modules/profile/__tests__/profile-api-props.builder'
8
8
 
@@ -19,8 +19,8 @@ export const handlers = [
19
19
  http.all(ProfileEndpoints.getProfile(), () => {
20
20
  return HttpResponse.json(new ProfileAPIPropsBuilder())
21
21
  }),
22
- http.all(MessagesEndpoints.getSignedUrls(), () => {
23
- return HttpResponse.json(new SignedUrlsResponseBuilder())
22
+ http.all(MessagesEndpoints.getFilesSignedUrls(), () => {
23
+ return HttpResponse.json(new SignedFilesUrlsResponseBuilder())
24
24
  }),
25
25
  http.all(MessagesEndpoints.getAll(':conversationId'), ({ request }) => {
26
26
  const limit = Number(new URL(request.url)?.searchParams?.get?.('limit'))
@@ -1,7 +1,7 @@
1
1
  import { chance } from '@/src/config/tests'
2
- import type { IGetSignedUrlsResponse } from '../types'
2
+ import type { IGetFilesSignedUrlsResponse } from '../types'
3
3
 
4
- class SignedUrlsResponseBuilder implements IGetSignedUrlsResponse {
4
+ class FilesSignedUrlsResponseBuilder implements IGetFilesSignedUrlsResponse {
5
5
  uploadUrl: string
6
6
  futureDownloadUrl: string
7
7
  imagePreviewUrl: string
@@ -39,4 +39,4 @@ class SignedUrlsResponseBuilder implements IGetSignedUrlsResponse {
39
39
  }
40
40
  }
41
41
 
42
- export default SignedUrlsResponseBuilder
42
+ export default FilesSignedUrlsResponseBuilder
@@ -34,7 +34,6 @@ function ChatFilePreview({
34
34
  <DocumentPreview
35
35
  name={name}
36
36
  size={size}
37
- type={type}
38
37
  isLoading={isLoading}
39
38
  showCloseButton={showCloseButton}
40
39
  onClose={onClose}
@@ -5,7 +5,7 @@ import { Icon, Spinner } from '@/src/lib/components'
5
5
  import { useMediaQuery } from '@/src/lib/hooks'
6
6
  import { useWidgetSettingsAtom } from '@/src/modules/widget/store'
7
7
  import { CHAT_FILE_PREVIEW_CONFIG } from '../../constants'
8
- import { formatFileSize } from '../../utils'
8
+ import { formatFileSize, getFileTypeFromFileName } from '../../utils'
9
9
  import { CloseButton } from '../close-button'
10
10
 
11
11
  import type { DocumentPreviewProps } from './types'
@@ -13,7 +13,6 @@ import type { DocumentPreviewProps } from './types'
13
13
  export default function DocumentPreview({
14
14
  name,
15
15
  size,
16
- type,
17
16
  isLoading,
18
17
  showCloseButton,
19
18
  onClose
@@ -23,7 +22,9 @@ export default function DocumentPreview({
23
22
  const isMobile = useMediaQuery({ maxSize: 'md' })
24
23
  const formattedSize = formatFileSize(size)
25
24
  const isDarkMode = settings?.config?.theme === 'dark'
26
- const config = CHAT_FILE_PREVIEW_CONFIG[type]
25
+
26
+ const fileType = getFileTypeFromFileName(name)
27
+ const config = CHAT_FILE_PREVIEW_CONFIG[fileType]
27
28
 
28
29
  return (
29
30
  <div
@@ -1,9 +1,6 @@
1
- import type { FilePreviewProps } from '../../types'
2
-
3
1
  export type DocumentPreviewProps = {
4
2
  name: string
5
3
  size: number
6
- type: Exclude<FilePreviewProps['type'], 'image'>
7
4
  isLoading: boolean
8
5
  showCloseButton: boolean
9
6
  onClose?: () => void
@@ -1,4 +1,7 @@
1
+ import { FILE_TYPES } from '../../hooks/use-chat-file-upload/constants'
2
+
1
3
  import { FILE_SIZE_UNITS } from './constants'
4
+ import type { FilePreviewProps } from './types'
2
5
 
3
6
  export function formatFileSize(size: number): string {
4
7
  if (size < FILE_SIZE_UNITS.KB) {
@@ -11,3 +14,17 @@ export function formatFileSize(size: number): string {
11
14
 
12
15
  return `${Math.round((size / FILE_SIZE_UNITS.MB) * 10) / 10}MB`
13
16
  }
17
+
18
+ export function getFileTypeFromFileName(
19
+ fileName: string
20
+ ): Exclude<FilePreviewProps['type'], 'image'> {
21
+ const extension = `.${fileName.split('.').pop()?.toLowerCase() || ''}`
22
+
23
+ for (const [category, extensions] of Object.entries(FILE_TYPES)) {
24
+ if (category !== 'image' && (extensions as readonly string[]).includes(extension)) {
25
+ return category as Exclude<FilePreviewProps['type'], 'image'>
26
+ }
27
+ }
28
+
29
+ return 'document'
30
+ }
@@ -1,18 +1,15 @@
1
- import type { ChatFileUploaderProps } from './types'
1
+ import type { ChatFileUploaderProps } from './chat-file-uploader'
2
2
 
3
3
  export class ChatFileUploaderBuilder {
4
- private props: ChatFileUploaderProps = {}
4
+ private props: ChatFileUploaderProps = {
5
+ onUploadFile: () => {}
6
+ }
5
7
 
6
8
  withDisabled(disabled: boolean): ChatFileUploaderBuilder {
7
9
  this.props.disabled = disabled
8
10
  return this
9
11
  }
10
12
 
11
- withLoading(loading: boolean): ChatFileUploaderBuilder {
12
- this.props.loading = loading
13
- return this
14
- }
15
-
16
13
  build(): ChatFileUploaderProps {
17
14
  return this.props
18
15
  }
@@ -24,26 +24,6 @@ describe('ChatFileUploader', () => {
24
24
  expect(button).toBeDisabled()
25
25
  })
26
26
 
27
- it('should be disabled when loading prop is true', () => {
28
- const props = new ChatFileUploaderBuilder().withLoading(true).build()
29
- renderComponent(props)
30
-
31
- const button = screen.getByRole('button', {
32
- name: /general.buttons.open_options/i
33
- })
34
- expect(button).toBeDisabled()
35
- })
36
-
37
- it('should be enabled when both disabled and loading are false', () => {
38
- const props = new ChatFileUploaderBuilder().withDisabled(false).withLoading(false).build()
39
- renderComponent(props)
40
-
41
- const button = screen.getByRole('button', {
42
- name: /general.buttons.open_options/i
43
- })
44
- expect(button).toBeEnabled()
45
- })
46
-
47
27
  it('should trigger file input when clicking file option', async () => {
48
28
  const { user } = renderComponent()
49
29
 
@@ -2,24 +2,25 @@ import { type ChangeEvent, useRef } from 'react'
2
2
  import { useTranslation } from 'react-i18next'
3
3
 
4
4
  import { DropdownActions } from '@/src/lib/components'
5
- import { useChatFileUpload } from '@/src/lib/hooks/use-chat-file-upload'
6
- import { FILE_TYPES, FILE_TYPES_KEYS } from '@/src/lib/hooks/use-chat-file-upload/constants'
7
- import type { FileType } from '@/src/lib/hooks/use-chat-file-upload/types'
5
+ import {
6
+ FILE_TYPES,
7
+ FILE_TYPES_KEYS
8
+ } from '@/src/modules/messages/hooks/use-chat-file-upload/constants'
9
+ import type { FileType } from '../../types'
8
10
 
9
- import type { ChatFileUploaderProps } from './types'
11
+ const acceptImageTypes = FILE_TYPES.image.join()
12
+ const acceptFileTypes = [FILE_TYPES.document, FILE_TYPES.spreadsheet, FILE_TYPES.pdf].flat().join()
10
13
 
11
- function ChatFileUploader({ disabled = false, loading = false }: ChatFileUploaderProps) {
14
+ export type ChatFileUploaderProps = {
15
+ onUploadFile: (file: File, type: FileType) => void
16
+ disabled?: boolean
17
+ }
18
+
19
+ function ChatFileUploader({ onUploadFile, disabled = false }: ChatFileUploaderProps) {
12
20
  const { t } = useTranslation()
13
21
  const fileInputRef = useRef<HTMLInputElement>(null)
14
22
  const imageInputRef = useRef<HTMLInputElement>(null)
15
23
 
16
- const acceptFileTypes = [FILE_TYPES.document, FILE_TYPES.spreadsheet, FILE_TYPES.pdf]
17
- .flat()
18
- .join()
19
- const acceptImageTypes = FILE_TYPES.image.join()
20
-
21
- const { handleSelectFile, selectedFile } = useChatFileUpload()
22
-
23
24
  const triggerFileInput = (type: FileType) => {
24
25
  const inputRef = type === FILE_TYPES_KEYS.DOCUMENT ? fileInputRef : imageInputRef
25
26
  inputRef.current?.click()
@@ -28,7 +29,7 @@ function ChatFileUploader({ disabled = false, loading = false }: ChatFileUploade
28
29
  const handleFileInputChange = (e: ChangeEvent<HTMLInputElement>, type: FileType) => {
29
30
  const file = e.target.files?.[0]
30
31
  if (file) {
31
- handleSelectFile(file, type)
32
+ onUploadFile(file, type)
32
33
  }
33
34
  e.target.value = ''
34
35
  }
@@ -53,9 +54,10 @@ function ChatFileUploader({ disabled = false, loading = false }: ChatFileUploade
53
54
  aria-hidden='true'
54
55
  aria-label={t('general.buttons.image')}
55
56
  />
57
+
56
58
  <DropdownActions
57
59
  triggerIcon='plus'
58
- disabled={disabled || loading || selectedFile !== null}
60
+ disabled={disabled}
59
61
  items={[
60
62
  {
61
63
  label: 'general.buttons.file',
@@ -0,0 +1,34 @@
1
+ import type { PropsWithChildren } from 'react'
2
+
3
+ import { useChatFileUpload } from '@/src/modules/messages/hooks/use-chat-file-upload'
4
+ import { useAttachedFileAtom } from '../../store/attached-file.atom'
5
+ import { ChatFilePreview } from '../chat-file-preview'
6
+ import { ChatFileUploader } from '../chat-file-uploader'
7
+
8
+ function ChatFileUploaderWrapper({ children }: PropsWithChildren) {
9
+ const { handleUploadFile, handleRemoveFile, isUploading } = useChatFileUpload()
10
+ const [attachedFileAtom] = useAttachedFileAtom()
11
+
12
+ return (
13
+ <div className='flex flex-col gap-2'>
14
+ {attachedFileAtom && (
15
+ <ChatFilePreview
16
+ name={attachedFileAtom.name}
17
+ size={attachedFileAtom.size}
18
+ type={attachedFileAtom.type}
19
+ imageUrl={attachedFileAtom.previewUrl}
20
+ isLoading={isUploading}
21
+ onClose={handleRemoveFile}
22
+ showCloseButton
23
+ />
24
+ )}
25
+
26
+ <div className='flex flex-row gap-2'>
27
+ <ChatFileUploader onUploadFile={handleUploadFile} disabled={Boolean(attachedFileAtom)} />
28
+ <div className='flex-1'>{children}</div>
29
+ </div>
30
+ </div>
31
+ )
32
+ }
33
+
34
+ export default ChatFileUploaderWrapper
@@ -0,0 +1 @@
1
+ export { default as ChatFileUploaderWrapper } from './chat-file-uploader-wrapper'
@@ -1,6 +1,8 @@
1
1
  import clsx from 'clsx'
2
2
 
3
3
  import type { ParsedMessage } from '../../types'
4
+ import { ChatFilePreview } from '../chat-file-preview'
5
+ import type { FilePreviewTypes } from '../chat-file-preview/types'
4
6
  import { MessageActions } from '../message-actions'
5
7
  import { MessageContentTypeRenderer } from '../message-content-type-renderer'
6
8
 
@@ -19,6 +21,14 @@ function MessageItem({ message }: { message: ParsedMessage }) {
19
21
  'self-end': messageFromUser
20
22
  }
21
23
  )}>
24
+ {message?.file && message?.metadata && (
25
+ <ChatFilePreview
26
+ name={message.file.name}
27
+ size={message.file.size}
28
+ imageUrl={message.file.imagePreviewUrl || ''}
29
+ type={message.metadata.context?.document_type?.toLocaleLowerCase() as FilePreviewTypes}
30
+ />
31
+ )}
22
32
  <div
23
33
  data-test='messages-item'
24
34
  className={clsx('w-full overflow-x-hidden rounded-lg p-3', {
@@ -6,5 +6,5 @@ export const MessagesEndpoints = {
6
6
  `${process.env.API_CONVERSATION_URL}/v1/conversations/${conversationId}/messages`,
7
7
  create: (conversationId: string) =>
8
8
  `${process.env.API_CONVERSATION_URL}/v1/conversations/${conversationId}/messages`,
9
- getSignedUrls: () => `${process.env.API_HOTMART_TUTOR}/api/v1/files/signed-urls`
9
+ getFilesSignedUrls: () => `${process.env.API_HOTMART_TUTOR}/api/v1/files/signed-urls`
10
10
  }
@@ -9,3 +9,5 @@ export const FILE_TYPES_KEYS = {
9
9
  DOCUMENT: 'document',
10
10
  IMAGE: 'image'
11
11
  } as const
12
+
13
+ export const MAX_FILE_SIZE = 15 * 1024 * 1024
@@ -0,0 +1,19 @@
1
+ import { renderHook, waitFor } from '@/src/config/tests'
2
+
3
+ import { useChatFileUpload } from './use-chat-file-upload'
4
+
5
+ describe('useChatFileUpload', () => {
6
+ beforeEach(() => {
7
+ vi.clearAllMocks()
8
+ })
9
+
10
+ const createHook = () => renderHook(() => useChatFileUpload())
11
+
12
+ it('should return correct values', async () => {
13
+ const { result } = createHook()
14
+
15
+ await waitFor(() => expect(result.current).toBeDefined())
16
+
17
+ expect(result.current.isUploading).toBe(false)
18
+ })
19
+ })
@@ -0,0 +1,102 @@
1
+ import { useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { Toast, ToastUtils } from '@/src/lib/utils'
5
+ import { useUploadFile } from '@/src/modules/messages/hooks/use-upload-file'
6
+ import { useWidgetSettingsAtomValue } from '@/src/modules/widget'
7
+ import { formatFileSize } from '../../components/chat-file-preview/utils'
8
+ import { useAttachedFileAtom } from '../../store/attached-file.atom'
9
+ import type { FileType, IAttachedFile } from '../../types'
10
+ import { useGetFilesSignedUrls } from '../use-get-files-signed-urls'
11
+
12
+ import { FILE_TYPES_KEYS, MAX_FILE_SIZE } from './constants'
13
+
14
+ export type UseChatFileUploadReturn = {
15
+ handleUploadFile: (file: File, type: FileType) => void
16
+ handleRemoveFile: () => void
17
+ isUploading: boolean
18
+ }
19
+
20
+ export function useChatFileUpload(): UseChatFileUploadReturn {
21
+ const { t } = useTranslation()
22
+ const [isUploading, setIsUploading] = useState<boolean>(false)
23
+ const settings = useWidgetSettingsAtomValue()
24
+ const [attachedFileAtom, setAttachedFileAtom] = useAttachedFileAtom()
25
+ const uploadFile = useUploadFile()
26
+ const getSignedUrls = useGetFilesSignedUrls({
27
+ chatId: settings?.conversationId || '',
28
+ productId: settings?.config?.metadata?.agentProductId || 0
29
+ })
30
+
31
+ const handleUploadFile = (file: File, type: FileType) => {
32
+ if (file.size > MAX_FILE_SIZE) {
33
+ ToastUtils.dispatch({
34
+ type: Toast.DANGER,
35
+ message: t('send_message.file_upload.error.file_size_exceeded', {
36
+ max_file_size: formatFileSize(MAX_FILE_SIZE)
37
+ })
38
+ })
39
+ return
40
+ }
41
+
42
+ const errorMessageLabel = `send_message.file_upload.error.upload_${type === FILE_TYPES_KEYS.IMAGE ? 'image' : 'file'}`
43
+
44
+ setIsUploading(true)
45
+
46
+ const newFile: IAttachedFile = {
47
+ file,
48
+ type,
49
+ name: file.name,
50
+ size: file.size
51
+ }
52
+
53
+ if (type === FILE_TYPES_KEYS.IMAGE) {
54
+ newFile.previewUrl = URL.createObjectURL(file)
55
+ }
56
+
57
+ setAttachedFileAtom(newFile)
58
+
59
+ getSignedUrls.mutate(
60
+ { fileExtension: file.name.split('.').pop() || '', fileNameWithExtension: file.name },
61
+ {
62
+ onSuccess: (data) => {
63
+ setAttachedFileAtom((prevAttachedFile) => ({
64
+ ...(prevAttachedFile as IAttachedFile),
65
+ downloadUrl: data.futureDownloadUrl,
66
+ imagePreviewUrl: data.imagePreviewUrl
67
+ }))
68
+ uploadFile.mutate(
69
+ { signedUrl: data.uploadUrl, file },
70
+ {
71
+ onSuccess: () => {
72
+ setIsUploading(false)
73
+ },
74
+ onError: () => {
75
+ handleRemoveFile()
76
+ ToastUtils.dispatch({ message: t(errorMessageLabel), type: 'danger' })
77
+ }
78
+ }
79
+ )
80
+ },
81
+ onError: () => {
82
+ handleRemoveFile()
83
+ ToastUtils.dispatch({ message: t(errorMessageLabel), type: 'danger' })
84
+ }
85
+ }
86
+ )
87
+ }
88
+
89
+ const handleRemoveFile = () => {
90
+ if (attachedFileAtom?.previewUrl) {
91
+ URL.revokeObjectURL(attachedFileAtom.previewUrl)
92
+ }
93
+ setAttachedFileAtom(null)
94
+ setIsUploading(false)
95
+ }
96
+
97
+ return {
98
+ handleUploadFile,
99
+ handleRemoveFile,
100
+ isUploading
101
+ }
102
+ }
@@ -0,0 +1 @@
1
+ export * from './use-get-files-signed-urls'
@@ -1,20 +1,20 @@
1
1
  import { renderHook, waitFor } from '@/src/config/tests'
2
2
 
3
- import type { UseGetSignedUrlsProps } from './use-get-signed-urls'
4
- import { useGetSignedUrls } from './use-get-signed-urls'
3
+ import type { UseGetFilesSignedUrlsProps } from './use-get-files-signed-urls'
4
+ import { useGetFilesSignedUrls } from './use-get-files-signed-urls'
5
5
 
6
- describe('useGetSignedUrls', () => {
7
- const props: UseGetSignedUrlsProps = {
6
+ describe('useGetFilesSignedUrls', () => {
7
+ const props: UseGetFilesSignedUrlsProps = {
8
8
  chatId: 'chat-id',
9
- fileExtension: 'pdf',
10
- fileNameWithExtension: 'file.pdf',
11
9
  productId: 123
12
10
  }
13
- const render = () => renderHook(() => useGetSignedUrls(props))
11
+ const render = () => renderHook(() => useGetFilesSignedUrls(props))
14
12
 
15
13
  it('should get signed upload url', async () => {
16
14
  const { result } = render()
17
15
 
16
+ result.current.mutate({ fileExtension: 'png', fileNameWithExtension: 'image.png' })
17
+
18
18
  await waitFor(() => expect(result.current.isSuccess).toBeTruthy())
19
19
 
20
20
  expect(result.current.data).toMatchObject({
@@ -0,0 +1,25 @@
1
+ import { useMutation } from '@tanstack/react-query'
2
+
3
+ import DirectMessagesService from '../../service.direct'
4
+
5
+ export type UseGetFilesSignedUrlsProps = {
6
+ chatId: string
7
+ productId: number
8
+ }
9
+
10
+ export type UseGetFilesSignedUrlsMutationProps = {
11
+ fileExtension: string
12
+ fileNameWithExtension: string
13
+ }
14
+
15
+ export function useGetFilesSignedUrls({ chatId, productId }: UseGetFilesSignedUrlsProps) {
16
+ return useMutation({
17
+ mutationFn: ({ fileExtension, fileNameWithExtension }: UseGetFilesSignedUrlsMutationProps) =>
18
+ DirectMessagesService.getFilesSignedUrls({
19
+ chatId,
20
+ fileExtension,
21
+ fileNameWithExtension,
22
+ productId
23
+ })
24
+ })
25
+ }
@@ -15,6 +15,7 @@ import {
15
15
  } from '@/src/modules/widget'
16
16
  import { MessagesEvents } from '../../events'
17
17
  import { useMessagesMaxCount } from '../../store'
18
+ import { useAttachedFileAtom } from '../../store/attached-file.atom'
18
19
  import { setMessagesCache } from '../../utils'
19
20
  import { getAllMessagesQuery } from '../use-infinite-get-messages'
20
21
 
@@ -25,6 +26,7 @@ function useSendTextMessage() {
25
26
  const queryClient = useQueryClient()
26
27
  const limit = useMessagesMaxCount()
27
28
  const isAgentMode = useIsAgentParentAtomValue()
29
+ const [attachedFileAtom, setAttachedFileAtom] = useAttachedFileAtom()
28
30
 
29
31
  const userId = useMemo(() => profileQuery.data?.userId?.toString(), [profileQuery.data?.userId])
30
32
  const profileId = useMemo(() => profileQuery.data?.id?.toString(), [profileQuery.data?.id])
@@ -60,7 +62,16 @@ function useSendTextMessage() {
60
62
  message: {
61
63
  content: {
62
64
  type: 'text/plain',
63
- text: processedMessage
65
+ text: processedMessage,
66
+ ...(attachedFileAtom
67
+ ? {
68
+ file: {
69
+ name: attachedFileAtom.name,
70
+ size: attachedFileAtom.size,
71
+ imagePreviewUrl: attachedFileAtom.imagePreviewUrl
72
+ }
73
+ }
74
+ : {})
64
75
  },
65
76
  metadata: {
66
77
  author: 'user',
@@ -88,7 +99,13 @@ function useSendTextMessage() {
88
99
  source: settings.config?.metadata?.source,
89
100
  prompt: settings.config?.metadata?.promptId,
90
101
  membershipSlug: settings.membershipSlug,
91
- membershipId: settings.membershipId
102
+ membershipId: settings.membershipId,
103
+ ...(attachedFileAtom
104
+ ? {
105
+ document_type: attachedFileAtom.type.toUpperCase(),
106
+ document_url: attachedFileAtom.downloadUrl
107
+ }
108
+ : {})
92
109
  },
93
110
  externalId: v4(),
94
111
  namespace: settings.namespace,
@@ -115,6 +132,7 @@ function useSendTextMessage() {
115
132
  },
116
133
  onSuccess(data) {
117
134
  setMessagesCache({ queryKey: messagesQueryConfig.queryKey, queryClient, data })()
135
+ setAttachedFileAtom(null)
118
136
  },
119
137
  onSettled(data, error) {
120
138
  DataHubService.sendEvent({
@@ -0,0 +1 @@
1
+ export * from './use-upload-file'
@@ -0,0 +1,15 @@
1
+ import { useMutation } from '@tanstack/react-query'
2
+
3
+ import DirectMessagesService from '../../service.direct'
4
+
5
+ export type UseUploadFileMutationProps = {
6
+ signedUrl: string
7
+ file: File
8
+ }
9
+
10
+ export function useUploadFile() {
11
+ return useMutation({
12
+ mutationFn: ({ signedUrl, file }: UseUploadFileMutationProps) =>
13
+ DirectMessagesService.uploadFile({ signedUrl, file })
14
+ })
15
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Message } from '@hotmart-org-ca/sparkie/dist/MessageService'
2
+ import axios from 'axios'
2
3
 
3
4
  import { api } from '@/src/config/request'
4
5
 
@@ -6,9 +7,10 @@ import { MessagesEndpoints } from './constants'
6
7
  import type {
7
8
  DirectMessagesServiceProps,
8
9
  FetchMessagesResponse,
9
- IGetSignedUrlsPayload,
10
- IGetSignedUrlsResponse,
11
- IMessageWithSenderData
10
+ IGetFilesSignedUrlsPayload,
11
+ IGetFilesSignedUrlsResponse,
12
+ IMessageWithSenderData,
13
+ IUploadFilePayload
12
14
  } from './types'
13
15
 
14
16
  class DirectMessagesService {
@@ -39,17 +41,32 @@ class DirectMessagesService {
39
41
  return data
40
42
  }
41
43
 
42
- async getSignedUrls({
44
+ async getFilesSignedUrls({
43
45
  productId,
44
46
  chatId,
45
47
  fileExtension,
46
48
  fileNameWithExtension
47
- }: IGetSignedUrlsPayload): Promise<IGetSignedUrlsResponse> {
48
- const { data } = await api.post<IGetSignedUrlsResponse>(MessagesEndpoints.getSignedUrls(), {
49
- productId,
50
- chatId,
51
- fileExtension,
52
- fileNameWithExtension
49
+ }: IGetFilesSignedUrlsPayload): Promise<IGetFilesSignedUrlsResponse> {
50
+ const { data } = await api.post<IGetFilesSignedUrlsResponse>(
51
+ MessagesEndpoints.getFilesSignedUrls(),
52
+ {
53
+ productId,
54
+ chatId,
55
+ fileExtension,
56
+ fileNameWithExtension
57
+ }
58
+ )
59
+
60
+ return data
61
+ }
62
+
63
+ async uploadFile({ signedUrl, file }: IUploadFilePayload) {
64
+ // New Axios instance without Bearer Token
65
+ const api = axios.create()
66
+ const { data } = await api.put<IGetFilesSignedUrlsResponse>(signedUrl, file, {
67
+ headers: {
68
+ 'Content-Type': file.type
69
+ }
53
70
  })
54
71
 
55
72
  return data
@@ -0,0 +1,7 @@
1
+ import { atom, useAtom } from 'jotai'
2
+
3
+ import type { IAttachedFile } from '../types'
4
+
5
+ const attachedFileAtom = atom<IAttachedFile | null>(null)
6
+
7
+ export const useAttachedFileAtom = () => useAtom(attachedFileAtom)
@@ -10,6 +10,17 @@ export type IMessage = SparkieMsg & {
10
10
  sessionId: string
11
11
  externalId: string
12
12
  correlationId: string
13
+ context?: {
14
+ ['document_type']: string
15
+ ['document_url']: string
16
+ }
17
+ }
18
+ content: MessageContent & {
19
+ file?: {
20
+ name: string
21
+ size: number
22
+ imagePreviewUrl?: string
23
+ }
13
24
  }
14
25
  sending?: boolean // indicates when the current message is being sent
15
26
  }
@@ -19,13 +30,18 @@ export type IGetMessagesPayload = {
19
30
  before?: number
20
31
  }
21
32
 
22
- export type IGetSignedUrlsPayload = {
33
+ export type IGetFilesSignedUrlsPayload = {
23
34
  chatId: string
24
35
  fileExtension: string
25
36
  fileNameWithExtension: string
26
37
  productId: number
27
38
  }
28
39
 
40
+ export type IUploadFilePayload = {
41
+ signedUrl: string
42
+ file: File
43
+ }
44
+
29
45
  export type ISendTextMessagePayload = {
30
46
  conversationId: string
31
47
  content: {
@@ -70,13 +86,25 @@ export type MessageParserArgs = {
70
86
  profileId: string
71
87
  }
72
88
 
73
- export type IGetSignedUrlsResponse = {
89
+ export type IGetFilesSignedUrlsResponse = {
74
90
  uploadUrl: string
75
91
  futureDownloadUrl: string
76
92
  imagePreviewUrl: string
77
93
  fileNameWithExtension: string
78
94
  }
79
95
 
96
+ export type FileType = 'document' | 'image'
97
+
98
+ export type IAttachedFile = {
99
+ file: File
100
+ type: FileType
101
+ name: string
102
+ size: number
103
+ previewUrl?: string
104
+ downloadUrl?: string
105
+ imagePreviewUrl?: string
106
+ }
107
+
80
108
  export type ParsedMessage = {
81
109
  from: string
82
110
  id: string
@@ -1,12 +1,14 @@
1
- import { useCallback, useEffect, useMemo, useRef } from 'react'
1
+ import { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'
2
2
  import { useInfiniteQuery } from '@tanstack/react-query'
3
3
 
4
4
  import { useMediaQuery } from '@/src/lib/hooks'
5
5
  import { isTextEmpty } from '@/src/lib/utils/is-text-empty'
6
6
  import { ChatInput, MessagesList, useChatInputValueAtom } from '@/src/modules/messages/components'
7
+ import { ChatFileUploaderWrapper } from '@/src/modules/messages/components/chat-file-uploader-wrapper'
7
8
  import { MessagesContainer } from '@/src/modules/messages/components/messages-container'
8
9
  import { getAllMessagesQuery, useSendTextMessage } from '@/src/modules/messages/hooks'
9
10
  import { useMessagesMaxCount } from '@/src/modules/messages/store'
11
+ import { useAttachedFileAtom } from '@/src/modules/messages/store/attached-file.atom'
10
12
  import { useGetProfile } from '@/src/modules/profile'
11
13
  import { TutorWidgetEvents } from '../../events'
12
14
  import { useSendViewChatEvent } from '../../hooks'
@@ -25,6 +27,7 @@ function ChatPage() {
25
27
  const chatInputRef = useRef<HTMLTextAreaElement>(null)
26
28
  const scrollerRef = useRef<HTMLDivElement>(null)
27
29
  const settings = useWidgetSettingsAtomValue()
30
+ const [attachedFileAtom] = useAttachedFileAtom()
28
31
  const profileQuery = useGetProfile()
29
32
  const widgetTabs = useWidgetTabsValueAtom()
30
33
  const sendTextMessageMutation = useSendTextMessage()
@@ -101,18 +104,29 @@ function ChatPage() {
101
104
  }
102
105
  }, [messagesQuery.isError, setWidgetLoading])
103
106
 
107
+ const ChatInputWrapper = settings?.config?.metadata?.showFileUpload
108
+ ? ChatFileUploaderWrapper
109
+ : Fragment
110
+
104
111
  return (
105
112
  <PageLayout
106
113
  asideChild={
107
114
  <>
108
- <ChatInput
109
- name='new-chat-msg-input'
110
- ref={chatInputRef}
111
- onSend={widgetTabs.currentTab === 'chat' ? handleSendMessage : undefined}
112
- loading={sendTextMessageMutation.isPending}
113
- inputDisabled={messagesQuery?.isLoading}
114
- buttonDisabled={widgetLoading || messagesQuery?.isLoading || !value.trim()}
115
- />
115
+ <ChatInputWrapper>
116
+ <ChatInput
117
+ name='new-chat-msg-input'
118
+ ref={chatInputRef}
119
+ onSend={widgetTabs.currentTab === 'chat' ? handleSendMessage : undefined}
120
+ loading={sendTextMessageMutation.isPending}
121
+ inputDisabled={messagesQuery?.isLoading}
122
+ buttonDisabled={
123
+ widgetLoading ||
124
+ messagesQuery?.isLoading ||
125
+ !value.trim() ||
126
+ (Boolean(attachedFileAtom) && !attachedFileAtom?.downloadUrl)
127
+ }
128
+ />
129
+ </ChatInputWrapper>
116
130
 
117
131
  <div className='mx-auto w-fit'>
118
132
  <AIDisclaimer />
@@ -1,14 +0,0 @@
1
- export type FileType = 'document' | 'image'
2
-
3
- export type SelectedFile = {
4
- file: File
5
- type: FileType
6
- name: string
7
- size: number
8
- previewUrl?: string
9
- }
10
-
11
- export type UseFileUploadReturn = {
12
- selectedFile: SelectedFile | null
13
- handleSelectFile: (file: File, type: FileType) => void
14
- }
@@ -1,59 +0,0 @@
1
- import { act, renderHook } from '@/src/config/tests'
2
-
3
- import { useChatFileUpload } from './use-chat-file-upload'
4
-
5
- describe('useChatFileUpload', () => {
6
- beforeEach(() => {
7
- global.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
8
- })
9
-
10
- afterEach(() => {
11
- vi.clearAllMocks()
12
- })
13
-
14
- it('should initialize with default values', () => {
15
- const { result } = renderHook(() => useChatFileUpload())
16
-
17
- expect(result.current.selectedFile).toBeNull()
18
- })
19
-
20
- describe('when selecting a document file', () => {
21
- it('should store the file with correct properties', () => {
22
- const { result } = renderHook(() => useChatFileUpload())
23
-
24
- const mockFile = new File(['content'], 'document.pdf', { type: 'application/pdf' })
25
-
26
- act(() => {
27
- result.current.handleSelectFile(mockFile, 'document')
28
- })
29
-
30
- expect(result.current.selectedFile).toEqual({
31
- file: mockFile,
32
- type: 'document',
33
- name: 'document.pdf',
34
- size: mockFile.size
35
- })
36
- })
37
- })
38
-
39
- describe('when selecting an image file', () => {
40
- it('should create a preview URL', () => {
41
- const { result } = renderHook(() => useChatFileUpload())
42
-
43
- const mockImage = new File(['image'], 'photo.jpg', { type: 'image/jpeg' })
44
-
45
- act(() => {
46
- result.current.handleSelectFile(mockImage, 'image')
47
- })
48
-
49
- expect(result.current.selectedFile).toEqual({
50
- file: mockImage,
51
- type: 'image',
52
- name: 'photo.jpg',
53
- size: mockImage.size,
54
- previewUrl: 'blob:mock-url'
55
- })
56
- expect(global.URL.createObjectURL).toHaveBeenCalledWith(mockImage)
57
- })
58
- })
59
- })
@@ -1,28 +0,0 @@
1
- import { useState } from 'react'
2
-
3
- import { FILE_TYPES_KEYS } from './constants'
4
- import type { FileType, SelectedFile, UseFileUploadReturn } from './types'
5
-
6
- export function useChatFileUpload(): UseFileUploadReturn {
7
- const [selectedFile, setSelectedFile] = useState<SelectedFile | null>(null)
8
-
9
- const handleSelectFile = (file: File, type: FileType) => {
10
- const newFile: SelectedFile = {
11
- file,
12
- type,
13
- name: file.name,
14
- size: file.size
15
- }
16
-
17
- if (type === FILE_TYPES_KEYS.IMAGE) {
18
- newFile.previewUrl = URL.createObjectURL(file)
19
- }
20
-
21
- setSelectedFile(newFile)
22
- }
23
-
24
- return {
25
- selectedFile,
26
- handleSelectFile
27
- }
28
- }
@@ -1,4 +0,0 @@
1
- export type ChatFileUploaderProps = {
2
- disabled?: boolean
3
- loading?: boolean
4
- }
@@ -1 +0,0 @@
1
- export * from './use-get-signed-urls'
@@ -1,38 +0,0 @@
1
- import { useQuery } from '@tanstack/react-query'
2
-
3
- import { MessagesEndpoints } from '../../constants'
4
- import DirectMessagesService from '../../service.direct'
5
-
6
- export type UseGetSignedUrlsProps = {
7
- chatId: string
8
- fileExtension: string
9
- fileNameWithExtension: string
10
- productId: number
11
- enabled?: boolean
12
- }
13
-
14
- export function useGetSignedUrls({
15
- chatId,
16
- fileExtension,
17
- fileNameWithExtension,
18
- productId,
19
- enabled
20
- }: UseGetSignedUrlsProps) {
21
- return useQuery({
22
- queryKey: [
23
- MessagesEndpoints.getSignedUrls(),
24
- chatId,
25
- fileExtension,
26
- fileNameWithExtension,
27
- productId
28
- ],
29
- queryFn: () =>
30
- DirectMessagesService.getSignedUrls({
31
- chatId,
32
- fileExtension,
33
- fileNameWithExtension,
34
- productId
35
- }),
36
- enabled
37
- })
38
- }