app-tutor-ai-consumer 1.18.2 → 1.19.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 (40) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +1 -1
  3. package/src/config/tests/handlers.ts +12 -0
  4. package/src/development-bootstrap.tsx +5 -2
  5. package/src/index.tsx +3 -0
  6. package/src/lib/components/button/button.tsx +105 -14
  7. package/src/lib/components/button/styles.module.css +9 -0
  8. package/src/lib/components/icons/arrow-up.svg +5 -0
  9. package/src/lib/components/icons/copy.svg +5 -0
  10. package/src/lib/components/icons/icon-names.d.ts +3 -0
  11. package/src/lib/components/icons/like.svg +5 -0
  12. package/src/modules/messages/components/message-actions/index.ts +2 -0
  13. package/src/modules/messages/components/message-actions/message-actions.tsx +49 -0
  14. package/src/modules/messages/components/message-item/message-item.tsx +21 -5
  15. package/src/modules/messages/components/message-item-error/message-item-error.tsx +16 -9
  16. package/src/modules/messages/components/message-skeleton/message-skeleton.tsx +1 -4
  17. package/src/modules/messages/components/messages-container/index.ts +2 -0
  18. package/src/modules/messages/components/messages-container/messages-container.tsx +91 -0
  19. package/src/modules/messages/components/messages-list/messages-list.tsx +9 -82
  20. package/src/modules/messages/constants.ts +5 -0
  21. package/src/modules/messages/events.ts +12 -4
  22. package/src/modules/messages/hooks/index.ts +1 -0
  23. package/src/modules/messages/hooks/use-all-messages/use-all-messages.tsx +1 -2
  24. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.spec.tsx +18 -19
  25. package/src/modules/messages/hooks/use-infinite-get-messages/use-infinite-get-messages.tsx +41 -35
  26. package/src/modules/messages/hooks/use-scroller/index.ts +2 -0
  27. package/src/modules/messages/hooks/use-scroller/use-scroller.tsx +50 -0
  28. package/src/modules/messages/hooks/use-send-text-message/use-send-text-message.tsx +31 -2
  29. package/src/modules/messages/hooks/use-subscribe-message-received-event/use-subscribe-message-received-event.tsx +47 -64
  30. package/src/modules/messages/store/index.ts +1 -0
  31. package/src/modules/messages/store/messages-max-count.atom.ts +13 -0
  32. package/src/modules/messages/utils/index.ts +2 -0
  33. package/src/modules/messages/utils/set-messages-cache/index.ts +1 -0
  34. package/src/modules/messages/utils/set-messages-cache/utils.ts +53 -0
  35. package/src/modules/widget/components/chat-page/chat-page.spec.tsx +23 -7
  36. package/src/modules/widget/components/chat-page/chat-page.tsx +70 -14
  37. package/src/modules/widget/components/greetings-card/greetings-card.tsx +1 -1
  38. package/src/modules/widget/components/header/header.tsx +6 -4
  39. package/src/modules/widget/components/starter-page/starter-page.spec.tsx +4 -1
  40. package/src/modules/widget/components/starter-page/starter-page.tsx +31 -5
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ # [1.19.0](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.18.2...v1.19.0) (2025-07-29)
2
+
3
+ ### Features
4
+
5
+ - add message actions ([3a8d5dc](https://github.com/Hotmart-Org/app-tutor-ai-consumer/commit/3a8d5dcd1ae75ce40b7cbf6edf8629da921c9308))
6
+
1
7
  ## [1.18.2](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.18.1...v1.18.2) (2025-07-28)
2
8
 
3
9
  ## [1.18.1](https://github.com/Hotmart-Org/app-tutor-ai-consumer/compare/v1.18.0...v1.18.1) (2025-07-24)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "app-tutor-ai-consumer",
3
- "version": "1.18.2",
3
+ "version": "1.19.0",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "dev": "rspack serve --env=development --config config/rspack/rspack.config.js",
@@ -1,5 +1,8 @@
1
1
  import { http, HttpResponse } from 'msw'
2
2
 
3
+ import type { IMessageWithSenderData } from '@/src/modules/messages'
4
+ import { MessagesEndpoints, MSG_MAX_COUNT } from '@/src/modules/messages'
5
+ import IMessageWithSenderDataMock from '@/src/modules/messages/__tests__/imessage-with-sender-data.mock'
3
6
  import { ProfileEndpoints } from '@/src/modules/profile'
4
7
  import ProfileAPIPropsBuilder from '@/src/modules/profile/__tests__/profile-api-props.builder'
5
8
 
@@ -12,5 +15,14 @@ export const handlers = [
12
15
  }),
13
16
  http.all(ProfileEndpoints.getProfile(), () => {
14
17
  return HttpResponse.json(new ProfileAPIPropsBuilder())
18
+ }),
19
+ http.all(MessagesEndpoints.getAll(':conversationId'), ({ request }) => {
20
+ const limit = Number(new URL(request.url)?.searchParams?.get?.('limit'))
21
+
22
+ return HttpResponse.json(
23
+ new IMessageWithSenderDataMock().getMany(
24
+ isNaN(limit) ? MSG_MAX_COUNT : limit
25
+ ) as IMessageWithSenderData[]
26
+ )
15
27
  })
16
28
  ]
@@ -1,10 +1,12 @@
1
1
  import './index'
2
2
 
3
+ import Chance from 'chance'
3
4
  import { v4 } from 'uuid'
4
5
 
5
6
  import { LANGUAGES } from './config/i18n'
6
7
  import { devMode } from './lib/utils'
7
8
 
9
+ const chance = new Chance()
8
10
  const rootId = 'app-tutor-ai-widget'
9
11
 
10
12
  if (devMode) {
@@ -36,8 +38,9 @@ if (devMode) {
36
38
  productName: 'Curso de Assinatura',
37
39
  productId: 4266504,
38
40
  sessionId: v4(),
39
- config: {
40
- theme: 'dark'
41
+ user: {
42
+ id: v4(),
43
+ name: chance.name()
41
44
  }
42
45
  })
43
46
  })()
package/src/index.tsx CHANGED
@@ -37,6 +37,9 @@ window.startChatWidget = async (
37
37
  }
38
38
 
39
39
  const rootElement = document.getElementById(elementId) as HTMLElement
40
+
41
+ if (!rootElement) return
42
+
40
43
  rootElement.setAttribute('id', 'hotmart-app-tutor-ai-consumer-root')
41
44
  const theme = (rootElement.getAttribute('data-theme') ?? 'dark') as Theme
42
45
  const root = createRoot(rootElement)
@@ -3,6 +3,8 @@ import type { ButtonHTMLAttributes, PropsWithChildren } from 'react'
3
3
 
4
4
  import { Spinner } from '../spinner'
5
5
 
6
+ import styles from './styles.module.css'
7
+
6
8
  export type ButtonProps = PropsWithChildren<
7
9
  ButtonHTMLAttributes<HTMLButtonElement> & {
8
10
  variant?: 'brand' | 'secondary' | 'primary' | 'tertiary' | 'gradient-outline'
@@ -24,8 +26,7 @@ function Button({
24
26
  const defaultBorder = 'border border-transparent'
25
27
  const defaultPadding = 'px-4 py-2'
26
28
  const disabledClasses = 'cursor-not-allowed'
27
-
28
- const content = loading ? <Spinner className='h-full w-full text-current' /> : children
29
+ const gridClasses = 'grid [grid-template-areas:stack]'
29
30
 
30
31
  if (!show) return null
31
32
 
@@ -34,6 +35,7 @@ function Button({
34
35
  return (
35
36
  <button
36
37
  className={clsx(
38
+ gridClasses,
37
39
  defaultClasses,
38
40
  'group relative inline-flex items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-[var(--ai-color-gradient-primary)] to-[var(--ai-color-gradient-accent)] p-[1px] hover:text-neutral-1000 group-hover:from-[var(--ai-color-gradient-primary)] group-hover:to-[var(--ai-color-gradient-accent)]',
39
41
  className
@@ -43,15 +45,29 @@ function Button({
43
45
  aria-busy={loading}>
44
46
  <span
45
47
  data-label='text-content'
46
- className='relative flex-1 rounded-lg bg-neutral-100 px-5 py-2.5 text-neutral-1000 transition-all duration-75 ease-in group-hover:bg-transparent'>
47
- {content}
48
+ className={clsx(
49
+ 'relative flex-1 rounded-lg bg-neutral-100 px-5 py-2.5 text-neutral-1000 transition-all duration-75 ease-in group-hover:bg-transparent',
50
+ 'flex flex-nowrap gap-2 [grid-area:stack]',
51
+ {
52
+ visible: !loading,
53
+ invisible: loading
54
+ }
55
+ )}>
56
+ {children}
48
57
  </span>
58
+ <Spinner
59
+ className={clsx('mx-auto my-auto h-[1em] w-[1em] text-current', '[grid-area:stack]', {
60
+ visible: loading,
61
+ invisible: !loading
62
+ })}
63
+ />
49
64
  </button>
50
65
  )
51
66
  case 'primary':
52
67
  return (
53
68
  <button
54
69
  className={clsx(
70
+ gridClasses,
55
71
  defaultPadding,
56
72
  defaultClasses,
57
73
  defaultBorder,
@@ -61,13 +77,27 @@ function Button({
61
77
  {...props}
62
78
  disabled={props.disabled || loading}
63
79
  aria-busy={loading}>
64
- {content}
80
+ <span
81
+ data-label='text-content'
82
+ className={clsx('flex flex-nowrap gap-2 [grid-area:stack]', {
83
+ visible: !loading,
84
+ invisible: loading
85
+ })}>
86
+ {children}
87
+ </span>
88
+ <Spinner
89
+ className={clsx('mx-auto my-auto h-[1em] w-[1em] text-current', '[grid-area:stack]', {
90
+ visible: loading,
91
+ invisible: !loading
92
+ })}
93
+ />
65
94
  </button>
66
95
  )
67
96
  case 'secondary':
68
97
  return (
69
98
  <button
70
99
  className={clsx(
100
+ gridClasses,
71
101
  defaultPadding,
72
102
  defaultClasses,
73
103
  defaultBorder,
@@ -77,29 +107,62 @@ function Button({
77
107
  {...props}
78
108
  disabled={props.disabled || loading}
79
109
  aria-busy={loading}>
80
- {content}
110
+ <span
111
+ data-label='text-content'
112
+ className={clsx('flex flex-nowrap gap-2 [grid-area:stack]', {
113
+ visible: !loading,
114
+ invisible: loading
115
+ })}>
116
+ {children}
117
+ </span>
118
+ <Spinner
119
+ className={clsx('mx-auto my-auto h-[1em] w-[1em] text-current', '[grid-area:stack]', {
120
+ visible: loading,
121
+ invisible: !loading
122
+ })}
123
+ />
81
124
  </button>
82
125
  )
83
126
  case 'tertiary':
84
127
  return (
85
128
  <button
86
129
  className={clsx(
87
- defaultPadding,
88
- defaultClasses,
89
- defaultBorder,
90
- 'text-1000 bg-transparent hover:bg-neutral-900',
130
+ `relative grid place-items-center rounded bg-transparent`,
131
+ styles.tertiary,
91
132
  className
92
133
  )}
93
134
  {...props}
94
135
  disabled={props.disabled || loading}
95
136
  aria-busy={loading}>
96
- {content}
137
+ <span
138
+ data-label='text-content'
139
+ className={clsx(`col-start-1 row-start-1 flex flex-nowrap gap-2`, {
140
+ invisible: loading,
141
+ visible: !loading
142
+ })}>
143
+ {children}
144
+ </span>
145
+
146
+ {loading && (
147
+ <div className='col-start-1 row-start-1'>
148
+ <Spinner
149
+ className={clsx(
150
+ 'col-start-1 row-start-1 mx-auto my-auto h-[1em] w-[1em] text-current',
151
+ {
152
+ visible: loading,
153
+ invisible: !loading
154
+ }
155
+ )}
156
+ />
157
+ </div>
158
+ )}
97
159
  </button>
98
160
  )
99
161
  case 'brand':
100
162
  return (
101
163
  <button
102
164
  className={clsx(
165
+ gridClasses,
103
166
  defaultPadding,
104
167
  defaultClasses,
105
168
  defaultBorder,
@@ -109,25 +172,53 @@ function Button({
109
172
  {...props}
110
173
  disabled={props.disabled || loading}
111
174
  aria-busy={loading}>
112
- {content}
175
+ <span
176
+ data-label='text-content'
177
+ className={clsx('flex flex-nowrap gap-2 [grid-area:stack]', {
178
+ visible: !loading,
179
+ invisible: loading
180
+ })}>
181
+ {children}
182
+ </span>
183
+ <Spinner
184
+ className={clsx('mx-auto my-auto h-[1em] w-[1em] text-current', '[grid-area:stack]', {
185
+ visible: loading,
186
+ invisible: !loading
187
+ })}
188
+ />
113
189
  </button>
114
190
  )
115
191
  default:
116
192
  return (
117
193
  <button
118
194
  className={clsx(
119
- 'rounded-full p-2 outline-none transition-colors duration-300 ease-in',
195
+ gridClasses,
196
+ 'rounded-full outline-none transition-colors duration-300 ease-in',
120
197
  {
121
198
  'cursor-pointer ring-primary-500 hover:bg-neutral-300 focus:bg-neutral-300 focus-visible:ring-2':
122
199
  !props.disabled,
123
200
  [disabledClasses]: props.disabled
124
201
  },
202
+ styles.defaultButton,
125
203
  className
126
204
  )}
127
205
  {...props}
128
206
  disabled={props.disabled || loading}
129
207
  aria-busy={loading}>
130
- {content}
208
+ <span
209
+ data-label='text-content'
210
+ className={clsx('flex flex-nowrap gap-2 [grid-area:stack]', {
211
+ visible: !loading,
212
+ invisible: loading
213
+ })}>
214
+ {children}
215
+ </span>
216
+ <Spinner
217
+ className={clsx('mx-auto my-auto h-[1em] w-[1em] text-current', '[grid-area:stack]', {
218
+ visible: loading,
219
+ invisible: !loading
220
+ })}
221
+ />
131
222
  </button>
132
223
  )
133
224
  }
@@ -0,0 +1,9 @@
1
+ .tertiary {
2
+ &:hover {
3
+ background-color: hsl(from currentColor h s calc(l - 30));
4
+ }
5
+ }
6
+
7
+ .defaultButton {
8
+ padding: var(--custom-btn-padding, var(--hc-size-spacing-2));
9
+ }
@@ -0,0 +1,5 @@
1
+ <svg viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path
3
+ d="M11.863 6.04013C11.7692 6.16499 11.6441 6.22743 11.519 6.22743C11.3939 6.22743 11.2689 6.16499 11.175 6.07135L6.51594 1.70123L6.51594 13.4693C6.51594 13.7503 6.26579 14 6.01563 14C5.76548 14 5.51533 13.7503 5.51533 13.4693L5.51533 1.70123L0.82495 6.07135C0.637335 6.25864 0.324643 6.25864 0.137028 6.04013C-0.0505867 5.82163 -0.0505867 5.50948 0.168298 5.32219L5.67167 0.140468C5.85929 -0.0468227 6.14071 -0.0468227 6.32833 0.140468L11.8317 5.32219C12.0506 5.50948 12.0506 5.82163 11.863 6.04013Z"
4
+ fill="currentColor" />
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path
3
+ d="M7.42886 11.1663C7.19494 11.1663 7.01301 11.3747 7.01301 11.583V11.9997C7.01301 12.4684 6.62314 12.833 6.1813 12.833H2.02275C1.55492 12.833 1.19104 12.4684 1.19104 11.9997V5.33301C1.19104 4.8903 1.55492 4.49967 2.02275 4.49967H4.93373C5.14166 4.49967 5.34959 4.31738 5.34959 4.08301C5.34959 3.87467 5.14166 3.66634 4.93373 3.66634H1.99676C1.08708 3.66634 0.333344 4.42155 0.333344 5.33301L0.359334 11.9997C0.359334 12.9372 1.08708 13.6663 2.02275 13.6663H6.1813C7.09098 13.6663 7.84471 12.9372 7.84471 11.9997V11.583C7.84471 11.3747 7.63679 11.1663 7.42886 11.1663ZM13.4068 2.59863L11.4055 0.593424C11.2495 0.437174 11.0416 0.333008 10.8077 0.333008H7.84471C6.90904 0.333008 6.1813 1.08822 6.1813 1.99967V8.66634C6.1813 9.60384 6.90904 10.333 7.84471 10.333H12.0033C12.9129 10.333 13.6667 9.60384 13.6667 8.66634V3.19759C13.6667 2.96322 13.5627 2.75488 13.4068 2.59863ZM11.1715 1.53092L12.4711 2.83301H11.1715V1.53092ZM12.835 8.66634C12.835 9.13509 12.4451 9.49967 12.0033 9.49967H7.84471C7.37688 9.49967 7.01301 9.13509 7.01301 8.66634V1.99967C7.01301 1.55697 7.37688 1.16634 7.84471 1.16634H10.3398V2.83301C10.3398 3.30176 10.7037 3.66634 11.1715 3.66634H12.835V8.66634Z"
4
+ fill="currentColor" />
5
+ </svg>
@@ -4,9 +4,12 @@ export type ValidIconNames =
4
4
  | 'archive'
5
5
  | 'arrow-down'
6
6
  | 'arrow-left'
7
+ | 'arrow-up'
7
8
  | 'chevron-down'
9
+ | 'copy'
8
10
  | 'close'
9
11
  | 'info'
12
+ | 'like'
10
13
  | 'send'
11
14
  | 'stop'
12
15
  | 'warning'
@@ -0,0 +1,5 @@
1
+ <svg viewBox="0 0 14 12" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path
3
+ d="M2.62501 4.28571H1.37501C0.776052 4.28571 0.333344 4.76786 0.333344 5.35714V10.9286C0.333344 11.5446 0.776052 12 1.37501 12H2.62501C3.19793 12 3.66668 11.5446 3.66668 10.9286V5.35714C3.66668 4.76786 3.19793 4.28571 2.62501 4.28571ZM2.83334 10.9286C2.83334 11.0625 2.72918 11.1429 2.62501 11.1429H1.37501C1.2448 11.1429 1.16668 11.0625 1.16668 10.9286V5.35714C1.16668 5.25 1.2448 5.14286 1.37501 5.14286H2.62501C2.72918 5.14286 2.83334 5.25 2.83334 5.35714V10.9286ZM13.6667 5.08929C13.6667 4.17857 12.9375 3.42857 12.0521 3.42857H9.39584C9.6823 2.70536 9.86459 2.00893 9.86459 1.63393C9.86459 0.830357 9.26563 0 8.25001 0C7.1823 0 6.94793 0.803571 6.71355 1.47321C6.01043 3.75 4.50001 4.09821 4.50001 4.71429C4.50001 4.98214 4.6823 5.14286 4.91668 5.14286C5.02084 5.14286 5.12501 5.11607 5.20313 5.03571C6.5573 3.61607 6.94793 3.53571 7.52084 1.74107C7.75522 0.991071 7.83334 0.857143 8.25001 0.857143C8.79688 0.857143 9.03126 1.3125 9.03126 1.63393C9.03126 1.90179 8.79688 2.8125 8.35418 3.66964C8.30209 3.72321 8.30209 3.80357 8.30209 3.85714C8.30209 4.125 8.51043 4.28571 8.71876 4.28571H12.0521C12.4688 4.28571 12.8333 4.66071 12.8333 5.08929C12.8333 5.49107 12.4948 5.83929 12.1042 5.86607C11.8958 5.89286 11.7136 6.08036 11.7136 6.29464C11.7136 6.61607 12.0261 6.64286 12.0261 7.125C12.0261 7.5 11.7656 7.82143 11.4011 7.90179C11.2448 7.92857 11.0625 8.0625 11.0625 8.30357C11.0625 8.54464 11.2448 8.59821 11.2448 8.94643C11.2448 9.77679 10.4115 9.53571 10.4115 10.0982C10.4115 10.2054 10.4636 10.2321 10.4636 10.3661C10.4636 10.7946 10.099 11.1429 9.6823 11.1429H8.22397C6.08855 11.1429 5.41147 9.42857 4.91668 9.42857C4.6823 9.42857 4.50001 9.64286 4.50001 9.85714C4.47397 10.3125 6.16668 12 8.22397 12H9.6823C10.5677 12 11.2969 11.2768 11.2969 10.3661C11.7656 10.0714 12.0781 9.53571 12.0781 8.94643C12.0781 8.8125 12.0521 8.67857 12.0261 8.57143C12.5208 8.27679 12.8594 7.74107 12.8594 7.125C12.8594 6.9375 12.8333 6.72321 12.7552 6.5625C13.3021 6.29464 13.6667 5.73214 13.6667 5.08929Z"
4
+ fill="currentColor" />
5
+ </svg>
@@ -0,0 +1,2 @@
1
+ export * from './message-actions'
2
+ export { default as MessageActions } from './message-actions'
@@ -0,0 +1,49 @@
1
+ import clsx from 'clsx'
2
+ import dayjs from 'dayjs'
3
+ import type { CSSProperties } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+
6
+ import { Button, Icon } from '@/src/lib/components'
7
+ import type { ParsedMessage } from '../../types'
8
+
9
+ export type MessageActionsProps = {
10
+ message: ParsedMessage
11
+ className?: string
12
+ showActions?: boolean
13
+ }
14
+
15
+ function MessageActions({ message, className, showActions = false }: MessageActionsProps) {
16
+ const { t } = useTranslation()
17
+ const copyToClipboard = () => {
18
+ if (!message.text) return
19
+
20
+ navigator.clipboard.writeText(message.text).catch((err) => {
21
+ console.error('Failed to copy text: ', err)
22
+ })
23
+ }
24
+
25
+ return (
26
+ <div className={className}>
27
+ <span className='text-xs tracking-wide'>
28
+ {dayjs(message.timestamp).format('DD/MM [•] LT')}
29
+ </span>
30
+ <div
31
+ style={{ '--custom-btn-padding': '0.5rem' } as CSSProperties}
32
+ className={clsx('flex flex-nowrap gap-2 text-neutral-600', {
33
+ hidden: !showActions
34
+ })}>
35
+ <Button aria-label={t('general.buttons.like')}>
36
+ <Icon name='like' className='h-3 w-3.5' />
37
+ </Button>
38
+ <Button className='rotate-180 scale-x-[-1]' aria-label={t('general.buttons.dislike')}>
39
+ <Icon name='like' className='h-3 w-3.5' />
40
+ </Button>
41
+ <Button onClick={copyToClipboard} aria-label={t('general.buttons.copy')}>
42
+ <Icon name='copy' className='h-3 w-3.5' />
43
+ </Button>
44
+ </div>
45
+ </div>
46
+ )
47
+ }
48
+
49
+ export default MessageActions
@@ -3,6 +3,7 @@ import type { Components } from 'react-markdown'
3
3
 
4
4
  import { MarkdownRenderer } from '@/src/lib/components'
5
5
  import type { ParsedMessage } from '../../types'
6
+ import { MessageActions } from '../message-actions'
6
7
  import { MessageImg } from '../message-img'
7
8
 
8
9
  const imgComponent: Components['img'] = ({ src }) => {
@@ -10,17 +11,32 @@ const imgComponent: Components['img'] = ({ src }) => {
10
11
  }
11
12
 
12
13
  function MessageItem({ message }: { message: ParsedMessage }) {
14
+ const messageFromUser = message.metadata.author === 'user'
15
+ const messageFromAi = message.metadata.author === 'ai'
16
+
13
17
  return (
14
18
  <div
15
- data-test='messages-item'
16
19
  className={clsx(
17
- 'max-w-[min(80%,52rem)] overflow-x-hidden rounded-lg px-3 text-sm/normal text-neutral-900',
20
+ 'flex max-w-[min(90%,52rem)] flex-col items-end gap-2 text-sm/normal text-neutral-900',
18
21
  {
19
- 'self-end bg-neutral-200': message.metadata.author === 'user',
20
- 'border border-neutral-300 bg-neutral-100': message.metadata.author === 'ai'
22
+ 'self-end': messageFromUser
21
23
  }
22
24
  )}>
23
- <MarkdownRenderer content={message?.text ?? message?.name} imgComponent={imgComponent} />
25
+ <div
26
+ data-test='messages-item'
27
+ className={clsx('w-full overflow-x-hidden rounded-lg px-3', {
28
+ 'bg-neutral-200': messageFromUser,
29
+ 'border border-neutral-300 bg-neutral-100': messageFromAi
30
+ })}>
31
+ <MarkdownRenderer content={message?.text ?? message?.name} imgComponent={imgComponent} />
32
+ </div>
33
+ <MessageActions
34
+ className={clsx('flex items-center justify-between gap-2', {
35
+ 'w-full': messageFromAi
36
+ })}
37
+ message={message}
38
+ showActions={messageFromAi}
39
+ />
24
40
  </div>
25
41
  )
26
42
  }
@@ -1,4 +1,8 @@
1
- import type { MouseEventHandler, ReactNode } from 'react'
1
+ import { type MouseEventHandler, type ReactNode, useEffect } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { Button } from '@/src/lib/components'
5
+ import { devMode } from '@/src/lib/utils'
2
6
 
3
7
  export type MessageItemErrorProps = {
4
8
  message?: ReactNode
@@ -6,18 +10,21 @@ export type MessageItemErrorProps = {
6
10
  show?: boolean
7
11
  }
8
12
 
9
- // TODO: [PLACEHOLDER] Refactor using the PD choice for Error Handling
10
13
  function MessageItemError({ message, retry, show = true }: MessageItemErrorProps) {
14
+ const { t } = useTranslation()
15
+
16
+ useEffect(() => {
17
+ if (show && devMode) console.log(message)
18
+ }, [message, show])
19
+
11
20
  if (!show) return null
12
21
 
13
22
  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>
23
+ <div className='mt-2 flex justify-start gap-2 rounded border border-danger-500 bg-neutral-200 p-4 text-neutral-800'>
24
+ <span>{t('chat_page.error.loading.content')}</span>
25
+ <Button variant='tertiary' className='px-4 text-sm/relaxed text-danger-400' onClick={retry}>
26
+ {t('chat_page.error.loading.action')}
27
+ </Button>
21
28
  </div>
22
29
  )
23
30
  }
@@ -4,10 +4,7 @@ import { AIAvatarIcon } from '@/src/modules/widget'
4
4
 
5
5
  const MessageSkeleton = forwardRef<HTMLDivElement>((_, ref) => {
6
6
  return (
7
- <div
8
- ref={ref}
9
- className='flex max-w-[86%] flex-col items-start gap-2'
10
- aria-label='Loading Component'>
7
+ <div ref={ref} className='flex flex-col items-start gap-2' aria-label='Loading Component'>
11
8
  <AIAvatarIcon className='rounded-lg bg-ai-chat-response' />
12
9
  <div className='flex w-full flex-col items-start gap-2'>
13
10
  <div className='h-3 w-full animate-pulse rounded-full bg-neutral-200 transition-colors delay-0' />
@@ -0,0 +1,2 @@
1
+ export * from './messages-container'
2
+ export { default as MessagesContainer } from './messages-container'
@@ -0,0 +1,91 @@
1
+ import { forwardRef, useEffect } from 'react'
2
+ import clsx from 'clsx'
3
+ import type { MouseEventHandler, PropsWithChildren } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { useTranslation } from 'react-i18next'
6
+
7
+ import { Button, Icon } from '@/src/lib/components'
8
+ import { MessageSkeleton } from '@/src/modules/messages/components'
9
+ import { useSkeletonRef } from '@/src/modules/messages/hooks/use-skeleton-ref'
10
+ import {
11
+ ScrollToBottomButton,
12
+ usePageLayoutMainRefContext,
13
+ useWidgetLoadingAtom
14
+ } from '@/src/modules/widget'
15
+ import { useScroller } from '../../hooks'
16
+
17
+ const MessagesContainer = forwardRef<
18
+ HTMLDivElement,
19
+ PropsWithChildren<{
20
+ showButton?: boolean
21
+ loading?: boolean
22
+ handleShowMore?: () => Promise<void>
23
+ }>
24
+ >(({ children, handleShowMore, showButton = false, loading = false }, forwardedRef) => {
25
+ const { t } = useTranslation()
26
+ const skeletonRef = useSkeletonRef()
27
+ const [isLoadingNewMsg] = useWidgetLoadingAtom()
28
+ const mainLayoutRef = usePageLayoutMainRefContext()
29
+ const { scrollerRef, scrollToButtonRef, scrollToBottom, showScrollButton } =
30
+ useScroller(forwardedRef)
31
+
32
+ useEffect(() => {
33
+ scrollToBottom()
34
+ }, [scrollToBottom])
35
+
36
+ const handleClickShowMore: MouseEventHandler<HTMLButtonElement> = (e) => {
37
+ const scroller = scrollerRef?.current
38
+ const heightBeforeRender = Number(e?.currentTarget?.scrollHeight)
39
+
40
+ void handleShowMore?.().then(() => {
41
+ if (scroller && !isNaN(heightBeforeRender)) {
42
+ setTimeout(
43
+ () =>
44
+ scroller.scrollTo({
45
+ top: heightBeforeRender + 10,
46
+ behavior: 'smooth'
47
+ }),
48
+ 180
49
+ )
50
+ }
51
+ })
52
+ }
53
+
54
+ return (
55
+ <div ref={scrollerRef} className='mx-2 my-4 flex h-full flex-col gap-2 overflow-auto px-4'>
56
+ <div className='mb-auto flex-1 self-center'>
57
+ <Button
58
+ className='max-w-max rounded-full border border-neutral-300 bg-neutral-200 px-2 py-1 text-xs/normal tracking-wide text-neutral-900'
59
+ onClick={handleClickShowMore}
60
+ loading={loading}
61
+ show={showButton}>
62
+ <Icon name='arrow-up' className='h-4 w-3' aria-hidden />
63
+ <span className='text-nowrap'>{t('general.buttons.show_more')}</span>
64
+ </Button>
65
+ </div>
66
+ {children}
67
+
68
+ {mainLayoutRef.current &&
69
+ createPortal(
70
+ <ScrollToBottomButton
71
+ ref={scrollToButtonRef}
72
+ show={showScrollButton}
73
+ onClick={scrollToBottom}
74
+ />,
75
+ mainLayoutRef.current
76
+ )}
77
+ <div
78
+ className={clsx({
79
+ 'pointer-events-none h-0 overflow-hidden opacity-0': !isLoadingNewMsg,
80
+ 'mt-2 pb-4': isLoadingNewMsg
81
+ })}
82
+ ref={skeletonRef}>
83
+ <MessageSkeleton />
84
+ </div>
85
+ </div>
86
+ )
87
+ })
88
+
89
+ MessagesContainer.displayName = 'MessagesContainer'
90
+
91
+ export default MessagesContainer