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
@@ -0,0 +1,53 @@
1
+ import type { Message } from '@hotmart/sparkie/dist/MessageService'
2
+ import type { InfiniteData, QueryClient } from '@tanstack/react-query'
3
+ import { produce } from 'immer'
4
+
5
+ import type { FetchMessagesResponse, IMessageWithSenderData } from '../../types'
6
+
7
+ const placeholderID = 'remove::placeholder::id'
8
+
9
+ export type SetMessageCacheParams = {
10
+ queryKey: readonly unknown[]
11
+ queryClient: QueryClient
12
+ data: Partial<Message>
13
+ sending?: boolean
14
+ }
15
+
16
+ export const setMessagesCache =
17
+ ({ queryClient, queryKey, data, sending }: SetMessageCacheParams) =>
18
+ () => {
19
+ queryClient.setQueryData<InfiniteData<FetchMessagesResponse>>(queryKey, (oldData) => {
20
+ return produce(oldData, (draft) => {
21
+ const lastPageMessages = draft?.pages?.at(-1)?.messages
22
+ const lastMessage = lastPageMessages?.at?.(-1)
23
+
24
+ const placeholderMsgIndex = Number(
25
+ lastPageMessages?.findIndex((msg) => msg.id === placeholderID)
26
+ )
27
+
28
+ const msgIndex = Number(
29
+ !isNaN(placeholderMsgIndex) && placeholderMsgIndex !== -1
30
+ ? placeholderMsgIndex
31
+ : lastPageMessages?.length
32
+ )
33
+
34
+ if (isNaN(msgIndex)) return draft
35
+
36
+ const newMessage = {
37
+ ...lastMessage,
38
+ sentAt: Date.now(),
39
+ updatedAt: Date.now(),
40
+ metadata: {
41
+ ...lastMessage?.metadata,
42
+ author: 'user'
43
+ },
44
+ ...data,
45
+ ...(sending ? { id: placeholderID } : {})
46
+ } as IMessageWithSenderData
47
+
48
+ lastPageMessages?.splice(msgIndex, 1, newMessage)
49
+
50
+ return draft
51
+ })
52
+ })
53
+ }
@@ -1,27 +1,43 @@
1
- import { render, screen, waitFor } from '@/src/config/tests'
2
- import type { IMessageWithSenderData } from '@/src/modules/messages'
3
- import IMessageWithSenderDataMock from '@/src/modules/messages/__tests__/imessage-with-sender-data.mock'
1
+ import { createRef } from 'react'
2
+
3
+ import { chance, render, screen, waitFor } from '@/src/config/tests'
4
+ import { useScroller } from '@/src/modules/messages/hooks/use-scroller'
5
+ import { useGetProfile } from '@/src/modules/profile'
4
6
  import WidgetSettingPropsBuilder from '../../__tests__/widget-settings-props.builder'
5
7
  import * as Store from '../../store'
6
8
 
7
9
  import ChatPage from './chat-page'
8
10
 
11
+ vi.mock('@/src/modules/profile', () => ({ useGetProfile: vi.fn() }))
12
+
13
+ vi.mock('@/src/modules/messages/hooks/use-scroller', () => ({
14
+ useScroller: vi.fn()
15
+ }))
16
+
9
17
  describe('ChatPage', () => {
10
18
  const defaultSettings = new WidgetSettingPropsBuilder()
11
- const getMessagesMock = new IMessageWithSenderDataMock().getMany(10) as IMessageWithSenderData[]
19
+ const scrollerRef = createRef<HTMLDivElement>()
20
+ const scrollToButtonRef = createRef<HTMLButtonElement>()
21
+
22
+ const useScrollerMock = {
23
+ scrollerRef,
24
+ scrollToButtonRef,
25
+ scrollToBottom: vi.fn(),
26
+ showScrollButton: false
27
+ }
12
28
 
13
29
  const renderComponent = () => render(<ChatPage />)
14
30
 
15
31
  beforeEach(() => {
16
32
  vi.spyOn(Store, 'useWidgetSettingsAtomValue').mockReturnValue(defaultSettings)
33
+ vi.mocked(useGetProfile).mockReturnValue({ data: { id: chance.guid() } } as never)
34
+ vi.mocked(useScroller).mockReturnValue(useScrollerMock)
17
35
  })
18
36
 
19
37
  it('should render each fetched message item from API', async () => {
20
38
  renderComponent()
21
39
 
22
- await waitFor(() =>
23
- expect(screen.getAllByTestId('messages-item')).toHaveLength(getMessagesMock.length)
24
- )
40
+ await waitFor(() => expect(screen.getAllByTestId('messages-item')).toHaveLength(2))
25
41
 
26
42
  expect(screen.getByPlaceholderText(/send_message.field.placeholder/)).toBeInTheDocument()
27
43
  })
@@ -1,24 +1,55 @@
1
- import { useRef } from 'react'
1
+ import { lazy, useEffect, useMemo, useRef } from 'react'
2
+ import { useInfiniteQuery } from '@tanstack/react-query'
2
3
 
3
4
  import { isTextEmpty } from '@/src/lib/utils/is-text-empty'
4
5
  import { ChatInput, MessagesList, useChatInputValueAtom } from '@/src/modules/messages/components'
5
- import { useAllMessages, useSendTextMessage } from '@/src/modules/messages/hooks'
6
+ import { MessagesContainer } from '@/src/modules/messages/components/messages-container'
7
+ import { getAllMessagesQuery, useSendTextMessage } from '@/src/modules/messages/hooks'
8
+ import { useMessagesMaxCount } from '@/src/modules/messages/store'
9
+ import { useGetProfile } from '@/src/modules/profile'
6
10
  import {
7
- useWidgetLoadingAtomValue,
11
+ useWidgetLoadingAtom,
8
12
  useWidgetSettingsAtomValue,
9
13
  useWidgetTabsValueAtom
10
14
  } from '../../store'
11
15
  import { WidgetHeader } from '../header'
12
16
  import { PageLayout } from '../page-layout'
13
17
 
18
+ const MessageItemError = lazy(
19
+ () => import('@/src/modules/messages/components/message-item-error/message-item-error')
20
+ )
21
+
22
+ const MessageItemEndOfScroll = lazy(
23
+ () =>
24
+ import(
25
+ '@/src/modules/messages/components/message-item-end-of-scroll/message-item-end-of-scroll'
26
+ )
27
+ )
28
+
14
29
  function ChatPage() {
15
- const widgetTabs = useWidgetTabsValueAtom()
16
30
  const chatInputRef = useRef<HTMLTextAreaElement>(null)
31
+ const scrollerRef = useRef<HTMLDivElement>(null)
32
+ const settings = useWidgetSettingsAtomValue()
33
+ const profileQuery = useGetProfile()
34
+ const widgetTabs = useWidgetTabsValueAtom()
17
35
  const sendTextMessageMutation = useSendTextMessage()
18
- const { messagesQuery } = useAllMessages()
19
- const widgetLoading = useWidgetLoadingAtomValue()
36
+ const limit = useMessagesMaxCount()
20
37
  const [value, setValue] = useChatInputValueAtom()
21
- const settings = useWidgetSettingsAtomValue()
38
+ const [, setWidgetLoading] = useWidgetLoadingAtom()
39
+
40
+ const conversationId = useMemo(() => settings?.conversationId, [settings?.conversationId])
41
+ const profileId = useMemo(() => profileQuery.data?.id, [profileQuery.data?.id])
42
+ const messagesQueryConfig = useMemo(
43
+ () =>
44
+ getAllMessagesQuery({
45
+ conversationId,
46
+ profileId,
47
+ limit
48
+ }),
49
+ [conversationId, limit, profileId]
50
+ )
51
+
52
+ const messagesQuery = useInfiniteQuery(messagesQueryConfig)
22
53
 
23
54
  const handleSendMessage = () => {
24
55
  const text = chatInputRef.current?.value ?? ''
@@ -33,6 +64,12 @@ function ChatPage() {
33
64
  })
34
65
  }
35
66
 
67
+ useEffect(() => {
68
+ if (messagesQuery.isError) {
69
+ setWidgetLoading(false)
70
+ }
71
+ }, [messagesQuery.isError, setWidgetLoading])
72
+
36
73
  return (
37
74
  <PageLayout
38
75
  asideChild={
@@ -40,18 +77,37 @@ function ChatPage() {
40
77
  name='new-chat-msg-input'
41
78
  ref={chatInputRef}
42
79
  onSend={widgetTabs.currentTab === 'chat' ? handleSendMessage : undefined}
43
- loading={widgetLoading || sendTextMessageMutation.isPending}
80
+ loading={sendTextMessageMutation.isPending}
44
81
  inputDisabled={messagesQuery?.isLoading}
45
82
  buttonDisabled={messagesQuery?.isLoading || !value.trim()}
46
83
  />
47
84
  }>
48
- <>
49
- <div className='mt-4 px-6 py-4'>
50
- <WidgetHeader enabledButtons={['info', 'close']} tutorName={settings?.tutorName} />
51
- </div>
52
- <MessagesList />
53
- </>
85
+ <div className='mt-4 px-6 py-4'>
86
+ <WidgetHeader enabledButtons={['info', 'close']} tutorName={settings?.tutorName} />
87
+ </div>
88
+ <MessagesContainer
89
+ ref={scrollerRef}
90
+ handleShowMore={async () => {
91
+ await messagesQuery.fetchNextPage()
92
+ }}
93
+ showButton={messagesQuery.hasNextPage}
94
+ loading={messagesQuery.isFetchingNextPage}>
95
+ <MessageItemEndOfScroll
96
+ show={
97
+ !messagesQuery.isFetching &&
98
+ !messagesQuery.hasNextPage &&
99
+ Number(messagesQuery.data?.size) > 0
100
+ }
101
+ />
102
+ {messagesQuery.data && <MessagesList messagesMap={messagesQuery.data} />}
103
+ <MessageItemError
104
+ show={messagesQuery.isError}
105
+ message={`❌ Error loading messages: ${messagesQuery.error?.message ?? ''}`}
106
+ retry={() => void messagesQuery.refetch()}
107
+ />
108
+ </MessagesContainer>
54
109
  </PageLayout>
55
110
  )
56
111
  }
112
+
57
113
  export default ChatPage
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
3
3
 
4
4
  export type GreetingsCardProps = {
5
5
  tutorName: string
6
- author: string
6
+ author?: string
7
7
  isDarkTheme?: boolean
8
8
  }
9
9
 
@@ -1,3 +1,5 @@
1
+ import { useTranslation } from 'react-i18next'
2
+
1
3
  import { Button, Icon } from '@/src/lib/components'
2
4
  import { TutorWidgetEvents } from '../../events'
3
5
  import { useWidgetTabsAtom } from '../../store'
@@ -35,7 +37,9 @@ function WidgetHeader({
35
37
  showContentWithoutMeta,
36
38
  showContent = true
37
39
  }: WidgetHeaderProps) {
40
+ const { t } = useTranslation()
38
41
  const [, setTab] = useWidgetTabsAtom()
42
+ const name = tutorName ?? t('general.name')
39
43
 
40
44
  const handleClickArchive = () => {
41
45
  setTab('chat')
@@ -48,10 +52,8 @@ function WidgetHeader({
48
52
  return (
49
53
  <div className='grid-areas-[a_b] mt-0.5 grid grid-cols-[1fr_auto] items-center text-neutral-1000'>
50
54
  <div className='grid-area-[a]'>
51
- {showContent && !showContentWithoutMeta && <WidgetHeaderContent tutorName={tutorName} />}
52
- {showContentWithoutMeta && !showContent && (
53
- <WidgetHeaderContentWithoutMeta name={tutorName} />
54
- )}
55
+ {showContent && !showContentWithoutMeta && <WidgetHeaderContent tutorName={name} />}
56
+ {showContentWithoutMeta && !showContent && <WidgetHeaderContentWithoutMeta name={name} />}
55
57
  </div>
56
58
  <div className='shrink-0'>
57
59
  <div className='grid-area-[b] ml-auto flex max-w-max gap-3 text-neutral-700'>
@@ -3,7 +3,10 @@ import { useSendTextMessage } from '@/src/modules/messages/hooks'
3
3
 
4
4
  import WidgetStarterPage from './starter-page'
5
5
 
6
- vi.mock('@/src/modules/messages/hooks', () => ({ useSendTextMessage: vi.fn() }))
6
+ vi.mock('@/src/modules/messages/hooks', () => ({
7
+ getAllMessagesQuery: vi.fn(() => ({ pages: [], queryKey: ['any'] })),
8
+ useSendTextMessage: vi.fn()
9
+ }))
7
10
 
8
11
  describe('WidgetStarterPage', () => {
9
12
  const useSendTextMessageMock = { mutate: vi.fn() }
@@ -1,4 +1,5 @@
1
- import { useRef } from 'react'
1
+ import { useEffect, useMemo, useRef } from 'react'
2
+ import { useQueryClient } from '@tanstack/react-query'
2
3
  import clsx from 'clsx'
3
4
  import type { MouseEventHandler } from 'react'
4
5
  import { useTranslation } from 'react-i18next'
@@ -6,7 +7,9 @@ import { useTranslation } from 'react-i18next'
6
7
  import { Button } from '@/src/lib/components'
7
8
  import { useRefEventListener } from '@/src/lib/hooks'
8
9
  import { ChatInput, useChatInputValueAtom } from '@/src/modules/messages/components'
9
- import { useSendTextMessage } from '@/src/modules/messages/hooks'
10
+ import { getAllMessagesQuery, useSendTextMessage } from '@/src/modules/messages/hooks'
11
+ import { useMessagesMaxCount } from '@/src/modules/messages/store'
12
+ import { useGetProfile } from '@/src/modules/profile'
10
13
  import { useWidgetSettingsAtom, useWidgetTabsAtom } from '../../store'
11
14
  import { GreetingsCard } from '../greetings-card'
12
15
  import { WidgetHeader } from '../header'
@@ -21,6 +24,10 @@ function WidgetStarterPage() {
21
24
  const [chatInputValue, setChatInputValue] = useChatInputValueAtom()
22
25
  const [, setWidgetTabs] = useWidgetTabsAtom()
23
26
  const sendTextMessageMutation = useSendTextMessage()
27
+ const profileQuery = useGetProfile()
28
+ const limit = useMessagesMaxCount()
29
+ const queryClient = useQueryClient()
30
+ const name = settings?.tutorName ?? t('general.name')
24
31
 
25
32
  useRefEventListener<HTMLTextAreaElement>({
26
33
  config: {
@@ -53,6 +60,24 @@ function WidgetStarterPage() {
53
60
  sendText(chatInputRef.current?.value)
54
61
  }
55
62
 
63
+ const conversationId = useMemo(() => settings?.conversationId, [settings?.conversationId])
64
+
65
+ const profileId = useMemo(() => profileQuery.data?.id, [profileQuery.data?.id])
66
+
67
+ const messagesQueryConfig = useMemo(
68
+ () =>
69
+ getAllMessagesQuery({
70
+ conversationId,
71
+ profileId,
72
+ limit
73
+ }),
74
+ [conversationId, limit, profileId]
75
+ )
76
+
77
+ useEffect(() => {
78
+ void queryClient.prefetchInfiniteQuery(messagesQueryConfig)
79
+ }, [messagesQueryConfig, queryClient])
80
+
56
81
  return (
57
82
  <PageLayout
58
83
  asideChild={
@@ -70,13 +95,14 @@ function WidgetStarterPage() {
70
95
  })}>
71
96
  <WidgetHeader
72
97
  enabledButtons={['archive', 'info', 'close']}
73
- tutorName={settings?.tutorName}
98
+ tutorName={name}
99
+ showContent={false}
74
100
  />
75
101
 
76
102
  <div className='my-auto'>
77
103
  <GreetingsCard
78
- author={settings?.author ?? ''}
79
- tutorName={settings?.tutorName ?? ''}
104
+ author={settings?.user?.name}
105
+ tutorName={name}
80
106
  isDarkTheme={settings?.config?.theme === 'dark'}
81
107
  />
82
108
  </div>