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.
- package/CHANGELOG.md +12 -0
- package/config/vitest/setupTests.ts +6 -1
- package/package.json +11 -2
- package/public/assets/images/default-image.png +0 -0
- package/src/@types/declarations.d.ts +12 -3
- package/src/config/dayjs/index.ts +2 -0
- package/src/config/dayjs/init.ts +28 -0
- package/src/config/dayjs/utils/format-fulldate.ts +7 -0
- package/src/config/dayjs/utils/format-time.ts +20 -0
- package/src/config/dayjs/utils/index.ts +2 -0
- package/src/config/styles/global.css +19 -1
- package/src/config/tanstack/query-provider.tsx +1 -1
- package/src/config/tests/handlers.ts +6 -0
- package/src/index.tsx +4 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdownrenderer/__tests__/markdown.stub.ts +334 -0
- package/src/lib/components/markdownrenderer/components/index.ts +1 -0
- package/src/lib/components/markdownrenderer/components/md-code-block/index.ts +1 -0
- package/src/lib/components/markdownrenderer/components/md-code-block/md-code-block.tsx +71 -0
- package/src/lib/components/markdownrenderer/index.ts +2 -0
- package/src/lib/components/markdownrenderer/markdownrenderer.tsx +115 -0
- package/src/lib/utils/constants.ts +1 -1
- package/src/lib/utils/copy-text-to-clipboard.tsx +13 -0
- package/src/lib/utils/extract-text-from-react-nodes.ts +23 -0
- package/src/lib/utils/index.ts +3 -0
- package/src/lib/utils/urls.ts +20 -0
- package/src/modules/messages/__tests__/imessage-with-sender-data.builder.ts +113 -0
- package/src/modules/messages/__tests__/imessage-with-sender-data.mock.ts +15 -0
- package/src/modules/messages/components/index.ts +2 -0
- package/src/modules/messages/components/message-img/index.ts +1 -0
- package/src/modules/messages/components/message-img/message-img.tsx +47 -0
- package/src/modules/messages/components/message-item/index.ts +2 -0
- package/src/modules/messages/components/message-item/message-item.spec.tsx +26 -0
- package/src/modules/messages/components/message-item/message-item.tsx +15 -0
- package/src/modules/messages/components/messages-list/messages-list.tsx +46 -17
- package/src/modules/messages/hooks/index.ts +1 -0
- package/src/modules/messages/hooks/use-fetch-messages/index.ts +2 -0
- package/src/modules/messages/hooks/use-fetch-messages/use-fetch-messages.spec.tsx +46 -0
- package/src/modules/messages/hooks/use-fetch-messages/use-fetch-messages.tsx +103 -0
- package/src/modules/messages/service.ts +26 -3
- package/src/modules/messages/types.ts +33 -2
- package/src/modules/messages/utils/index.ts +1 -0
- package/src/modules/messages/utils/messages-parser/index.ts +1 -0
- package/src/modules/messages/utils/messages-parser/utils.ts +28 -0
- package/src/modules/profile/__tests__/profile-api-props.builder.ts +74 -0
- package/src/modules/profile/__tests__/profile-props.builder.ts +42 -0
- package/src/modules/profile/constants.ts +3 -0
- package/src/modules/profile/hooks/index.ts +1 -0
- package/src/modules/profile/hooks/use-get-profile/index.ts +1 -0
- package/src/modules/profile/hooks/use-get-profile/use-get-profile.spec.tsx +20 -0
- package/src/modules/profile/hooks/use-get-profile/use-get-profile.tsx +14 -0
- package/src/modules/profile/index.ts +4 -0
- package/src/modules/profile/service.tsx +19 -0
- package/src/modules/profile/types.ts +17 -0
- package/src/modules/widget/components/chat-page/chat-page.tsx +3 -3
- package/src/modules/widget/components/container/container.tsx +1 -1
- package/src/modules/widget/components/onboarding-page/onboarding-page.tsx +16 -14
- package/src/modules/widget/components/starter-page/starter-page.tsx +3 -3
- package/src/modules/widget/store/widget-settings.atom.ts +3 -1
- package/src/config/styles/shared-styles.module.css +0 -16
- 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 *
|
|
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
|
+
}
|
package/src/lib/utils/index.ts
CHANGED
|
@@ -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
|
|
@@ -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,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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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,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
|