app-tutor-ai-consumer 1.3.0 → 1.4.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 (61) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/config/vitest/setupTests.ts +6 -1
  3. package/package.json +11 -2
  4. package/public/assets/images/default-image.png +0 -0
  5. package/src/@types/declarations.d.ts +12 -3
  6. package/src/config/dayjs/index.ts +2 -0
  7. package/src/config/dayjs/init.ts +28 -0
  8. package/src/config/dayjs/utils/format-fulldate.ts +7 -0
  9. package/src/config/dayjs/utils/format-time.ts +20 -0
  10. package/src/config/dayjs/utils/index.ts +2 -0
  11. package/src/config/styles/global.css +19 -1
  12. package/src/config/tanstack/query-provider.tsx +1 -1
  13. package/src/config/tests/handlers.ts +6 -0
  14. package/src/index.tsx +4 -0
  15. package/src/lib/components/index.ts +1 -0
  16. package/src/lib/components/markdownrenderer/__tests__/markdown.stub.ts +334 -0
  17. package/src/lib/components/markdownrenderer/components/index.ts +1 -0
  18. package/src/lib/components/markdownrenderer/components/md-code-block/index.ts +1 -0
  19. package/src/lib/components/markdownrenderer/components/md-code-block/md-code-block.tsx +71 -0
  20. package/src/lib/components/markdownrenderer/index.ts +2 -0
  21. package/src/lib/components/markdownrenderer/markdownrenderer.tsx +115 -0
  22. package/src/lib/utils/constants.ts +1 -1
  23. package/src/lib/utils/copy-text-to-clipboard.tsx +13 -0
  24. package/src/lib/utils/extract-text-from-react-nodes.ts +23 -0
  25. package/src/lib/utils/index.ts +3 -0
  26. package/src/lib/utils/urls.ts +20 -0
  27. package/src/modules/messages/__tests__/imessage-with-sender-data.builder.ts +113 -0
  28. package/src/modules/messages/__tests__/imessage-with-sender-data.mock.ts +15 -0
  29. package/src/modules/messages/components/index.ts +2 -0
  30. package/src/modules/messages/components/message-img/index.ts +1 -0
  31. package/src/modules/messages/components/message-img/message-img.tsx +47 -0
  32. package/src/modules/messages/components/message-item/index.ts +2 -0
  33. package/src/modules/messages/components/message-item/message-item.spec.tsx +26 -0
  34. package/src/modules/messages/components/message-item/message-item.tsx +15 -0
  35. package/src/modules/messages/components/messages-list/messages-list.tsx +46 -17
  36. package/src/modules/messages/hooks/index.ts +1 -0
  37. package/src/modules/messages/hooks/use-fetch-messages/index.ts +2 -0
  38. package/src/modules/messages/hooks/use-fetch-messages/use-fetch-messages.spec.tsx +46 -0
  39. package/src/modules/messages/hooks/use-fetch-messages/use-fetch-messages.tsx +103 -0
  40. package/src/modules/messages/service.ts +26 -3
  41. package/src/modules/messages/types.ts +33 -2
  42. package/src/modules/messages/utils/index.ts +1 -0
  43. package/src/modules/messages/utils/messages-parser/index.ts +1 -0
  44. package/src/modules/messages/utils/messages-parser/utils.ts +28 -0
  45. package/src/modules/profile/__tests__/profile-api-props.builder.ts +74 -0
  46. package/src/modules/profile/__tests__/profile-props.builder.ts +42 -0
  47. package/src/modules/profile/constants.ts +3 -0
  48. package/src/modules/profile/hooks/index.ts +1 -0
  49. package/src/modules/profile/hooks/use-get-profile/index.ts +1 -0
  50. package/src/modules/profile/hooks/use-get-profile/use-get-profile.spec.tsx +20 -0
  51. package/src/modules/profile/hooks/use-get-profile/use-get-profile.tsx +14 -0
  52. package/src/modules/profile/index.ts +4 -0
  53. package/src/modules/profile/service.tsx +19 -0
  54. package/src/modules/profile/types.ts +17 -0
  55. package/src/modules/widget/components/chat-page/chat-page.tsx +3 -3
  56. package/src/modules/widget/components/container/container.tsx +1 -1
  57. package/src/modules/widget/components/onboarding-page/onboarding-page.tsx +16 -14
  58. package/src/modules/widget/components/starter-page/starter-page.tsx +3 -3
  59. package/src/modules/widget/store/widget-settings.atom.ts +3 -1
  60. package/src/config/styles/shared-styles.module.css +0 -16
  61. package/src/modules/widget/components/container/styles.module.css +0 -11
@@ -0,0 +1,115 @@
1
+ import clsx from 'clsx'
2
+ import Markdown, { type Components } from 'react-markdown'
3
+ import rehypeRaw from 'rehype-raw'
4
+ import rehypeSanitize from 'rehype-sanitize'
5
+ import remarkBreaks from 'remark-breaks'
6
+ import remarkGfm from 'remark-gfm'
7
+
8
+ import { URLutils } from '../../utils'
9
+
10
+ import MdCodeBlock from './components/md-code-block'
11
+
12
+ const mdComponents: Partial<Components> = {
13
+ h1({ children, ...props }) {
14
+ return (
15
+ <h1 className='mb-4 border-b pb-2 text-2xl font-bold' {...props}>
16
+ {children}
17
+ </h1>
18
+ )
19
+ },
20
+ h2({ children, ...props }) {
21
+ return (
22
+ <h2 className='mb-3 mt-6 text-xl font-semibold' {...props}>
23
+ {children}
24
+ </h2>
25
+ )
26
+ },
27
+ code(props) {
28
+ return <MdCodeBlock {...props} />
29
+ },
30
+ pre({ children }) {
31
+ return (
32
+ <span className='my-2 inline-block w-full overflow-hidden rounded-lg border'>{children}</span>
33
+ )
34
+ },
35
+ a({ href, children, ...props }) {
36
+ const url = URLutils.getURLwithProtocol(href)
37
+
38
+ return (
39
+ <a
40
+ href={url}
41
+ target={url?.startsWith('http') ? '_blank' : '_self'}
42
+ rel={url?.startsWith('http') ? 'noopener noreferrer' : undefined}
43
+ className='text-blue-600 underline hover:text-blue-800'
44
+ {...props}>
45
+ {children}
46
+ </a>
47
+ )
48
+ },
49
+ table({ children, ...props }) {
50
+ return (
51
+ <div className='overflow-x-auto'>
52
+ <table className='min-w-full border-collapse border border-neutral-300' {...props}>
53
+ {children}
54
+ </table>
55
+ </div>
56
+ )
57
+ },
58
+ th({ children, ...props }) {
59
+ return (
60
+ <th className='border border-neutral-300 px-4 py-2 text-left font-semibold' {...props}>
61
+ {children}
62
+ </th>
63
+ )
64
+ },
65
+ td({ children, ...props }) {
66
+ return (
67
+ <td className='border border-neutral-300 px-4 py-2' {...props}>
68
+ {children}
69
+ </td>
70
+ )
71
+ },
72
+ blockquote({ children, ...props }) {
73
+ return (
74
+ <blockquote
75
+ className='my-2 border-l-4 border-blue-500 pl-4 italic text-neutral-100'
76
+ {...props}>
77
+ {children}
78
+ </blockquote>
79
+ )
80
+ },
81
+ p({ children }) {
82
+ return <span className='my-2 inline-block'>{children}</span>
83
+ }
84
+ }
85
+
86
+ export type MarkdownRendererProps = {
87
+ content?: string
88
+ className?: string
89
+ allowDangerousHtml?: boolean
90
+ enableGfm?: boolean
91
+ imgComponent?: Components['img']
92
+ }
93
+ function MarkdownRenderer({
94
+ content,
95
+ allowDangerousHtml,
96
+ className,
97
+ enableGfm = true,
98
+ imgComponent
99
+ }: MarkdownRendererProps) {
100
+ const remarkPlugins = [...(enableGfm ? [remarkGfm] : []), remarkBreaks]
101
+ const rehypePlugins = [rehypeSanitize, ...(allowDangerousHtml ? [rehypeRaw] : [])]
102
+
103
+ return (
104
+ <div className={clsx('max-w-none', className)}>
105
+ <Markdown
106
+ remarkPlugins={remarkPlugins}
107
+ rehypePlugins={rehypePlugins}
108
+ components={!imgComponent ? mdComponents : { ...mdComponents, img: imgComponent }}>
109
+ {content}
110
+ </Markdown>
111
+ </div>
112
+ )
113
+ }
114
+
115
+ export default MarkdownRenderer
@@ -1,6 +1,6 @@
1
1
  export const devMode = process.env.NODE_ENV === 'development'
2
2
  export const productionMode = process.env.NODE_ENV === 'production'
3
3
  export const RELEASE_FULL_NAME = `${process.env.RELEASE_FULL_NAME}_${process.env.NODE_ENV}`
4
- export const DEFAULT_STALE_TIME = 1000 * 60 * 30 // 30 minutes
4
+ export const DEFAULT_STALE_TIME = 1000 * 60 * 3 // 3 minutes
5
5
  export const APP_VERSION = process.env.PROJECT_VERSION ?? ''
6
6
  export const APP_SYSTEM = 'Web'
@@ -0,0 +1,13 @@
1
+ export async function copyTextToClipboard(textToCopy: string) {
2
+ try {
3
+ if (navigator?.clipboard?.writeText) {
4
+ await navigator.clipboard.writeText(textToCopy)
5
+ return true
6
+ }
7
+ throw Error('Clipboard API not available or writeText method not supported.')
8
+ } catch (err) {
9
+ console.error('Failed to copy text:', err)
10
+ }
11
+
12
+ return false
13
+ }
@@ -0,0 +1,23 @@
1
+ import { isValidElement } from 'react'
2
+ import type { ReactElement, ReactNode } from 'react'
3
+
4
+ export type ElementWithProps = ReactElement & { props: { children: ReactNode } }
5
+
6
+ export const hasChildren = (element: ReactElement): element is ElementWithProps => {
7
+ const children = element as ElementWithProps
8
+
9
+ return children.props && 'children' in children.props
10
+ }
11
+
12
+ export const extractTextFromReactNodes = (children: ReactNode): string => {
13
+ if (typeof children === 'string' || typeof children === 'number') {
14
+ return String(children)
15
+ }
16
+
17
+ if (Array.isArray(children)) return children.map(extractTextFromReactNodes).join('')
18
+
19
+ if (isValidElement(children) && hasChildren(children))
20
+ return extractTextFromReactNodes(children.props.children)
21
+
22
+ return ''
23
+ }
@@ -1,4 +1,7 @@
1
1
  export * from './constants'
2
+ export * from './copy-text-to-clipboard'
3
+ export * from './extract-text-from-react-nodes'
2
4
  export { default as HttpCodes } from './http-codes'
3
5
  export * from './languages'
4
6
  export * from './message-types'
7
+ export { default as URLutils } from './urls'
@@ -0,0 +1,20 @@
1
+ import type { Match } from 'linkify-it'
2
+ import linkifyit from 'linkify-it'
3
+
4
+ class URLutils {
5
+ private linkify: linkifyit
6
+
7
+ constructor() {
8
+ this.linkify = new linkifyit()
9
+ }
10
+
11
+ getURLwithProtocol = (url?: string) => {
12
+ if (!url) return url
13
+
14
+ const [result = {} as Match] = this.linkify?.match(url) ?? []
15
+
16
+ return result?.url ?? `http://${url}`
17
+ }
18
+ }
19
+
20
+ export default new URLutils()
@@ -0,0 +1,113 @@
1
+ import type { MessageContent } from '@hotmart/sparkie/dist/MessageService'
2
+
3
+ import { chance } from '@/src/config/tests'
4
+ import type { IMessageWithSenderData } from '../types'
5
+
6
+ class IMessageWithSenderDataBuilder implements IMessageWithSenderData {
7
+ id: string
8
+ conversationId: string
9
+ threadId: string
10
+ contactId: string
11
+ type: string
12
+ channel: string
13
+ content: MessageContent
14
+ metadata: {
15
+ author: 'ai' | 'user'
16
+ sessionId: string
17
+ externalId: string
18
+ correlationId: string
19
+ }
20
+ sentAt: number
21
+ updatedAt: number
22
+ contact: { id: string; name?: string; picture?: string; userId?: number }
23
+ parentId?: string
24
+ deletedAt?: number
25
+ sending?: boolean
26
+
27
+ constructor() {
28
+ this.id = chance.guid()
29
+ this.conversationId = chance.guid()
30
+ this.threadId = chance.guid()
31
+ this.contactId = chance.guid()
32
+ this.type = chance.animal()
33
+ this.channel = chance.name()
34
+ this.contact = { id: chance.guid() }
35
+ this.content = { type: chance.cc_type(), text: chance.sentence() }
36
+ this.metadata = {
37
+ author: 'ai',
38
+ sessionId: chance.guid(),
39
+ externalId: chance.guid(),
40
+ correlationId: chance.guid()
41
+ }
42
+ this.sentAt = chance.date().getTime()
43
+ this.updatedAt = chance.date().getTime()
44
+ }
45
+
46
+ withId(id: typeof this.id) {
47
+ this.id = id
48
+
49
+ return this
50
+ }
51
+
52
+ withConversationId(conversationId: typeof this.conversationId) {
53
+ this.conversationId = conversationId
54
+
55
+ return this
56
+ }
57
+
58
+ withThreadId(threadId: typeof this.threadId) {
59
+ this.threadId = threadId
60
+
61
+ return this
62
+ }
63
+
64
+ withContactId(contactId: typeof this.contactId) {
65
+ this.contactId = contactId
66
+
67
+ return this
68
+ }
69
+
70
+ withType(type: typeof this.type) {
71
+ this.type = type
72
+
73
+ return this
74
+ }
75
+
76
+ withChannel(channel: typeof this.channel) {
77
+ this.channel = channel
78
+
79
+ return this
80
+ }
81
+
82
+ withContact(contact: typeof this.contact) {
83
+ this.contact = contact
84
+
85
+ return this
86
+ }
87
+
88
+ withContent(content: typeof this.content) {
89
+ this.content = content
90
+
91
+ return this
92
+ }
93
+
94
+ withMetadata(metadata: typeof this.metadata) {
95
+ this.metadata = metadata
96
+
97
+ return this
98
+ }
99
+
100
+ withSentAt(sentAt: typeof this.sentAt) {
101
+ this.sentAt = sentAt
102
+
103
+ return this
104
+ }
105
+
106
+ withUpdatedAt(updatedAt: typeof this.updatedAt) {
107
+ this.updatedAt = updatedAt
108
+
109
+ return this
110
+ }
111
+ }
112
+
113
+ export default IMessageWithSenderDataBuilder
@@ -0,0 +1,15 @@
1
+ import { MockGenerator } from '@/src/config/tests'
2
+ import type { IMessageWithSenderData } from '../types'
3
+
4
+ import IMessageWithSenderDataBuilder from './imessage-with-sender-data.builder'
5
+
6
+ class IMessageWithSenderDataMock extends MockGenerator<Partial<IMessageWithSenderData>> {
7
+ getOne(properties?: Partial<IMessageWithSenderData>): IMessageWithSenderData {
8
+ return {
9
+ ...new IMessageWithSenderDataBuilder(),
10
+ ...properties
11
+ }
12
+ }
13
+ }
14
+
15
+ export default IMessageWithSenderDataMock
@@ -1,2 +1,4 @@
1
1
  export * from './chat-input'
2
+ export * from './message-img'
3
+ export * from './message-item'
2
4
  export * from './messages-list'
@@ -0,0 +1 @@
1
+ export { default as MessageImg } from './message-img'
@@ -0,0 +1,47 @@
1
+ import { useState } from 'react'
2
+ import clsx from 'clsx'
3
+
4
+ import { Spinner } from '@/src/lib/components'
5
+ import type { ParsedMessage } from '../../types'
6
+
7
+ import DefaultImage from '@/public/assets/images/default-image.png'
8
+
9
+ const BASE_WIDTH = 200
10
+
11
+ export type MessageImgProps = {
12
+ message: ParsedMessage
13
+ }
14
+
15
+ function MessageImg({ message: { dimensions, thumbnails, url, name } }: MessageImgProps) {
16
+ const [isLoading, setIsLoading] = useState(true)
17
+
18
+ let height = BASE_WIDTH
19
+
20
+ if (!url) return null
21
+
22
+ const thumbURL = thumbnails?.md || thumbnails?.sm || thumbnails?.lg || url
23
+
24
+ if (dimensions?.width) {
25
+ height = (height / dimensions.width) * BASE_WIDTH
26
+ }
27
+
28
+ return (
29
+ <a href={url} target='_blank' rel='noopener noreferrer'>
30
+ {isLoading && <Spinner className={`h-12 w-12`} />}
31
+ <img
32
+ width={BASE_WIDTH}
33
+ height={height}
34
+ src={thumbURL}
35
+ alt={name}
36
+ className={clsx({ hidden: isLoading })}
37
+ onLoad={() => setIsLoading(false)}
38
+ onError={({ currentTarget }) => {
39
+ currentTarget.src = DefaultImage
40
+ setIsLoading(false)
41
+ }}
42
+ />
43
+ </a>
44
+ )
45
+ }
46
+
47
+ export default MessageImg
@@ -0,0 +1,2 @@
1
+ export * from './message-item'
2
+ export { default as MessageItem } from './message-item'
@@ -0,0 +1,26 @@
1
+ import { render, screen } from '@/src/config/tests'
2
+ import { TEST_MARKDOWN_STUB } from '@/src/lib/components/markdownrenderer/__tests__/markdown.stub'
3
+ import type { ParsedMessage } from '../../types'
4
+
5
+ import MessageItem from './message-item'
6
+
7
+ describe('MessageItem', () => {
8
+ const message = { text: TEST_MARKDOWN_STUB } as ParsedMessage
9
+
10
+ const renderComponent = (props = { message }) => render(<MessageItem {...props} />)
11
+
12
+ it('should render markdown as html', () => {
13
+ renderComponent()
14
+
15
+ const reactDocLink = screen.getByRole('link', { name: /External Link to React Documentation/i })
16
+
17
+ expect(reactDocLink).toBeInTheDocument()
18
+ expect(reactDocLink).toHaveAttribute('href', 'https://reactjs.org/docs/getting-started.html')
19
+ })
20
+
21
+ it('should render the custom image component', () => {
22
+ renderComponent()
23
+
24
+ expect(screen.getAllByRole('img')).toHaveLength(3)
25
+ })
26
+ })
@@ -0,0 +1,15 @@
1
+ import type { Components } from 'react-markdown'
2
+
3
+ import { MarkdownRenderer } from '@/src/lib/components'
4
+ import type { ParsedMessage } from '../../types'
5
+ import { MessageImg } from '../message-img'
6
+
7
+ const imgComponent: Components['img'] = ({ src }) => {
8
+ return <MessageImg message={{ thumbnails: {}, url: src, dimensions: {} } as ParsedMessage} />
9
+ }
10
+
11
+ function MessageItem({ message }: { message: ParsedMessage }) {
12
+ return <MarkdownRenderer content={message?.text ?? message?.name} imgComponent={imgComponent} />
13
+ }
14
+
15
+ export default MessageItem
@@ -1,22 +1,51 @@
1
+ import { useRef } from 'react'
2
+ import clsx from 'clsx'
3
+
4
+ import { useGetProfile } from '@/src/modules/profile'
5
+ import { useWidgetSettingsAtomValue } from '@/src/modules/widget'
6
+ import { useFetchMessages } from '../../hooks'
7
+ import { MessageItem } from '../message-item'
8
+
1
9
  function MessagesList() {
10
+ const loadFirstPage = useRef(true)
11
+ const currentMessages = useRef([])
12
+ const settings = useWidgetSettingsAtomValue()
13
+ const profileQuery = useGetProfile()
14
+
15
+ const messagesQuery = useFetchMessages({
16
+ conversationId: settings?.conversationId ?? '',
17
+ currentMessages: currentMessages.current,
18
+ loadFirstPage: loadFirstPage?.current ?? true,
19
+ profileId: profileQuery.data?.id ?? '',
20
+ enabled: Boolean(settings?.conversationId && profileQuery.data?.id)
21
+ })
22
+
23
+ if (messagesQuery.isLoading) return <h3>Loading...</h3>
24
+
2
25
  return (
3
- <div className='flex flex-1 flex-col items-start justify-center gap-6'>
4
- <div className='max-w-[80%] self-end rounded-lg bg-neutral-800 p-3 text-sm/normal text-neutral-0'>
5
- <span>Quero saber o que Tutor do BEM faz</span>
6
- </div>
7
- <div className='flex max-w-[80%] flex-col gap-1 rounded-lg bg-ai-chat-response p-3 text-sm/normal text-neutral-0'>
8
- <span>
9
- Sou seu assistente de IA, criado pelo Marcelo Horta, para te acompanhar durante todo o seu
10
- aprendizado. Confira o que posso fazer por você:
11
- </span>
12
- <span>
13
- 🤓 Dúvidas sobre as aulas:Me faça perguntas sobre o conteúdo das aulas e eu ajudo você a
14
- aprender melhor.
15
- </span>
16
- <span>
17
- 📝 ResumosPosso gerar um resumo do que foi visto na aula pra facilitar seus estudos.
18
- </span>
19
- </div>
26
+ <div className='flex flex-1 flex-col justify-center gap-6'>
27
+ {messagesQuery.data?.messages &&
28
+ Array.from(messagesQuery.data.messages).map(([publishingDate, messages], i) => (
29
+ <div key={i} className='flex flex-1 flex-col justify-center gap-6'>
30
+ <span className='self-center rounded-full border border-neutral-700 bg-neutral-800 px-4 py-2 text-xs capitalize text-neutral-50'>
31
+ {publishingDate}
32
+ </span>
33
+ {messages.map((msg, k) => (
34
+ <div
35
+ key={`${msg.id}-${k}`}
36
+ className={clsx(
37
+ 'max-w-[min(80%,52rem)] rounded-lg p-3 text-sm/normal text-neutral-0',
38
+ {
39
+ 'self-end bg-neutral-800': msg.from === 'me' || msg.metadata.author === 'user',
40
+ 'bg-ai-chat-response':
41
+ msg.from !== profileQuery?.data?.id || msg.metadata.author === 'ai'
42
+ }
43
+ )}>
44
+ <MessageItem message={msg} />
45
+ </div>
46
+ ))}
47
+ </div>
48
+ ))}
20
49
  </div>
21
50
  )
22
51
  }
@@ -0,0 +1 @@
1
+ export * from './use-fetch-messages'
@@ -0,0 +1,2 @@
1
+ export * from './use-fetch-messages'
2
+ export { default as useFetchMessages } from './use-fetch-messages'
@@ -0,0 +1,46 @@
1
+ import { formatTime } from '@/src/config/dayjs'
2
+ import { chance, renderHook, waitFor } from '@/src/config/tests'
3
+ import type { IMessageWithSenderData } from '../..'
4
+ import { MessagesService } from '../..'
5
+ import IMessageWithSenderDataMock from '../../__tests__/imessage-with-sender-data.mock'
6
+ import { messagesParser } from '../../utils'
7
+
8
+ import useFetchMessages from './use-fetch-messages'
9
+
10
+ describe('useFetchMessages', () => {
11
+ const conversationId = chance.guid()
12
+
13
+ const defaultProps = {
14
+ conversationId,
15
+ currentMessages: [],
16
+ loadFirstPage: true,
17
+ profileId: conversationId,
18
+ enabled: true
19
+ }
20
+ const fetchMsgMock = {
21
+ hasMore: false,
22
+ messages: new IMessageWithSenderDataMock().getMany() as IMessageWithSenderData[]
23
+ }
24
+
25
+ const render = (props = defaultProps) => renderHook(() => useFetchMessages(props))
26
+
27
+ beforeEach(() => {
28
+ vi.spyOn(MessagesService, 'fetchMessages').mockResolvedValue(fetchMsgMock)
29
+ })
30
+
31
+ it('should render without errors when given the right props', async () => {
32
+ const { result } = render()
33
+
34
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
35
+
36
+ const resMsg = messagesParser({
37
+ messages: fetchMsgMock.messages,
38
+ profileId: defaultProps.profileId
39
+ })
40
+
41
+ expect(result.current.data).toMatchObject({
42
+ hasMore: fetchMsgMock.hasMore,
43
+ messages: new Map().set(formatTime(resMsg[0].timestamp, true), resMsg)
44
+ })
45
+ })
46
+ })
@@ -0,0 +1,103 @@
1
+ import { useCallback, useEffect } from 'react'
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
3
+
4
+ import { formatTime } from '@/src/config/dayjs'
5
+ import { SparkieService } from '@/src/modules/sparkie'
6
+ import MessagesService from '../../service'
7
+ import type {
8
+ FetchMessagesResponse,
9
+ IFetchMessagesOptions,
10
+ IMessage,
11
+ ParsedMessage
12
+ } from '../../types'
13
+ import { messagesParser } from '../../utils'
14
+
15
+ export type UseFetchMessagesProps = IFetchMessagesOptions & {
16
+ profileId: string
17
+ enabled?: boolean
18
+ }
19
+
20
+ export const getFetchMessagesQuery = ({
21
+ conversationId,
22
+ currentMessages,
23
+ loadFirstPage,
24
+ profileId,
25
+ enabled
26
+ }: UseFetchMessagesProps) => ({
27
+ queryKey: [
28
+ 'sparkie:messageService:getAll',
29
+ conversationId,
30
+ loadFirstPage,
31
+ currentMessages,
32
+ profileId
33
+ ],
34
+ queryFn: () =>
35
+ MessagesService.fetchMessages({
36
+ conversationId,
37
+ currentMessages,
38
+ loadFirstPage
39
+ }),
40
+ enabled,
41
+ select({ hasMore, messages }: FetchMessagesResponse) {
42
+ const parsedMessages = messagesParser({ messages: messages, profileId }).reduce(
43
+ (msgsMap, currMsg) => {
44
+ const timestamp = formatTime(currMsg.timestamp, true)
45
+
46
+ if (!msgsMap.has(timestamp)) {
47
+ msgsMap.set(timestamp, [currMsg])
48
+ return msgsMap
49
+ }
50
+
51
+ const existingTimestampValues = Array.from(msgsMap.get(timestamp) ?? [])
52
+
53
+ msgsMap.set(
54
+ timestamp,
55
+ [...existingTimestampValues, currMsg].sort((a, b) => a.timestamp - b.timestamp)
56
+ )
57
+
58
+ return msgsMap
59
+ },
60
+ new Map<string, ParsedMessage[]>()
61
+ )
62
+
63
+ return { hasMore, messages: parsedMessages }
64
+ }
65
+ })
66
+
67
+ function useFetchMessages({
68
+ conversationId,
69
+ currentMessages,
70
+ loadFirstPage,
71
+ profileId,
72
+ enabled = false
73
+ }: UseFetchMessagesProps) {
74
+ const query = getFetchMessagesQuery({
75
+ conversationId,
76
+ currentMessages,
77
+ loadFirstPage,
78
+ profileId,
79
+ enabled
80
+ })
81
+ const queryClient = useQueryClient()
82
+
83
+ const messageReceived = useCallback(
84
+ (data: IMessage) => {
85
+ if (data.conversationId !== conversationId) return
86
+
87
+ void queryClient.invalidateQueries({ queryKey: query.queryKey })
88
+ },
89
+ [conversationId, queryClient, query.queryKey]
90
+ )
91
+
92
+ useEffect(() => {
93
+ SparkieService.subscribeEvents({ messageReceived })
94
+
95
+ return () => {
96
+ SparkieService.removeEventSubscription({ messageReceived })
97
+ }
98
+ }, [messageReceived])
99
+
100
+ return useQuery(query)
101
+ }
102
+
103
+ export default useFetchMessages