diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d6e31a8f..6a81ffb77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - PR [#2549](https://github.com/microsoft/BotFramework-WebChat/pull/2549): `useSuggestedActions` - PR [#2550](https://github.com/microsoft/BotFramework-WebChat/pull/2550): `useConnectivityStatus`, `useGroupTimestamp`, `useTimeoutForSend`, `useUserID`, `useUsername` - PR [#2551](https://github.com/microsoft/BotFramework-WebChat/pull/2551): `useLastTypingAt`, `useSendTypingIndicator`, `useTypingIndicator` + - PR [#2552](https://github.com/microsoft/BotFramework-WebChat/pull/2552): `useFocusSendBox`, `useScrollToEnd`, `useSendBoxValue`, `useSubmitSendBox`, `useTextBoxSubmit`, `useTextBoxValue` - Bring your own Adaptive Cards package by specifying `adaptiveCardsPackage` prop, by [@compulim](https://github.com/compulim) in PR [#2543](https://github.com/microsoft/BotFramework-WebChat/pull/2543) ### Fixed diff --git a/__tests__/hooks/useScrollToEnd.js b/__tests__/hooks/useScrollToEnd.js new file mode 100644 index 0000000000..0fd74f2a4c --- /dev/null +++ b/__tests__/hooks/useScrollToEnd.js @@ -0,0 +1,34 @@ +import { imageSnapshotOptions, timeouts } from '../constants.json'; + +import minNumActivitiesShown from '../setup/conditions/minNumActivitiesShown'; +import scrollToBottomButtonVisible from '../setup/conditions/scrollToBottomButtonVisible'; +import scrollToBottomCompleted from '../setup/conditions/scrollToBottomCompleted'; +import uiConnected from '../setup/conditions/uiConnected'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +jest.setTimeout(timeouts.test); + +test('calling scrollToEnd should scroll to end', async () => { + const { driver, pageObjects } = await setupWebDriver(); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendMessageViaSendBox('help'); + await driver.wait(minNumActivitiesShown(2), timeouts.directLine); + await driver.wait(scrollToBottomCompleted(), timeouts.scrollToBottom); + + await driver.executeScript(() => { + document.querySelector('[role="log"] > *').scrollTop = 0; + }); + + await driver.wait(scrollToBottomButtonVisible(), timeouts.ui); + + expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); + + await pageObjects.runHook('useScrollToEnd', [], scrollToEnd => scrollToEnd()); + await driver.wait(scrollToBottomCompleted(), timeouts.scrollToBottom); + + expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); +}); diff --git a/__tests__/hooks/useSendBox.js b/__tests__/hooks/useSendBox.js new file mode 100644 index 0000000000..38390ef744 --- /dev/null +++ b/__tests__/hooks/useSendBox.js @@ -0,0 +1,60 @@ +import { timeouts } from '../constants.json'; + +import speechRecognitionStartCalled from '../setup/conditions/speechRecognitionStartCalled'; +import uiConnected from '../setup/conditions/uiConnected'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +jest.setTimeout(timeouts.test); + +test('sendBoxDictationStarted should return if dictation is started or not', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + webSpeechPonyfillFactory: () => window.WebSpeechMock + } + }); + + await driver.wait(uiConnected(), timeouts.directLine); + + await expect( + pageObjects.runHook('useSendBoxDictationStarted', [], sendBoxDictationStarted => sendBoxDictationStarted[0]) + ).resolves.toMatchInlineSnapshot(`false`); + + await pageObjects.clickMicrophoneButton(); + + await driver.wait(speechRecognitionStartCalled(), timeouts.ui); + + await expect( + pageObjects.runHook('useSendBoxDictationStarted', [], sendBoxDictationStarted => sendBoxDictationStarted[0]) + ).resolves.toMatchInlineSnapshot(`true`); + + await pageObjects.putSpeechRecognitionResult('recognizing', 'Hello'); + + await expect( + pageObjects.runHook('useSendBoxDictationStarted', [], sendBoxDictationStarted => sendBoxDictationStarted[0]) + ).resolves.toMatchInlineSnapshot(`true`); +}); + +test('sendBoxDictationStarted should return false when synthesizing', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + webSpeechPonyfillFactory: () => window.WebSpeechMock + } + }); + + await driver.wait(uiConnected(), timeouts.directLine); + await pageObjects.sendMessageViaMicrophone('Hello, World!'); + await expect(pageObjects.startSpeechSynthesize()); + await pageObjects.clickMicrophoneButton(); + + await expect( + pageObjects.runHook('useSendBoxDictationStarted', [], sendBoxDictationStarted => sendBoxDictationStarted[0]) + ).resolves.toMatchInlineSnapshot(`false`); + + await pageObjects.endSpeechSynthesize(); + + await expect( + pageObjects.runHook('useSendBoxDictationStarted', [], sendBoxDictationStarted => sendBoxDictationStarted[0]) + ).resolves.toMatchInlineSnapshot(`true`); +}); diff --git a/__tests__/hooks/useSendBoxValue.js b/__tests__/hooks/useSendBoxValue.js new file mode 100644 index 0000000000..0ab161c23f --- /dev/null +++ b/__tests__/hooks/useSendBoxValue.js @@ -0,0 +1,26 @@ +import { timeouts } from '../constants.json'; + +import uiConnected from '../setup/conditions/uiConnected'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +jest.setTimeout(timeouts.test); + +test('getter should get the send box text', async () => { + const { driver, pageObjects } = await setupWebDriver(); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.typeOnSendBox('Hello, World!'); + await expect(pageObjects.runHook('useSendBoxValue', [], result => result[0])).resolves.toBe('Hello, World!'); +}); + +test('setter should set the send box text', async () => { + const { driver, pageObjects } = await setupWebDriver(); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.runHook('useSendBoxValue', [], result => result[1]('Hello, World!')); + await expect(pageObjects.getSendBoxText()).resolves.toBe('Hello, World!'); +}); diff --git a/__tests__/hooks/useSubmitSendBox.js b/__tests__/hooks/useSubmitSendBox.js new file mode 100644 index 0000000000..d6a8255c28 --- /dev/null +++ b/__tests__/hooks/useSubmitSendBox.js @@ -0,0 +1,24 @@ +import { imageSnapshotOptions, timeouts } from '../constants.json'; + +import minNumActivitiesShown from '../setup/conditions/minNumActivitiesShown'; +import uiConnected from '../setup/conditions/uiConnected'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +jest.setTimeout(timeouts.test); + +test('calling submitSendBox should send the message in send box', async () => { + const { driver, pageObjects } = await setupWebDriver(); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.typeOnSendBox('Hello, World!'); + await pageObjects.runHook('useSubmitSendBox', [], submitSendBox => submitSendBox()); + + await driver.wait(minNumActivitiesShown(2), timeouts.directLine); + + const base64PNG = await driver.takeScreenshot(); + + expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); +}); diff --git a/__tests__/hooks/useTextBox.js b/__tests__/hooks/useTextBox.js new file mode 100644 index 0000000000..8bcba788aa --- /dev/null +++ b/__tests__/hooks/useTextBox.js @@ -0,0 +1,38 @@ +import { imageSnapshotOptions, timeouts } from '../constants.json'; + +import minNumActivitiesShown from '../setup/conditions/minNumActivitiesShown'; +import scrollToBottomCompleted from '../setup/conditions/scrollToBottomCompleted'; +import uiConnected from '../setup/conditions/uiConnected'; + +// selenium-webdriver API doc: +// https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/index_exports_WebDriver.html + +jest.setTimeout(timeouts.test); + +test('calling submit should scroll to end', async () => { + const { driver, pageObjects } = await setupWebDriver(); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.typeOnSendBox('help'); + + await expect(pageObjects.runHook('useTextBoxValue', [], textBoxValue => textBoxValue[0])).resolves.toBe('help'); + + await pageObjects.clickSendButton(); + + await driver.wait(minNumActivitiesShown(2), timeouts.directLine); + await driver.wait(scrollToBottomCompleted(), timeouts.scrollToBottom); + + await driver.executeScript(() => { + document.querySelector('[role="log"] > *').scrollTop = 0; + }); + + expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); + + await pageObjects.runHook('useTextBoxValue', [], textBoxValue => textBoxValue[1]('Hello, World!')); + await pageObjects.runHook('useTextBoxSubmit', [], textBoxSubmit => textBoxSubmit()); + + await driver.wait(scrollToBottomCompleted(), timeouts.scrollToBottom); + + expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); +}); diff --git a/packages/component/src/Activity/ScrollToEndButton.js b/packages/component/src/Activity/ScrollToEndButton.js index c1130eab69..0bc349e008 100644 --- a/packages/component/src/Activity/ScrollToEndButton.js +++ b/packages/component/src/Activity/ScrollToEndButton.js @@ -4,12 +4,13 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; -import connectToWebChat from '../connectToWebChat'; import Localize from '../Localization/Localize'; +import useScrollToEnd from '../hooks/useScrollToEnd'; import useStyleSet from '../hooks/useStyleSet'; -const ScrollToEndButton = ({ className, scrollToEnd }) => { +const ScrollToEndButton = ({ className }) => { const [{ scrollToEndButton: scrollToEndButtonStyleSet }] = useStyleSet(); + const scrollToEnd = useScrollToEnd(); return ( {sendFailedText.substr(sendFailedRetryMatch.index + sendFailedRetryMatch[0].length)} ) : ( - ) @@ -73,10 +91,9 @@ SendStatus.propTypes = { channelData: PropTypes.shape({ state: PropTypes.string }) - }).isRequired, - retrySend: PropTypes.func.isRequired + }).isRequired }; -export default connectSendStatus()(SendStatus); +export default SendStatus; export { connectSendStatus }; diff --git a/packages/component/src/Dictation.js b/packages/component/src/Dictation.js index 484bf62e82..cb233ebe45 100644 --- a/packages/component/src/Dictation.js +++ b/packages/component/src/Dictation.js @@ -9,7 +9,9 @@ import useActivities from './hooks/useActivities'; import useDisabled from './hooks/useDisabled'; import useEmitTypingIndicator from './hooks/useEmitTypingIndicator'; import useLanguage from './hooks/useLanguage'; +import useSendBoxValue from './hooks/useSendBoxValue'; import useSendTypingIndicator from './hooks/useSendTypingIndicator'; +import useSubmitSendBox from './hooks/useSubmitSendBox'; const { DictateState: { DICTATING, IDLE, STARTING } @@ -20,17 +22,17 @@ const Dictation = ({ onError, setDictateInterims, setDictateState, - setSendBox, startSpeakingActivity, stopDictate, - submitSendBox, webSpeechPonyfill: { SpeechGrammarList, SpeechRecognition } = {} }) => { + const [, setSendBox] = useSendBoxValue(); const [activities] = useActivities(); const [disabled] = useDisabled(); const [language] = useLanguage(); const [sendTypingIndicator] = useSendTypingIndicator(); const emitTypingIndicator = useEmitTypingIndicator(); + const submitSendBox = useSubmitSendBox(); const numSpeakingActivities = useMemo(() => activities.filter(({ channelData: { speak } = {} }) => speak).length, [ activities @@ -96,10 +98,8 @@ Dictation.propTypes = { onError: PropTypes.func, setDictateInterims: PropTypes.func.isRequired, setDictateState: PropTypes.func.isRequired, - setSendBox: PropTypes.func.isRequired, startSpeakingActivity: PropTypes.func.isRequired, stopDictate: PropTypes.func.isRequired, - submitSendBox: PropTypes.func.isRequired, webSpeechPonyfill: PropTypes.shape({ SpeechGrammarList: PropTypes.any.isRequired, SpeechRecognition: PropTypes.any.isRequired @@ -112,20 +112,16 @@ export default connectToWebChat( postActivity, setDictateInterims, setDictateState, - setSendBox, startSpeakingActivity, stopDictate, - submitSendBox, webSpeechPonyfill }) => ({ dictateState, postActivity, setDictateInterims, setDictateState, - setSendBox, startSpeakingActivity, stopDictate, - submitSendBox, webSpeechPonyfill }) )(Dictation); diff --git a/packages/component/src/SendBox/SendButton.js b/packages/component/src/SendBox/SendButton.js index b88f381fe1..b9dcb368b5 100644 --- a/packages/component/src/SendBox/SendButton.js +++ b/packages/component/src/SendBox/SendButton.js @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import connectToWebChat from '../connectToWebChat'; @@ -6,6 +5,7 @@ import IconButton from './IconButton'; import SendIcon from './Assets/SendIcon'; import useDisabled from '../hooks/useDisabled'; import useLocalize from '../hooks/useLocalize'; +import useSubmitSendBox from '../hooks/useSubmitSendBox'; const connectSendButton = (...selectors) => connectToWebChat( @@ -17,9 +17,10 @@ const connectSendButton = (...selectors) => ...selectors ); -const SendButton = ({ submitSendBox }) => { +const SendButton = () => { const [disabled] = useDisabled(); const altText = useLocalize('Send'); + const submitSendBox = useSubmitSendBox(); return ( @@ -28,10 +29,6 @@ const SendButton = ({ submitSendBox }) => { ); }; -SendButton.propTypes = { - submitSendBox: PropTypes.func.isRequired -}; - -export default connectSendButton()(SendButton); +export default SendButton; export { connectSendButton }; diff --git a/packages/component/src/SendBox/TextBox.js b/packages/component/src/SendBox/TextBox.js index 00d9fcc21a..3dcd8d544b 100644 --- a/packages/component/src/SendBox/TextBox.js +++ b/packages/component/src/SendBox/TextBox.js @@ -1,14 +1,18 @@ import { css } from 'glamor'; import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; import { Context as TypeFocusSinkContext } from '../Utils/TypeFocusSink'; import connectToWebChat from '../connectToWebChat'; import useDisabled from '../hooks/useDisabled'; +import useFocusSendBox from '../hooks/useFocusSendBox'; import useLocalize from '../hooks/useLocalize'; +import useScrollToEnd from '../hooks/useScrollToBottom'; +import useSendBoxValue from '../hooks/useSendBoxValue'; import useStyleOptions from '../hooks/useStyleOptions'; import useStyleSet from '../hooks/useStyleSet'; +import useSubmitSendBox from '../hooks/useSubmitSendBox'; const ROOT_CSS = css({ display: 'flex', @@ -56,17 +60,82 @@ const connectSendTextBox = (...selectors) => ...selectors ); -const TextBox = ({ className, onChange, onKeyPress, onSubmit, value }) => { - const [{ sendBoxTextWrap }] = useStyleOptions(); +function useTextBoxSubmit(setFocus) { + const [sendBoxValue] = useSendBoxValue(); + const focusSendBox = useFocusSendBox(); + const scrollToEnd = useScrollToEnd(); + const submitSendBox = useSubmitSendBox(); + + return useCallback(() => { + if (sendBoxValue) { + scrollToEnd(); + submitSendBox(); + setFocus && focusSendBox(); + } + }, [focusSendBox, scrollToEnd, sendBoxValue, setFocus, submitSendBox]); +} + +// TODO: [P1] Will enable when useStopDictate is implemented + +// function useTextBoxValue() { +// const [value, setSendBox] = useSendBoxValue(); +// const stopDictate = useStopDictate(); +// const setter = useCallback( +// value => { +// setSendBox(value); +// stopDictate(); +// }, +// [setSendBox, stopDictate] +// ); + +// return [value, setter]; +// } + +const TextBox = ({ className, onChange, value: textBoxValue }) => { const [{ sendBoxTextArea: sendBoxTextAreaStyleSet, sendBoxTextBox: sendBoxTextBoxStyleSet }] = useStyleSet(); + const [{ sendBoxTextWrap }] = useStyleOptions(); const [disabled] = useDisabled(); + + // TODO: [P1] Will enable when useStopDictate is implemented + // const [textBoxValue, setTextBoxValue] = useTextBoxValue(); + + const submitTextBox = useTextBoxSubmit(); + const sendBoxString = useLocalize('Sendbox'); const typeYourMessageString = useLocalize('Type your message'); + // TODO: [P1] Will enable when useStopDictate is implemented + // const handleChange = useCallback(({ target: { value } }) => setTextBoxValue(value), [setTextBoxValue]); + + const handleKeyPress = useCallback( + event => { + const { key, shiftKey } = event; + + if (key === 'Enter' && !shiftKey) { + event.preventDefault(); + + // If text box is submitted, focus on the send box + submitTextBox(true); + } + }, + [submitTextBox] + ); + + const handleSubmit = useCallback( + event => { + event.preventDefault(); + + // Consider clearing the send box only after we received POST_ACTIVITY_PENDING + // E.g. if the connection is bad, sending the message essentially do nothing but just clearing the send box + submitTextBox(); + }, + [submitTextBox] + ); + return (
{ @@ -80,7 +149,7 @@ const TextBox = ({ className, onChange, onKeyPress, onSubmit, value }) => { placeholder={typeYourMessageString} ref={sendFocusRef} type="text" - value={value} + value={textBoxValue} /> ) : (
@@ -89,13 +158,13 @@ const TextBox = ({ className, onChange, onKeyPress, onSubmit, value }) => { data-id="webchat-sendbox-input" disabled={disabled} onChange={onChange} - onKeyPress={onKeyPress} + onKeyPress={handleKeyPress} placeholder={typeYourMessageString} ref={sendFocusRef} rows="1" - value={value} + value={textBoxValue} /> -
{value + '\n'}
+
{textBoxValue + '\n'}
) } @@ -113,8 +182,6 @@ TextBox.defaultProps = { TextBox.propTypes = { className: PropTypes.string, onChange: PropTypes.func.isRequired, - onKeyPress: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, value: PropTypes.string }; diff --git a/packages/component/src/hooks/index.js b/packages/component/src/hooks/index.js index e50fbbd171..9c410dcaed 100644 --- a/packages/component/src/hooks/index.js +++ b/packages/component/src/hooks/index.js @@ -4,6 +4,7 @@ import useAvatarForUser from './useAvatarForUser'; import useConnectivityStatus from './useConnectivityStatus'; import useDisabled from './useDisabled'; import useEmitTypingIndicator from './useEmitTypingIndicator'; +import useFocusSendBox from './useFocusSendBox'; import useGroupTimestamp from './useGroupTimestamp'; import useLanguage from './useLanguage'; import useLocalize from './useLocalize'; @@ -12,6 +13,8 @@ import usePerformCardAction from './usePerformCardAction'; import usePostActivity from './usePostActivity'; import useReferenceGrammarID from './useReferenceGrammarID'; import useRenderMarkdownAsHTML from './useRenderMarkdownAsHTML'; +import useScrollToEnd from './useScrollToEnd'; +import useSendBoxValue from './useSendBoxValue'; import useSendEvent from './useSendEvent'; import useSendFiles from './useSendFiles'; import useSendMessage from './useSendMessage'; @@ -19,12 +22,14 @@ import useSendMessageBack from './useSendMessageBack'; import useSendPostBack from './useSendPostBack'; import useStyleOptions from './useStyleOptions'; import useStyleSet from './useStyleSet'; +import useSubmitSendBox from './useSubmitSendBox'; import useSuggestedActions from './useSuggestedActions'; import useTimeoutForSend from './useTimeoutForSend'; import useUserID from './useUserID'; import useUsername from './useUsername'; import { useSendBoxDictationStarted } from '../BasicSendBox'; +import { useTextBoxSubmit, useTextBoxValue } from '../SendBox/TextBox'; import { useTypingIndicatorVisible } from '../SendBox/TypingIndicator'; export { @@ -34,6 +39,7 @@ export { useConnectivityStatus, useDisabled, useEmitTypingIndicator, + useFocusSendBox, useGroupTimestamp, useLanguage, useLocalize, @@ -42,7 +48,9 @@ export { usePostActivity, useReferenceGrammarID, useRenderMarkdownAsHTML, + useScrollToEnd, useSendBoxDictationStarted, + useSendBoxValue, useSendEvent, useSendFiles, useSendMessage, @@ -50,7 +58,10 @@ export { useSendPostBack, useStyleOptions, useStyleSet, + useSubmitSendBox, useSuggestedActions, + useTextBoxSubmit, + useTextBoxValue, useTimeoutForSend, useTypingIndicatorVisible, useUserID, diff --git a/packages/component/src/hooks/useFocusSendBox.js b/packages/component/src/hooks/useFocusSendBox.js new file mode 100644 index 0000000000..393256c138 --- /dev/null +++ b/packages/component/src/hooks/useFocusSendBox.js @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import WebChatUIContext from '../WebChatUIContext'; + +export default function useFocusSendBox() { + return useContext(WebChatUIContext).focusSendBox; +} diff --git a/packages/component/src/hooks/useScrollToEnd.js b/packages/component/src/hooks/useScrollToEnd.js new file mode 100644 index 0000000000..5849f1ab49 --- /dev/null +++ b/packages/component/src/hooks/useScrollToEnd.js @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import WebChatUIContext from '../WebChatUIContext'; + +export default function useScrollToEnd() { + return useContext(WebChatUIContext).scrollToEnd; +} diff --git a/packages/component/src/hooks/useSendBoxValue.js b/packages/component/src/hooks/useSendBoxValue.js new file mode 100644 index 0000000000..e4e200d137 --- /dev/null +++ b/packages/component/src/hooks/useSendBoxValue.js @@ -0,0 +1,8 @@ +import { useContext } from 'react'; + +import { useSelector } from '../WebChatReduxContext'; +import WebChatUIContext from '../WebChatUIContext'; + +export default function useSendBoxValue() { + return [useSelector(({ sendBoxValue }) => sendBoxValue), useContext(WebChatUIContext).setSendBox]; +} diff --git a/packages/component/src/hooks/useSubmitSendBox.js b/packages/component/src/hooks/useSubmitSendBox.js new file mode 100644 index 0000000000..53a822add2 --- /dev/null +++ b/packages/component/src/hooks/useSubmitSendBox.js @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import WebChatUIContext from '../WebChatUIContext'; + +export default function useSubmitSendbox() { + return useContext(WebChatUIContext).submitSendBox; +}