app-tutor-ai-consumer 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.github/workflows/staging-staging.yml +148 -0
  2. package/.github/workflows/staging.yml +1 -2
  3. package/CHANGELOG.md +19 -0
  4. package/config/rspack/rspack.config.js +5 -1
  5. package/config/vitest/__mocks__/icons.tsx +3 -0
  6. package/config/vitest/__mocks__/intersection-observer.ts +10 -0
  7. package/config/vitest/__mocks__/sparkie.tsx +9 -9
  8. package/config/vitest/__mocks__/use-init-sparkie.tsx +14 -0
  9. package/config/vitest/vitest.config.mts +13 -8
  10. package/environments/.env.test +2 -0
  11. package/eslint.config.mjs +27 -0
  12. package/package.json +8 -4
  13. package/public/index.html +3 -4
  14. package/src/@types/index.d.ts +5 -2
  15. package/src/config/styles/global.css +2 -2
  16. package/src/config/tanstack/query-client.ts +3 -2
  17. package/src/config/tests/utils.tsx +3 -2
  18. package/src/config/tests/wrappers.tsx +4 -1
  19. package/src/development-bootstrap.tsx +15 -15
  20. package/src/index.tsx +37 -5
  21. package/src/lib/components/icons/ai-color.svg +17 -0
  22. package/src/lib/components/icons/arrow-down.svg +5 -0
  23. package/src/lib/components/icons/chevron-down.svg +4 -0
  24. package/src/lib/components/icons/icon-names.d.ts +1 -1
  25. package/src/lib/components/markdownrenderer/markdownrenderer.tsx +7 -9
  26. package/src/lib/hooks/index.ts +3 -0
  27. package/src/lib/hooks/use-intersection-observer-reverse-scroll/index.ts +2 -0
  28. package/src/lib/hooks/use-intersection-observer-reverse-scroll/use-intersection-observer-reverse-scroll.tsx +147 -0
  29. package/src/lib/hooks/use-ref-client-height/index.ts +2 -0
  30. package/src/lib/hooks/use-ref-client-height/use-ref-client-height.tsx +38 -0
  31. package/src/lib/hooks/use-scroll-to-ref/index.ts +2 -0
  32. package/src/lib/hooks/use-scroll-to-ref/use-scroll-to-ref.tsx +14 -0
  33. package/src/lib/hooks/use-throttle/index.ts +3 -0
  34. package/src/lib/hooks/use-throttle/types.ts +13 -0
  35. package/src/lib/hooks/use-throttle/use-throttle.spec.tsx +296 -0
  36. package/src/lib/hooks/use-throttle/use-throttle.tsx +91 -0
  37. package/src/lib/utils/is-text-empty.ts +3 -0
  38. package/src/main/main.spec.tsx +7 -6
  39. package/src/modules/cursor/__tests__/icursor-update.builder.ts +42 -0
  40. package/src/modules/cursor/hooks/index.ts +1 -0
  41. package/src/modules/cursor/hooks/use-update-cursor/index.ts +2 -0
  42. package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.spec.tsx +23 -0
  43. package/src/modules/cursor/hooks/use-update-cursor/use-update-cursor.ts +11 -0
  44. package/src/modules/cursor/index.ts +2 -0
  45. package/src/modules/cursor/service.ts +15 -0
  46. package/src/modules/cursor/types.ts +9 -0
  47. package/src/modules/global-providers/index.ts +1 -0
  48. package/src/modules/messages/__tests__/parsed-message.builder.ts +164 -0
  49. package/src/modules/messages/components/chat-input/chat-input.spec.tsx +72 -0
  50. package/src/modules/messages/components/chat-input/chat-input.tsx +52 -6
  51. package/src/modules/messages/components/index.ts +1 -0
  52. package/src/modules/messages/components/message-item/message-item.spec.tsx +2 -2
  53. package/src/modules/messages/components/message-item/message-item.tsx +14 -1
  54. package/src/modules/messages/components/message-item-end-of-scroll/index.ts +2 -0
  55. package/src/modules/messages/components/message-item-end-of-scroll/message-item-end-of-scroll.tsx +14 -0
  56. package/src/modules/messages/components/message-item-error/index.ts +2 -0
  57. package/src/modules/messages/components/message-item-error/message-item-error.tsx +25 -0
  58. package/src/modules/messages/components/message-item-loading/index.ts +2 -0
  59. package/src/modules/messages/components/message-item-loading/message-item-loading.tsx +16 -0
  60. package/src/modules/messages/components/message-skeleton/index.ts +1 -0
  61. package/src/modules/messages/components/message-skeleton/message-skeleton.tsx +23 -0
  62. package/src/modules/messages/components/messages-list/index.ts +1 -1
  63. package/src/modules/messages/components/messages-list/messages-list.tsx +82 -39
  64. package/src/modules/messages/constants.ts +1 -0
  65. package/src/modules/messages/hooks/index.ts +5 -0
  66. package/src/modules/messages/hooks/use-all-messages/index.ts +2 -0
  67. package/src/modules/messages/hooks/use-all-messages/use-all-messages.tsx +30 -0
  68. package/src/modules/messages/hooks/use-infinite-get-messages/index.ts +2 -0
  69. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +65 -0
  70. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +81 -0
  71. package/src/modules/messages/hooks/use-manage-scroll/index.ts +2 -0
  72. package/src/modules/messages/hooks/use-manage-scroll/use-manage-scroll.tsx +70 -0
  73. package/src/modules/messages/hooks/use-send-text-message/index.ts +2 -0
  74. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.spec.tsx +86 -0
  75. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +60 -0
  76. package/src/modules/messages/hooks/use-skeleton-ref/index.ts +2 -0
  77. package/src/modules/messages/hooks/use-skeleton-ref/use-skeleton-ref.tsx +34 -0
  78. package/src/modules/messages/hooks/use-subscribe-message-received-event/index.ts +2 -0
  79. package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +80 -0
  80. package/src/modules/messages/service.ts +8 -7
  81. package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.spec.tsx +58 -0
  82. package/src/modules/messages/utils/has-to-update-cursor/has-to-update-cursor.ts +30 -0
  83. package/src/modules/messages/utils/has-to-update-cursor/index.ts +2 -0
  84. package/src/modules/sparkie/__tests__/sparkie.mock.ts +33 -0
  85. package/src/modules/sparkie/service.ts +182 -35
  86. package/src/modules/sparkie/types.ts +10 -2
  87. package/src/modules/widget/__tests__/widget-settings-props.builder.ts +29 -1
  88. package/src/modules/widget/components/ai-avatar/ai-avatar.tsx +11 -53
  89. package/src/modules/widget/components/chat-page/chat-page.spec.tsx +28 -0
  90. package/src/modules/widget/components/chat-page/chat-page.tsx +23 -4
  91. package/src/modules/widget/components/container/container.tsx +5 -19
  92. package/src/modules/widget/components/index.ts +1 -0
  93. package/src/modules/widget/components/scroll-to-bottom-button/index.ts +2 -0
  94. package/src/modules/widget/components/scroll-to-bottom-button/scroll-to-bottom-button.tsx +32 -0
  95. package/src/modules/widget/events.ts +4 -0
  96. package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +8 -6
  97. package/src/modules/widget/store/index.ts +3 -0
  98. package/src/modules/widget/store/widget-container-intrinsic-height.atom.ts +13 -0
  99. package/src/modules/widget/store/widget-loading.atom.ts +11 -0
  100. package/src/modules/widget/store/widget-scrolling.atom.ts +11 -0
  101. package/src/modules/widget/store/widget-tabs.atom.ts +2 -1
  102. package/src/types.ts +4 -1
@@ -1,21 +1,22 @@
1
1
  import { chance, render, screen, waitFor } from '@/config/tests'
2
2
  import WidgetSettingPropsBuilder from '../modules/widget/__tests__/widget-settings-props.builder'
3
+ import { useWidgetSettingsAtom } from '../modules/widget/store/widget-settings.atom'
3
4
  import { Main } from '.'
4
5
 
6
+ vi.mock('../modules/widget/store/widget-settings.atom', () => ({ useWidgetSettingsAtom: vi.fn() }))
7
+
5
8
  describe('Main', () => {
6
9
  const defaultProps = new WidgetSettingPropsBuilder()
7
10
  const renderComponent = (props = { settings: defaultProps }) => render(<Main {...props} />)
8
11
 
9
- it('should render empty element when settings.tutorName is not defined', async () => {
10
- const { container } = renderComponent()
11
-
12
- await waitFor(() => {
13
- expect(container).toBeEmptyDOMElement()
14
- })
12
+ beforeEach(() => {
13
+ vi.mocked(useWidgetSettingsAtom).mockReturnValue([null, vi.fn()])
15
14
  })
16
15
 
17
16
  it('should render without errors', async () => {
18
17
  const props = new WidgetSettingPropsBuilder().withTutorName(chance.name())
18
+ vi.mocked(useWidgetSettingsAtom).mockReturnValue([props, vi.fn()])
19
+
19
20
  renderComponent({ settings: props })
20
21
 
21
22
  await waitFor(() => {
@@ -0,0 +1,42 @@
1
+ import { chance } from '@/src/config/tests'
2
+ import type { ICursorUpdate } from '../types'
3
+
4
+ class ICursorUpdateBuilder implements ICursorUpdate {
5
+ threadId: string
6
+ contactId: string
7
+ cursor: number
8
+ conversationId?: string
9
+
10
+ constructor() {
11
+ this.threadId = chance.guid()
12
+ this.contactId = chance.guid()
13
+ this.cursor = chance.integer()
14
+ this.conversationId = chance.guid()
15
+ }
16
+
17
+ withThreadId(threadId: typeof this.threadId) {
18
+ this.threadId = threadId
19
+
20
+ return this
21
+ }
22
+
23
+ withContactId(contactId: typeof this.contactId) {
24
+ this.contactId = contactId
25
+
26
+ return this
27
+ }
28
+
29
+ withCursor(cursor: typeof this.cursor) {
30
+ this.cursor = cursor
31
+
32
+ return this
33
+ }
34
+
35
+ withConversationId(conversationId: typeof this.conversationId) {
36
+ this.conversationId = conversationId
37
+
38
+ return this
39
+ }
40
+ }
41
+
42
+ export default ICursorUpdateBuilder
@@ -0,0 +1 @@
1
+ export * from './use-update-cursor'
@@ -0,0 +1,2 @@
1
+ export * from './use-update-cursor'
2
+ export { default as useUpdateCursor } from './use-update-cursor'
@@ -0,0 +1,23 @@
1
+ import { chance, renderHook, waitFor } from '@/src/config/tests'
2
+
3
+ import useUpdateCursor from './use-update-cursor'
4
+
5
+ describe('useUpdateCursor', () => {
6
+ const conversationId = chance.guid()
7
+ const createHook = () => renderHook(useUpdateCursor)
8
+
9
+ it('should update the cursor given a conversationId', async () => {
10
+ const { result } = createHook()
11
+
12
+ result.current.mutate(conversationId)
13
+
14
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
15
+
16
+ expect(result.current.data).toMatchObject({
17
+ threadId: expect.any(String) as string,
18
+ contactId: expect.any(String) as string,
19
+ cursor: expect.any(Number) as number,
20
+ conversationId: expect.any(String) as string
21
+ })
22
+ })
23
+ })
@@ -0,0 +1,11 @@
1
+ import { useMutation } from '@tanstack/react-query'
2
+
3
+ import { CursorService } from '../..'
4
+
5
+ function useUpdateCursor() {
6
+ return useMutation({
7
+ mutationFn: (conversationId: string) => CursorService.updateCursor(conversationId)
8
+ })
9
+ }
10
+
11
+ export default useUpdateCursor
@@ -0,0 +1,2 @@
1
+ export { default as CursorService } from './service'
2
+ export * from './types'
@@ -0,0 +1,15 @@
1
+ import { SparkieService } from '../sparkie'
2
+
3
+ import type { ICursorUpdate } from './types'
4
+
5
+ class CursorService {
6
+ constructor(private sparkie = SparkieService.sparkieInstance) {}
7
+
8
+ async updateCursor(conversationId: string): Promise<ICursorUpdate | null> {
9
+ const data = await this.sparkie.cursorService?.update(conversationId)
10
+
11
+ return data ?? null
12
+ }
13
+ }
14
+
15
+ export default new CursorService()
@@ -0,0 +1,9 @@
1
+ export interface ICursor {
2
+ threadId: string
3
+ contactId: string
4
+ cursor: number
5
+ }
6
+
7
+ export interface ICursorUpdate extends ICursor {
8
+ conversationId?: string
9
+ }
@@ -1 +1,2 @@
1
+ export * from './global-providers'
1
2
  export { default as GlobalProviders } from './global-providers'
@@ -0,0 +1,164 @@
1
+ import type { ParsedMessage } from '../types'
2
+ import { msgParser } from '../utils'
3
+
4
+ import IMessageWithSenderDataBuilder from './imessage-with-sender-data.builder'
5
+
6
+ const msg = new IMessageWithSenderDataBuilder()
7
+ const parsedMsg = msgParser(msg, msg.contactId)
8
+
9
+ class ParsedMessageBuilder implements ParsedMessage {
10
+ from: string
11
+ id: string
12
+ isRead: boolean
13
+ time: string
14
+ contact: { id: string; name?: string; picture?: string; userId?: number }
15
+ metadata: Record<string, unknown> & {
16
+ author: 'ai' | 'user'
17
+ sessionId: string
18
+ externalId: string
19
+ correlationId: string
20
+ }
21
+ parentId: string
22
+ sending: boolean
23
+ threadId: string
24
+ timestamp: number
25
+ type: string
26
+ text?: string
27
+ name?: string
28
+ url?: string
29
+ dimensions?: { width: number; height: number }
30
+ thumbnails?: { lg: string; md: string; sm: string }
31
+ caption?: string
32
+ loading?: boolean
33
+
34
+ constructor() {
35
+ this.from = parsedMsg.from
36
+ this.id = parsedMsg.id
37
+ this.isRead = parsedMsg.isRead
38
+ this.time = parsedMsg.time
39
+ this.contact = parsedMsg.contact
40
+ this.metadata = parsedMsg.metadata
41
+ this.parentId = parsedMsg.parentId ?? 'no-parent'
42
+ this.sending = parsedMsg.sending ?? false
43
+ this.threadId = parsedMsg.threadId
44
+ this.timestamp = parsedMsg.timestamp
45
+ this.type = parsedMsg.type
46
+ this.text = parsedMsg.text
47
+ this.name = parsedMsg.name
48
+ this.url = parsedMsg.url
49
+ this.dimensions = parsedMsg.dimensions
50
+ this.thumbnails = parsedMsg.thumbnails
51
+ this.caption = parsedMsg.caption
52
+ this.loading = parsedMsg.loading
53
+ }
54
+
55
+ withFrom(from: typeof this.from) {
56
+ this.from = from
57
+
58
+ return this
59
+ }
60
+
61
+ withId(id: typeof this.id) {
62
+ this.id = id
63
+
64
+ return this
65
+ }
66
+
67
+ withIsRead(isRead: typeof this.isRead) {
68
+ this.isRead = isRead
69
+
70
+ return this
71
+ }
72
+
73
+ withTime(time: typeof this.time) {
74
+ this.time = time
75
+
76
+ return this
77
+ }
78
+
79
+ withContact(contact: typeof this.contact) {
80
+ this.contact = contact
81
+
82
+ return this
83
+ }
84
+
85
+ withMetadata(metadata: typeof this.metadata) {
86
+ this.metadata = metadata
87
+
88
+ return this
89
+ }
90
+
91
+ withParentId(parentId: typeof this.parentId) {
92
+ this.parentId = parentId
93
+
94
+ return this
95
+ }
96
+
97
+ withSending(sending: typeof this.sending) {
98
+ this.sending = sending
99
+
100
+ return this
101
+ }
102
+
103
+ withThreadId(threadId: typeof this.threadId) {
104
+ this.threadId = threadId
105
+
106
+ return this
107
+ }
108
+
109
+ withTimestamp(timestamp: typeof this.timestamp) {
110
+ this.timestamp = timestamp
111
+
112
+ return this
113
+ }
114
+
115
+ withType(type: typeof this.type) {
116
+ this.type = type
117
+
118
+ return this
119
+ }
120
+
121
+ withText(text: typeof this.text) {
122
+ this.text = text
123
+
124
+ return this
125
+ }
126
+
127
+ withName(name: typeof this.name) {
128
+ this.name = name
129
+
130
+ return this
131
+ }
132
+
133
+ withUrl(url: typeof this.url) {
134
+ this.url = url
135
+
136
+ return this
137
+ }
138
+
139
+ withDimensions(dimensions: typeof this.dimensions) {
140
+ this.dimensions = dimensions
141
+
142
+ return this
143
+ }
144
+
145
+ withThumbnails(thumbnails: typeof this.thumbnails) {
146
+ this.thumbnails = thumbnails
147
+
148
+ return this
149
+ }
150
+
151
+ withCaption(caption: typeof this.caption) {
152
+ this.caption = caption
153
+
154
+ return this
155
+ }
156
+
157
+ withLoading(loading: typeof this.loading) {
158
+ this.loading = loading
159
+
160
+ return this
161
+ }
162
+ }
163
+
164
+ export default ParsedMessageBuilder
@@ -0,0 +1,72 @@
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<HTMLInputElement>()
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 there is no value', () => {
57
+ renderComponent()
58
+
59
+ expect(screen.getByRole('button', { name: /Submit Button/i })).toBeDisabled()
60
+ })
61
+
62
+ it('should the button be enabled and input have defaultValue when there is value', () => {
63
+ const name = chance.name()
64
+ vi.mocked(useChatInputValueAtom).mockReturnValue([name, vi.fn()])
65
+
66
+ renderComponent()
67
+
68
+ expect(ref.current?.defaultValue).toBe(name)
69
+
70
+ expect(screen.getByRole('button', { name: /Submit Button/i })).toBeEnabled()
71
+ })
72
+ })
@@ -1,4 +1,6 @@
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'
3
5
 
4
6
  import { Icon } from '@/src/lib/components'
@@ -6,9 +8,34 @@ import { Icon } from '@/src/lib/components'
6
8
  import { useChatInputValueAtom } from './chat-input.atom'
7
9
  import type { ChatInputProps } from './types'
8
10
 
9
- const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name, onSend }, ref) {
11
+ const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(({ name, onSend }, forwardedRef) => {
10
12
  const { t } = useTranslation()
11
- const [value] = useChatInputValueAtom()
13
+ const [value, setValue] = useChatInputValueAtom()
14
+ const ref = useRef<HTMLInputElement>(null)
15
+
16
+ useImperativeHandle(forwardedRef, () => ref?.current as HTMLInputElement)
17
+
18
+ const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
19
+ setValue(e.target.value?.trim())
20
+ }
21
+
22
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
23
+ const isEnterKey = e.code === 'Enter'
24
+ const isShiftKey = e.shiftKey
25
+
26
+ if (isEnterKey && !isShiftKey) {
27
+ e.preventDefault()
28
+ onSend?.()
29
+ }
30
+ }
31
+
32
+ useEffect(() => {
33
+ const input = ref?.current
34
+
35
+ if (input) {
36
+ input.focus()
37
+ }
38
+ }, [])
12
39
 
13
40
  return (
14
41
  <div className='flex items-center rounded-full border-neutral-800 bg-neutral-800 px-4 py-2'>
@@ -17,12 +44,31 @@ const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name,
17
44
  name={name}
18
45
  ref={ref}
19
46
  type='text'
20
- className='h-6 w-full border-none bg-transparent text-neutral-400 outline-0 placeholder:text-neutral-400'
47
+ className='h-6 w-full border-none bg-transparent text-neutral-400 outline-none outline-0 placeholder:text-neutral-400'
21
48
  placeholder={t('send_message.field.placeholder')}
22
49
  defaultValue={value}
50
+ onChange={handleChange}
51
+ onKeyDown={handleKeyDown}
23
52
  />
24
- <button onClick={onSend}>
25
- <Icon name='send' className='h-4 w-4 text-neutral-50' />
53
+ <button
54
+ onClick={onSend}
55
+ disabled={!value}
56
+ className={clsx(
57
+ 'flex size-8 flex-col items-center justify-center rounded-full outline-none transition-colors duration-300 ease-in',
58
+ {
59
+ 'cursor-pointer hover:scale-110 hover:bg-neutral-600 focus:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2':
60
+ Boolean(value),
61
+ 'cursor-not-allowed': !value
62
+ }
63
+ )}
64
+ aria-label='Submit Button'>
65
+ <Icon
66
+ name='send'
67
+ className={clsx('h-4 w-4 pr-0.5 pt-0.5 transition-colors duration-150', {
68
+ 'text-neutral-50': Boolean(value),
69
+ 'text-neutral-500': !value
70
+ })}
71
+ />
26
72
  </button>
27
73
  </div>
28
74
  )
@@ -1,4 +1,5 @@
1
1
  export * from './chat-input'
2
2
  export * from './message-img'
3
3
  export * from './message-item'
4
+ export * from './message-skeleton'
4
5
  export * from './messages-list'
@@ -1,11 +1,11 @@
1
1
  import { render, screen } from '@/src/config/tests'
2
2
  import { TEST_MARKDOWN_STUB } from '@/src/lib/components/markdownrenderer/__tests__/markdown.stub'
3
- import type { ParsedMessage } from '../../types'
3
+ import ParsedMessageBuilder from '../../__tests__/parsed-message.builder'
4
4
 
5
5
  import MessageItem from './message-item'
6
6
 
7
7
  describe('MessageItem', () => {
8
- const message = { text: TEST_MARKDOWN_STUB } as ParsedMessage
8
+ const message = new ParsedMessageBuilder().withText(TEST_MARKDOWN_STUB)
9
9
 
10
10
  const renderComponent = (props = { message }) => render(<MessageItem {...props} />)
11
11
 
@@ -1,3 +1,4 @@
1
+ import clsx from 'clsx'
1
2
  import type { Components } from 'react-markdown'
2
3
 
3
4
  import { MarkdownRenderer } from '@/src/lib/components'
@@ -9,7 +10,19 @@ const imgComponent: Components['img'] = ({ src }) => {
9
10
  }
10
11
 
11
12
  function MessageItem({ message }: { message: ParsedMessage }) {
12
- return <MarkdownRenderer content={message?.text ?? message?.name} imgComponent={imgComponent} />
13
+ return (
14
+ <div
15
+ data-test='messages-item'
16
+ className={clsx(
17
+ 'max-w-[min(80%,52rem)] overflow-x-hidden rounded-lg px-3 text-sm/normal text-neutral-50',
18
+ {
19
+ 'self-end bg-neutral-800': message.metadata.author === 'user',
20
+ 'bg-ai-chat-response': message.metadata.author === 'ai'
21
+ }
22
+ )}>
23
+ <MarkdownRenderer content={message?.text ?? message?.name} imgComponent={imgComponent} />
24
+ </div>
25
+ )
13
26
  }
14
27
 
15
28
  export default MessageItem
@@ -0,0 +1,2 @@
1
+ export * from './message-item-end-of-scroll'
2
+ export { default as MessageItemEndOfScroll } from './message-item-end-of-scroll'
@@ -0,0 +1,14 @@
1
+ export type MessageItemEndOfScrollProps = { show?: boolean }
2
+
3
+ // TODO: [PLACEHOLDER] Refactor using the PD choice for End of Scroll
4
+ function MessageItemEndOfScroll({ show = true }: MessageItemEndOfScrollProps) {
5
+ if (!show) return null
6
+
7
+ return (
8
+ <div className='rounded bg-gray-800/50 p-4 text-center text-gray-400'>
9
+ 🎉 You&apos;ve reached the beginning of the conversation!
10
+ </div>
11
+ )
12
+ }
13
+
14
+ export default MessageItemEndOfScroll
@@ -0,0 +1,2 @@
1
+ export * from './message-item-error'
2
+ export { default as MessageItemError } from './message-item-error'
@@ -0,0 +1,25 @@
1
+ import type { MouseEventHandler, ReactNode } from 'react'
2
+
3
+ export type MessageItemErrorProps = {
4
+ message?: ReactNode
5
+ retry?: MouseEventHandler<HTMLButtonElement>
6
+ show?: boolean
7
+ }
8
+
9
+ // TODO: [PLACEHOLDER] Refactor using the PD choice for Error Handling
10
+ function MessageItemError({ message, retry, show = true }: MessageItemErrorProps) {
11
+ if (!show) return null
12
+
13
+ return (
14
+ <div className='rounded bg-red-900/20 p-4 text-center text-red-400'>
15
+ {message}
16
+ <button
17
+ onClick={retry}
18
+ className='ml-2 cursor-pointer rounded bg-danger-600 px-3 py-1 text-sm text-danger-100 transition-colors hover:bg-danger-700'>
19
+ Retry
20
+ </button>
21
+ </div>
22
+ )
23
+ }
24
+
25
+ export default MessageItemError
@@ -0,0 +1,2 @@
1
+ export * from './message-item-loading'
2
+ export { default as MessageItemLoading } from './message-item-loading'
@@ -0,0 +1,16 @@
1
+ import { Spinner } from '@/src/lib/components'
2
+
3
+ export type MessageItemLoadingProps = { show?: boolean }
4
+
5
+ // TODO: [PLACEHOLDER] Refactor using the PD choice for loading new messages
6
+ function MessageItemLoading({ show = true }: MessageItemLoadingProps) {
7
+ if (!show) return null
8
+
9
+ return (
10
+ <div className='flex flex-col items-center justify-center p-8'>
11
+ <Spinner className='inline-flex h-6 w-6 animate-spin text-neutral-200' />
12
+ </div>
13
+ )
14
+ }
15
+
16
+ export default MessageItemLoading
@@ -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
@@ -1,2 +1,2 @@
1
- export * from './messages-list'
2
1
  export { default as MessagesList } from './messages-list'
2
+ export * from './messages-list'