diff --git a/web/src/components/ChatConversation.tsx b/web/src/components/ChatConversation.tsx index 283f451ef9..65da5ab060 100644 --- a/web/src/components/ChatConversation.tsx +++ b/web/src/components/ChatConversation.tsx @@ -4,7 +4,7 @@ import styled from 'styled-components' import ChatMessageModel from 'shared/api/models/ChatMessageModel' -import ChatMessage from './ChatMessage' +import ChatMessage, { Message } from './ChatMessage' const Container = styled.div` font-size: ${props => props.theme.fonts.hintFontSize}; @@ -15,6 +15,10 @@ const InitialMessage = styled.div` margin-bottom: 12px; ` +const TypingIndicatorWrapper = styled(Message)` + width: max-content; +` + const ErrorSendingStatus = styled.div` background-color: ${props => props.theme.colors.invalidInput}; border-radius: 5px; @@ -29,10 +33,27 @@ type ChatConversationProps = { className?: string } +type TypingIndicatorProps = { + isVisible: boolean +} + +const TypingIndicator = ({ isVisible }: TypingIndicatorProps): ReactElement | null => + isVisible ? ( + + ... + + ) : null + +const TYPING_INDICATOR_TIMEOUT = 60000 + const ChatConversation = ({ messages, hasError, className }: ChatConversationProps): ReactElement => { const { t } = useTranslation('chat') const [messagesCount, setMessagesCount] = useState(0) + const [typingIndicatorVisible, setTypingIndicatorVisible] = useState(false) const messagesEndRef = useRef(null) + const isLastMessageFromUser = messages[messages.length - 1]?.userIsAuthor + const hasOnlyReceivedInfoMessage = messages.filter(message => !message.userIsAuthor).length === 1 + const waitingForAnswer = isLastMessageFromUser || hasOnlyReceivedInfoMessage useEffect(() => { if (messagesCount < messages.length) { @@ -41,6 +62,19 @@ const ChatConversation = ({ messages, hasError, className }: ChatConversationPro } }, [messages, messagesCount]) + useEffect(() => { + if (waitingForAnswer) { + setTypingIndicatorVisible(true) + + const typingIndicatorTimeout = setTimeout(() => { + setTypingIndicatorVisible(false) + }, TYPING_INDICATOR_TIMEOUT) + + return () => clearTimeout(typingIndicatorTimeout) + } + return () => undefined + }, [waitingForAnswer]) + return ( {messages.length > 0 ? ( @@ -53,6 +87,7 @@ const ChatConversation = ({ messages, hasError, className }: ChatConversationPro showIcon={messages[index - 1]?.userIsAuthor !== message.userIsAuthor} /> ))} +
) : ( diff --git a/web/src/components/ChatMessage.tsx b/web/src/components/ChatMessage.tsx index 961716258f..d28949a999 100644 --- a/web/src/components/ChatMessage.tsx +++ b/web/src/components/ChatMessage.tsx @@ -9,7 +9,7 @@ import { ChatBot, ChatPerson } from '../assets' import RemoteContent from './RemoteContent' import Icon from './base/Icon' -const Message = styled.div` +export const Message = styled.div` border-radius: 5px; padding: 8px; border: 1px solid ${props => props.theme.colors.textDecorationColor}; diff --git a/web/src/components/__tests__/ChatConversation.spec.tsx b/web/src/components/__tests__/ChatConversation.spec.tsx index d104569d34..568ed31657 100644 --- a/web/src/components/__tests__/ChatConversation.spec.tsx +++ b/web/src/components/__tests__/ChatConversation.spec.tsx @@ -1,3 +1,4 @@ +import { act } from '@testing-library/react' import React from 'react' import ChatMessageModel from 'shared/api/models/ChatMessageModel' @@ -7,6 +8,7 @@ import ChatConversation from '../ChatConversation' jest.mock('react-i18next') window.HTMLElement.prototype.scrollIntoView = jest.fn() +jest.useFakeTimers() const render = (messages: ChatMessageModel[], hasError: boolean) => renderWithRouterAndTheme() @@ -21,10 +23,22 @@ describe('ChatConversation', () => { }), new ChatMessageModel({ id: 2, + body: 'Willkommen in der Integreat Chat Testumgebung auf Deutsch. Unser Team antwortet werktags, während unser Chatbot zusammenfassende Antworten aus verlinkten Artikeln liefert, die Sie zur Überprüfung wichtiger Informationen lesen sollten.', + userIsAuthor: false, + automaticAnswer: true, + }), + new ChatMessageModel({ + id: 3, body: 'Informationen zu Ihrer Frage finden Sie auf folgenden Seiten:', userIsAuthor: false, automaticAnswer: false, }), + new ChatMessageModel({ + id: 4, + body: 'Wie kann ich mein Deutsch verbessern?', + userIsAuthor: true, + automaticAnswer: false, + }), ] it('should display welcome text if conversation has not started', () => { @@ -37,7 +51,25 @@ describe('ChatConversation', () => { const { getByText, getByTestId } = render(testMessages, false) expect(getByText('chat:initialMessage')).toBeTruthy() expect(getByTestId(testMessages[0]!.id)).toBeTruthy() + expect(getByTestId(testMessages[2]!.id)).toBeTruthy() + }) + + it('should display typing indicator before the initial automatic answer and after for 60 seconds', () => { + const { getByText, queryByText, getByTestId } = render(testMessages, false) + expect(getByTestId(testMessages[0]!.id)).toBeTruthy() + expect(getByText('...')).toBeTruthy() expect(getByTestId(testMessages[1]!.id)).toBeTruthy() + expect(getByText('...')).toBeTruthy() + + act(() => jest.runAllTimers()) + expect(queryByText('...')).toBeNull() + }) + + it('should display typing indicator after opening the chatbot with existing conversation for unanswered user message', () => { + const { queryByText, getByTestId } = render(testMessages, false) + expect(getByTestId(testMessages[3]!.id)).toBeTruthy() + act(() => jest.runAllTimers()) + expect(queryByText('...')).toBeNull() }) it('should display error messages if error occurs', () => {