app-tutor-ai-consumer 1.2.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 (108) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/config/vitest/__mocks__/sparkie.tsx +12 -0
  3. package/config/vitest/setupTests.ts +6 -1
  4. package/config/vitest/vitest.config.mts +1 -0
  5. package/environments/.env.test +2 -2
  6. package/package.json +12 -2
  7. package/public/assets/images/default-image.png +0 -0
  8. package/public/assets/svg/tutor-onboarding.svg +128 -0
  9. package/src/@types/declarations.d.ts +16 -6
  10. package/src/config/dayjs/index.ts +2 -0
  11. package/src/config/dayjs/init.ts +28 -0
  12. package/src/config/dayjs/utils/format-fulldate.ts +7 -0
  13. package/src/config/dayjs/utils/format-time.ts +20 -0
  14. package/src/config/dayjs/utils/index.ts +2 -0
  15. package/src/config/optimizely/optimizely-provider.tsx +3 -3
  16. package/src/config/optimizely/optimizely.ts +1 -1
  17. package/src/config/styles/global.css +20 -1
  18. package/src/config/styles/utilities/bg-utilities.module.css +11 -0
  19. package/src/config/styles/utilities/text-utilities.module.css +6 -0
  20. package/src/config/tanstack/query-provider.tsx +1 -1
  21. package/src/config/tests/handlers.ts +9 -0
  22. package/src/index.tsx +4 -0
  23. package/src/lib/components/button/button.tsx +86 -0
  24. package/src/lib/components/button/index.ts +1 -0
  25. package/src/lib/components/index.ts +2 -0
  26. package/src/lib/components/markdownrenderer/__tests__/markdown.stub.ts +334 -0
  27. package/src/lib/components/markdownrenderer/components/index.ts +1 -0
  28. package/src/lib/components/markdownrenderer/components/md-code-block/index.ts +1 -0
  29. package/src/lib/components/markdownrenderer/components/md-code-block/md-code-block.tsx +71 -0
  30. package/src/lib/components/markdownrenderer/index.ts +2 -0
  31. package/src/lib/components/markdownrenderer/markdownrenderer.tsx +115 -0
  32. package/src/lib/hooks/index.ts +1 -0
  33. package/src/lib/hooks/use-ref-event-listener/index.ts +2 -0
  34. package/src/lib/hooks/use-ref-event-listener/use-ref-event-listener.tsx +32 -0
  35. package/src/lib/utils/constants.ts +1 -1
  36. package/src/lib/utils/copy-text-to-clipboard.tsx +13 -0
  37. package/src/lib/utils/extract-text-from-react-nodes.ts +23 -0
  38. package/src/lib/utils/index.ts +3 -0
  39. package/src/lib/utils/urls.ts +20 -0
  40. package/src/main/main.spec.tsx +17 -7
  41. package/src/main/main.tsx +2 -13
  42. package/src/modules/messages/__tests__/imessage-with-sender-data.builder.ts +113 -0
  43. package/src/modules/messages/__tests__/imessage-with-sender-data.mock.ts +15 -0
  44. package/src/modules/messages/components/chat-input/chat-input.atom.ts +12 -0
  45. package/src/modules/{create-message → messages}/components/chat-input/chat-input.tsx +6 -2
  46. package/src/modules/{create-message → messages}/components/chat-input/index.ts +1 -0
  47. package/src/modules/{create-message → messages}/components/chat-input/types.ts +1 -0
  48. package/src/modules/messages/components/index.ts +4 -0
  49. package/src/modules/messages/components/message-img/index.ts +1 -0
  50. package/src/modules/messages/components/message-img/message-img.tsx +47 -0
  51. package/src/modules/messages/components/message-item/index.ts +2 -0
  52. package/src/modules/messages/components/message-item/message-item.spec.tsx +26 -0
  53. package/src/modules/messages/components/message-item/message-item.tsx +15 -0
  54. package/src/modules/messages/components/messages-list/index.ts +2 -0
  55. package/src/modules/messages/components/messages-list/messages-list.tsx +53 -0
  56. package/src/modules/messages/constants.ts +1 -0
  57. package/src/modules/messages/hooks/index.ts +1 -0
  58. package/src/modules/messages/hooks/use-fetch-messages/index.ts +2 -0
  59. package/src/modules/messages/hooks/use-fetch-messages/use-fetch-messages.spec.tsx +46 -0
  60. package/src/modules/messages/hooks/use-fetch-messages/use-fetch-messages.tsx +103 -0
  61. package/src/modules/messages/index.ts +3 -0
  62. package/src/modules/messages/service.ts +86 -0
  63. package/src/modules/messages/types.ts +78 -0
  64. package/src/modules/messages/utils/index.ts +1 -0
  65. package/src/modules/messages/utils/messages-parser/index.ts +1 -0
  66. package/src/modules/messages/utils/messages-parser/utils.ts +28 -0
  67. package/src/modules/profile/__tests__/profile-api-props.builder.ts +74 -0
  68. package/src/modules/profile/__tests__/profile-props.builder.ts +42 -0
  69. package/src/modules/profile/constants.ts +3 -0
  70. package/src/modules/profile/hooks/index.ts +1 -0
  71. package/src/modules/profile/hooks/use-get-profile/index.ts +1 -0
  72. package/src/modules/profile/hooks/use-get-profile/use-get-profile.spec.tsx +20 -0
  73. package/src/modules/profile/hooks/use-get-profile/use-get-profile.tsx +14 -0
  74. package/src/modules/profile/index.ts +4 -0
  75. package/src/modules/profile/service.tsx +19 -0
  76. package/src/modules/profile/types.ts +17 -0
  77. package/src/modules/sparkie/constants.ts +21 -0
  78. package/src/modules/sparkie/index.ts +3 -0
  79. package/src/modules/sparkie/service.ts +94 -0
  80. package/src/modules/sparkie/types.ts +47 -0
  81. package/src/modules/sparkie/utils/validate-firebase-config.spec.ts +17 -0
  82. package/src/modules/sparkie/utils/validate-firebase-config.ts +12 -0
  83. package/src/modules/widget/__tests__/widget-settings-props.builder.ts +121 -0
  84. package/src/modules/widget/components/chat-page/chat-page.tsx +20 -0
  85. package/src/modules/widget/components/chat-page/index.ts +2 -0
  86. package/src/modules/widget/components/constants.tsx +9 -0
  87. package/src/modules/widget/components/container/container.tsx +32 -0
  88. package/src/modules/widget/components/container/index.ts +2 -0
  89. package/src/modules/widget/components/container/types.ts +3 -0
  90. package/src/modules/widget/components/greetings-card/greetings-card.tsx +1 -1
  91. package/src/modules/widget/components/greetings-card/styles.module.css +1 -3
  92. package/src/modules/widget/components/index.ts +3 -0
  93. package/src/modules/widget/components/onboarding-page/index.ts +1 -0
  94. package/src/modules/widget/components/onboarding-page/onboarding-page.tsx +40 -0
  95. package/src/modules/widget/components/onboarding-page/styles.module.css +7 -0
  96. package/src/modules/widget/components/starter-page/index.ts +1 -0
  97. package/src/modules/widget/components/starter-page/starter-page.tsx +41 -0
  98. package/src/modules/widget/hooks/index.ts +1 -0
  99. package/src/modules/widget/hooks/use-init-sparkie/index.ts +1 -0
  100. package/src/modules/widget/hooks/use-init-sparkie/use-init-sparkie.tsx +18 -0
  101. package/src/modules/widget/store/index.ts +1 -0
  102. package/src/modules/widget/store/widget-settings.atom.ts +3 -1
  103. package/src/modules/widget/store/widget-tabs.atom.ts +53 -0
  104. package/tailwind.config.js +95 -1
  105. package/config/vitest/index.ts +0 -1
  106. package/src/config/styles/shared-styles.module.css +0 -16
  107. package/src/main/styles.module.css +0 -15
  108. package/src/modules/create-message/components/index.ts +0 -1
@@ -0,0 +1,71 @@
1
+ import { memo, useState } from 'react'
2
+ import { Highlight, themes } from 'prism-react-renderer'
3
+ import type { PropsWithChildren } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+
6
+ import { copyTextToClipboard, extractTextFromReactNodes } from '@/src/lib/utils'
7
+
8
+ export type MdCodeBlockProps = PropsWithChildren<{
9
+ inline?: boolean
10
+ className?: string
11
+ }>
12
+
13
+ const MdCodeBlock = memo(({ inline, className, children, ...props }: MdCodeBlockProps) => {
14
+ const { t } = useTranslation()
15
+ const [copied, setCopied] = useState(false)
16
+ const codeContent = extractTextFromReactNodes(children)
17
+ const match = /language-(\w+)/.exec(className || '')
18
+ const language = match ? match[1] : 'text'
19
+
20
+ const handleCopy = () => {
21
+ setCopied(true)
22
+ void copyTextToClipboard(codeContent)
23
+ .then(
24
+ (result) =>
25
+ new Promise((resolve, reject) => {
26
+ if (!result) return reject(new Error('copy failed'))
27
+ setTimeout(resolve, 2000)
28
+ })
29
+ )
30
+ .finally(() => setCopied(false))
31
+ }
32
+
33
+ if (inline) {
34
+ return (
35
+ <code className='rounded bg-neutral-800 px-2 py-1 font-mono text-sm text-pink-600' {...props}>
36
+ {children}
37
+ </code>
38
+ )
39
+ }
40
+
41
+ return (
42
+ <div className='group relative'>
43
+ <div className='flex items-center justify-between rounded-t-lg bg-neutral-800 px-4 py-2 text-sm text-neutral-300'>
44
+ <span className='font-mono text-xs uppercase tracking-wide'>{language}</span>
45
+ <button
46
+ onClick={handleCopy}
47
+ className='rounded bg-neutral-700 px-2 py-1 text-xs opacity-0 transition-opacity duration-200 hover:bg-neutral-600 hover:text-neutral-0 group-hover:opacity-100'>
48
+ {t(copied ? `general.buttons.copied` : 'general.buttons.copy')}
49
+ </button>
50
+ </div>
51
+
52
+ <Highlight theme={themes.vsDark} code={codeContent} language={language}>
53
+ {({ className: highlightClassName, style, tokens, getLineProps, getTokenProps }) => (
54
+ <pre className={`${highlightClassName} overflow-x-auto rounded-b-lg p-4`} style={style}>
55
+ {tokens.map((line, i) => (
56
+ <div key={i} {...getLineProps({ line })}>
57
+ {line.map((token, key) => (
58
+ <span key={key} {...getTokenProps({ token })} />
59
+ ))}
60
+ </div>
61
+ ))}
62
+ </pre>
63
+ )}
64
+ </Highlight>
65
+ </div>
66
+ )
67
+ })
68
+
69
+ MdCodeBlock.displayName = 'MdCodeBlock'
70
+
71
+ export default MdCodeBlock
@@ -0,0 +1,2 @@
1
+ export * from './markdownrenderer'
2
+ export { default as MarkdownRenderer } from './markdownrenderer'
@@ -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 +1,2 @@
1
1
  export * from './use-default-id'
2
+ export * from './use-ref-event-listener'
@@ -0,0 +1,2 @@
1
+ export * from './use-ref-event-listener'
2
+ export { default as useRefEventListener } from './use-ref-event-listener'
@@ -0,0 +1,32 @@
1
+ import { useEffect } from 'react'
2
+
3
+ export type UseRefEventListenerConfig<T extends HTMLElement> = {
4
+ config: {
5
+ ref: React.RefObject<T | null>
6
+ eventTypes: string[]
7
+ handler: (event: Event) => void
8
+ options?: AddEventListenerOptions
9
+ }
10
+ }
11
+
12
+ function useRefEventListener<T extends HTMLElement>({
13
+ config: { eventTypes, handler, ref, options }
14
+ }: UseRefEventListenerConfig<T>) {
15
+ useEffect(() => {
16
+ const element = ref.current
17
+
18
+ if (!element) return
19
+
20
+ eventTypes.forEach((ev) => {
21
+ element.addEventListener(ev, handler, options)
22
+ })
23
+
24
+ return () => {
25
+ eventTypes.forEach((ev) => {
26
+ element.removeEventListener(ev, handler, options)
27
+ })
28
+ }
29
+ }, [ref, eventTypes, handler, options])
30
+ }
31
+
32
+ export default useRefEventListener
@@ -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()
@@ -1,15 +1,25 @@
1
- import { render, screen, waitFor } from '@/config/tests'
2
- import type { WidgetSettingProps } from '../types'
1
+ import { chance, render, screen, waitFor } from '@/config/tests'
2
+ import WidgetSettingPropsBuilder from '../modules/widget/__tests__/widget-settings-props.builder'
3
3
  import { Main } from '.'
4
4
 
5
5
  describe('Main', () => {
6
- const renderComponent = () => render(<Main settings={{} as WidgetSettingProps} />)
6
+ const defaultProps = new WidgetSettingPropsBuilder()
7
+ const renderComponent = (props = { settings: defaultProps }) => render(<Main {...props} />)
8
+
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
+ })
15
+ })
7
16
 
8
17
  it('should render without errors', async () => {
9
- renderComponent()
18
+ const props = new WidgetSettingPropsBuilder().withTutorName(chance.name())
19
+ renderComponent({ settings: props })
10
20
 
11
- await waitFor(() =>
12
- expect(screen.getByText(/general.greetings.description/i)).toBeInTheDocument()
13
- )
21
+ await waitFor(() => {
22
+ expect(screen.getByText(/onboarding.description/i)).toBeInTheDocument()
23
+ })
14
24
  })
15
25
  })
package/src/main/main.tsx CHANGED
@@ -1,17 +1,12 @@
1
1
  import '@/config/styles/index.css'
2
2
 
3
- import clsx from 'clsx'
4
-
5
3
  import { ErrorBoundary, GenericError } from '@/src/lib/components/errors'
6
4
  import { useDefaultId } from '@/src/lib/hooks'
7
5
  import { useAppLang } from '../config/i18n'
8
- import { ChatInput } from '../modules/create-message/components'
9
6
  import { GlobalProviders } from '../modules/global-providers'
10
- import { GreetingsCard } from '../modules/widget/components'
7
+ import { WidgetContainer } from '../modules/widget'
11
8
  import type { WidgetSettingProps } from '../types'
12
9
 
13
- import styles from './styles.module.css'
14
-
15
10
  function Main({ settings }: { settings: WidgetSettingProps }) {
16
11
  useDefaultId()
17
12
  useAppLang(settings.locale)
@@ -19,13 +14,7 @@ function Main({ settings }: { settings: WidgetSettingProps }) {
19
14
  return (
20
15
  <ErrorBoundary fallback={<GenericError />}>
21
16
  <GlobalProviders settings={settings}>
22
- <div
23
- className={clsx('flex min-h-svh flex-col items-center justify-center p-5', styles.main)}>
24
- <div className='flex flex-1 flex-col justify-center gap-6 lg:max-w-sm'>
25
- <GreetingsCard author={settings.author ?? ''} tutorName={settings.tutorName ?? ''} />
26
- <ChatInput name='new-chat-msg-input' />
27
- </div>
28
- </div>
17
+ <WidgetContainer />
29
18
  </GlobalProviders>
30
19
  </ErrorBoundary>
31
20
  )
@@ -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,12 @@
1
+ import { atom, useAtom } from 'jotai'
2
+
3
+ const chatInputValueAtom = atom<string>('')
4
+
5
+ export const chatInputAtom = atom(
6
+ (get) => get(chatInputValueAtom),
7
+ (_, set, config: string) => {
8
+ set(chatInputValueAtom, config)
9
+ }
10
+ )
11
+
12
+ export const useChatInputValueAtom = () => useAtom(chatInputAtom)
@@ -3,10 +3,13 @@ import { useTranslation } from 'react-i18next'
3
3
 
4
4
  import { Icon } from '@/src/lib/components'
5
5
 
6
+ import { useChatInputValueAtom } from './chat-input.atom'
6
7
  import type { ChatInputProps } from './types'
7
8
 
8
- const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name }, ref) {
9
+ const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name, onSend }, ref) {
9
10
  const { t } = useTranslation()
11
+ const [value] = useChatInputValueAtom()
12
+
10
13
  return (
11
14
  <div className='flex items-center rounded-full border-neutral-800 bg-neutral-800 px-4 py-2'>
12
15
  <input
@@ -16,8 +19,9 @@ const ChatInput = forwardRef<HTMLInputElement, ChatInputProps>(function ({ name
16
19
  type='text'
17
20
  className='h-6 w-full border-none bg-transparent text-neutral-400 outline-0 placeholder:text-neutral-400'
18
21
  placeholder={t('send_message.field.placeholder')}
22
+ defaultValue={value}
19
23
  />
20
- <button>
24
+ <button onClick={onSend}>
21
25
  <Icon name='send' className='h-4 w-4 text-neutral-50' />
22
26
  </button>
23
27
  </div>
@@ -1,2 +1,3 @@
1
1
  export { default as ChatInput } from './chat-input'
2
+ export * from './chat-input.atom'
2
3
  export * from './types'
@@ -1,3 +1,4 @@
1
1
  export type ChatInputProps = {
2
2
  name: string
3
+ onSend?: () => void
3
4
  }
@@ -0,0 +1,4 @@
1
+ export * from './chat-input'
2
+ export * from './message-img'
3
+ export * from './message-item'
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
@@ -0,0 +1,2 @@
1
+ export * from './messages-list'
2
+ export { default as MessagesList } from './messages-list'