diff --git a/CHANGELOG.md b/CHANGELOG.md index d66ba9947d..2c48ea14f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Resolves [#2539](https://github.com/Microsoft/BotFramework-WebChat/issues/2539), added React hooks for customization, by [@compulim](https://github.com/compulim), in the following PRs: - - PR [#2540](https://github.com/microsoft/BotFramework-WebChat/pull/2540): `useActivities`, `useReferenceGrammarID`, `useSendBoxDictationStarted` + - PR [#2540](https://github.com/microsoft/BotFramework-WebChat/pull/2540): `useActivities`, `useReferenceGrammarID`, `useSendBoxShowInterims` - PR [#2541](https://github.com/microsoft/BotFramework-WebChat/pull/2541): `useStyleOptions`, `useStyleSet` - PR [#2542](https://github.com/microsoft/BotFramework-WebChat/pull/2542): `useLanguage`, `useLocalize`, `useLocalizeDate` - PR [#2543](https://github.com/microsoft/BotFramework-WebChat/pull/2543): `useAdaptiveCardsHostConfig`, `useAdaptiveCardsPackage`, `useRenderMarkdownAsHTML` @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - 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` - PR [#2553](https://github.com/microsoft/BotFramework-WebChat/pull/2553): `useDictateInterims`, `useDictateState`, `useGrammars`, `useMarkActivityAsSpoken`, `useMicrophoneButton`, `useShouldSpeakIncomingActivity`, `useStartDictate`, `useStopDictate`, `useVoiceSelector`, `useWebSpeechPonyfill` + - PR [#2554](https://github.com/microsoft/BotFramework-WebChat/pull/2554): `useRenderActivity`, `useRenderAttachment` - 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) - Fixes [#2597](https://github.com/microsoft/BotFramework-WebChat/issues/2597). Modify `watch` script to `start` and add `tableflip` script for throwing `node_modules`, by [@corinagum](https://github.com/corinagum) in PR [#2598](https://github.com/microsoft/BotFramework-WebChat/pull/2598) - Adds Arabic Language Support, by [@midineo](https://github.com/midineo), in PR [#2593](https://github.com/microsoft/BotFramework-WebChat/pull/2593) @@ -55,12 +56,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Fixes [#2512](https://github.com/microsoft/BotFramework-WebChat/issues/2512). Adds check to ensure Adaptive Card's content is an Object, by [@tdurnford](https://github.com/tdurnford) in PR [#2590](https://github.com/microsoft/BotFramework-WebChat/pull/2590) - Fixes [#1780](https://github.com/microsoft/BotFramework-WebChat/issues/1780), [#2277](https://github.com/microsoft/BotFramework-WebChat/issues/2277), and [#2285](https://github.com/microsoft/BotFramework-WebChat/issues/2285). Make Suggested Actions accessible, Fix Markdown card in carousel being read multiple times, and label widgets of Connectivity Status and Suggested Actions containers, by [@corinagum](https://github.com/corinagum) in PR [#2613](https://github.com/microsoft/BotFramework-WebChat/pull/2613) - Fixes [#2608](https://github.com/microsoft/BotFramework-WebChat/issues/2608). Focus will return to sendbox after clicking New Messages or a Suggested Actions button, by [@corinagum](https://github.com/corinagum) in PR [#2628](https://github.com/microsoft/BotFramework-WebChat/pull/2628) -- `component`: Fixes [#2331](https://github.com/microsoft/BotFramework-WebChat/issues/2331). Updated timer to use React Hooks, by [@spyip](https://github.com/spyip) in PR [#2546](https://github.com/microsoft/BotFramework-WebChat/pull/2546) - Resolves [#2597](https://github.com/microsoft/BotFramework-WebChat/issues/2597). Modify `watch` script to `start` and add `tableflip` script for throwing `node_modules`, by [@corinagum](https://github.com/corinagum) in PR [#2598](https://github.com/microsoft/BotFramework-WebChat/pull/2598) -- Adds `suggestedActionLayout` to `defaultStyleOptions`, by [@spyip](https://github.com/spyip), in PR [#2596](https://github.com/microsoft/BotFramework-WebChat/pull/2596) +- Resolves [#1835](https://github.com/microsoft/BotFramework-WebChat/issues/1835). Adds `suggestedActionLayout` to `defaultStyleOptions`, by [@spyip](https://github.com/spyip), in PR [#2596](https://github.com/microsoft/BotFramework-WebChat/pull/2596) - Resolves [#2331](https://github.com/microsoft/BotFramework-WebChat/issues/2331). Updated timer to use React Hooks, by [@spyip](https://github.com/spyip) in PR [#2546](https://github.com/microsoft/BotFramework-WebChat/pull/2546) -- Resolves [#2620](https://github.com/microsoft/BotFramework-WebChat/issues/2620), update Chinese localization files, by [@spyip](https://github.com/spyip) in PR [#2631](https://github.com/microsoft/BotFramework-WebChat/pull/2631) +- Resolves [#2620](https://github.com/microsoft/BotFramework-WebChat/issues/2620). Adds Chinese localization files, by [@spyip](https://github.com/spyip) in PR [#2631](https://github.com/microsoft/BotFramework-WebChat/pull/2631) - Fixes [#2639](https://github.com/microsoft/BotFramework-WebChat/issues/2639). Fix passed in prop time from string to boolean, by [@corinagum](https://github.com/corinagum) in PR [#2640](https://github.com/microsoft/BotFramework-WebChat/pull/2640) +- `component`: Updated timer to use functional component, by [@spyip](https://github.com/spyip) in PR [#2546](https://github.com/microsoft/BotFramework-WebChat/pull/2546) ### Changed diff --git a/HOOKS.md b/HOOKS.md index 4089c1aba1..754606f322 100644 --- a/HOOKS.md +++ b/HOOKS.md @@ -320,17 +320,18 @@ This value is not controllable and is passed to Web Chat from the Direct Line ch ## `useRenderActivity` ```js -useRenderActivity(): ({ - activity: Activity, +useRenderActivity( renderAttachment: ({ activity: Activity, attachment: Attachment - }) => React.Element, + }) => React.Element +): ({ + activity: Activity, timestampClassName: string }) => React.Element ``` -This function is for rendering an activity inside a React element. The caller will need to pass `activity`, `timestampClassName`, and a render function for the attachment. This function is a composition of `activityRendererMiddleware`, which is passed as a prop. +This function is for rendering an activity and its attachments inside a React element. Because of the parent-child relationship, the caller will need to pass a render function in order for the attachment to create a render function for the activity. When rendering the activity, the caller will need to pass `activity` and `timestampClassName`. This function is a composition of `activityRendererMiddleware`, which is passed as a prop. ## `useRenderAttachment` @@ -596,15 +597,15 @@ This value can be partly controllable through Web Chat props. These are hooks that are specific for the send box. -- [`useSendBoxDictationStarted`](#usesendboxdictationstarted) +- [`useSendBoxSpeechInterimsVisible`](#usesendboxspeechinterimsvisible) -### `useSendBoxDictationStarted` +### `useSendBoxSpeechInterimsVisible` ```js -useSendBoxDictationStarted(): [boolean] +useSendBoxSpeechInterimsVisible(): [boolean] ``` -This function will return whether speech-to-text detection has been started or not. +This function will return whether the send box should show speech interims. ## `TextBox` diff --git a/__tests__/__image_snapshots__/chrome-docker/use-text-box-js-calling-submit-should-scroll-to-end-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/use-text-box-js-calling-submit-should-scroll-to-end-1-snap.png new file mode 100644 index 0000000000..9554f29539 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/use-text-box-js-calling-submit-should-scroll-to-end-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/use-text-box-js-calling-submit-should-scroll-to-end-2-snap.png b/__tests__/__image_snapshots__/chrome-docker/use-text-box-js-calling-submit-should-scroll-to-end-2-snap.png new file mode 100644 index 0000000000..f336aabc59 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/use-text-box-js-calling-submit-should-scroll-to-end-2-snap.png differ diff --git a/__tests__/hooks/useMicrophoneButton.js b/__tests__/hooks/useMicrophoneButton.js index 85d0287771..8f750aeb78 100644 --- a/__tests__/hooks/useMicrophoneButton.js +++ b/__tests__/hooks/useMicrophoneButton.js @@ -9,20 +9,27 @@ import uiConnected from '../setup/conditions/uiConnected'; jest.setTimeout(timeouts.test); test('microphoneButtonClick should toggle recording', async () => { - // TODO: [P1] Test is temporarily disabled until the hook is implemented - // const { driver, pageObjects } = await setupWebDriver({ - // props: { - // webSpeechPonyfillFactory: () => window.WebSpeechMock - // } - // }); - // await driver.wait(uiConnected(), timeouts.directLine); - // await pageObjects.runHook('useMicrophoneButtonClick', [], microphoneButtonClick => microphoneButtonClick()); - // await driver.wait(speechRecognitionStartCalled(), timeouts.ui); - // await expect( - // pageObjects.runHook('useMicrophoneButtonDisabled', [], microphoneButtonDisabled => microphoneButtonDisabled[0]) - // ).resolves.toBeTruthy(); - // await pageObjects.putSpeechRecognitionResult('recognizing', 'Hello'); - // await expect(pageObjects.isDictating()).resolves.toBeTruthy(); - // await pageObjects.runHook('useMicrophoneButtonClick', [], microphoneButtonClick => microphoneButtonClick()); - // await expect(pageObjects.isDictating()).resolves.toBeFalsy(); + const { driver, pageObjects } = await setupWebDriver({ + props: { + webSpeechPonyfillFactory: () => window.WebSpeechMock + } + }); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.runHook('useMicrophoneButtonClick', [], microphoneButtonClick => microphoneButtonClick()); + + await driver.wait(speechRecognitionStartCalled(), timeouts.ui); + + await expect( + pageObjects.runHook('useMicrophoneButtonDisabled', [], microphoneButtonDisabled => microphoneButtonDisabled[0]) + ).resolves.toBeTruthy(); + + await pageObjects.putSpeechRecognitionResult('recognizing', 'Hello'); + + await expect(pageObjects.isDictating()).resolves.toBeTruthy(); + + await pageObjects.runHook('useMicrophoneButtonClick', [], microphoneButtonClick => microphoneButtonClick()); + + await expect(pageObjects.isDictating()).resolves.toBeFalsy(); }); diff --git a/__tests__/hooks/useSendBoxSpeechInterimsVisible.js b/__tests__/hooks/useSendBoxSpeechInterimsVisible.js new file mode 100644 index 0000000000..cc1feb4969 --- /dev/null +++ b/__tests__/hooks/useSendBoxSpeechInterimsVisible.js @@ -0,0 +1,92 @@ +import { timeouts } from '../constants.json'; + +import negate from '../setup/conditions/negate'; +import speechRecognitionStartCalled from '../setup/conditions/speechRecognitionStartCalled'; +import speechSynthesisUtterancePended from '../setup/conditions/speechSynthesisUtterancePended'; +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('sendBoxSpeechInterimsVisible 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( + 'useSendBoxSpeechInterimsVisible', + [], + sendBoxSpeechInterimsVisible => sendBoxSpeechInterimsVisible[0] + ) + ).resolves.toMatchInlineSnapshot(`false`); + + await pageObjects.clickMicrophoneButton(); + + await driver.wait(speechRecognitionStartCalled(), timeouts.ui); + + await expect( + pageObjects.runHook( + 'useSendBoxSpeechInterimsVisible', + [], + sendBoxSpeechInterimsVisible => sendBoxSpeechInterimsVisible[0] + ) + ).resolves.toMatchInlineSnapshot(`true`); + + await pageObjects.putSpeechRecognitionResult('recognizing', 'Hello'); + + await expect( + pageObjects.runHook( + 'useSendBoxSpeechInterimsVisible', + [], + sendBoxSpeechInterimsVisible => sendBoxSpeechInterimsVisible[0] + ) + ).resolves.toMatchInlineSnapshot(`true`); +}); + +test('sendBoxSpeechInterimsVisible 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 expect( + pageObjects.runHook( + 'useSendBoxSpeechInterimsVisible', + [], + sendBoxSpeechInterimsVisible => sendBoxSpeechInterimsVisible[0] + ) + ).resolves.toMatchInlineSnapshot(`false`); + + await driver.wait(speechSynthesisUtterancePended(), timeouts.ui); + + await pageObjects.clickMicrophoneButton(); + + await driver.wait(negate(speechSynthesisUtterancePended()), timeouts.ui); + + await expect( + pageObjects.runHook( + 'useSendBoxSpeechInterimsVisible', + [], + sendBoxSpeechInterimsVisible => sendBoxSpeechInterimsVisible[0] + ) + ).resolves.toMatchInlineSnapshot(`true`); +}); + +test('setter should be undefined', async () => { + const { pageObjects } = await setupWebDriver(); + const [_, setLanguage] = await pageObjects.runHook('useSendBoxSpeechInterimsVisible'); + + expect(setLanguage).toBeUndefined(); +}); diff --git a/__tests__/hooks/useStartDictate.js b/__tests__/hooks/useStartDictate.js index d03340c312..8fb4eca99e 100644 --- a/__tests__/hooks/useStartDictate.js +++ b/__tests__/hooks/useStartDictate.js @@ -1,6 +1,5 @@ import { timeouts } from '../constants.json'; -import isDictating from '../setup/pageObjects/isDictating'; import uiConnected from '../setup/conditions/uiConnected'; // selenium-webdriver API doc: @@ -18,5 +17,11 @@ test('calling startDictate should start dictate', async () => { await driver.wait(uiConnected(), timeouts.directLine); await pageObjects.runHook('useStartDictate', [], startDictate => startDictate()); + // The engine is starting, but not fully started yet. await expect(pageObjects.isDictating()).resolves.toBeFalsy(); + + await pageObjects.putSpeechRecognitionResult('recognizing', 'Hello, World!'); + + // The engine has started, and recognition is ongoing and is not stopping. + await expect(pageObjects.isDictating()).resolves.toBeTruthy(); }); diff --git a/__tests__/hooks/useTextBox.js b/__tests__/hooks/useTextBox.js index 2873052478..d67fe8d42a 100644 --- a/__tests__/hooks/useTextBox.js +++ b/__tests__/hooks/useTextBox.js @@ -9,21 +9,31 @@ import uiConnected from '../setup/conditions/uiConnected'; jest.setTimeout(timeouts.test); -// TODO: [P1] Test is temporarily disable until fully implemented 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); + 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(minNumActivitiesShown(4), timeouts.directLine); + await driver.wait(scrollToBottomCompleted(), timeouts.scrollToBottom); + + expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); }); diff --git a/__tests__/hooks/useVoiceSelector.js b/__tests__/hooks/useVoiceSelector.js index dfc07ba405..af4087e05d 100644 --- a/__tests__/hooks/useVoiceSelector.js +++ b/__tests__/hooks/useVoiceSelector.js @@ -34,12 +34,12 @@ test('calling voiceSelector should use selectVoice from props', async () => { ]) ) ).resolves.toMatchInlineSnapshot(` - Object { - "default": false, - "lang": "zh-YUE", - "localService": true, - "name": "Mock Voice (zh-YUE)", - "voiceURI": "mock://web-speech/voice/zh-YUE", - } - `); + Object { + "default": false, + "lang": "zh-YUE", + "localService": true, + "name": "Mock Voice (zh-YUE)", + "voiceURI": "mock://web-speech/voice/zh-YUE", + } + `); }); diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js index ddbf106a7f..303cf98420 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; -import { Components, connectToWebChat, getTabIndex, hooks } from 'botframework-webchat-component'; +import { Components, getTabIndex, hooks } from 'botframework-webchat-component'; import useAdaptiveCardsHostConfig from '../hooks/useAdaptiveCardsHostConfig'; import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; @@ -226,6 +226,4 @@ AdaptiveCardRenderer.defaultProps = { tapAction: undefined }; -export default connectToWebChat(({ tapAction }) => ({ - tapAction -}))(AdaptiveCardRenderer); +export default AdaptiveCardRenderer; diff --git a/packages/bundle/src/adaptiveCards/Attachment/AnimationCardAttachment.js b/packages/bundle/src/adaptiveCards/Attachment/AnimationCardAttachment.js index f8d2e784b8..14e570f3ec 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AnimationCardAttachment.js +++ b/packages/bundle/src/adaptiveCards/Attachment/AnimationCardAttachment.js @@ -9,12 +9,7 @@ import CommonCard from './CommonCard'; const { ImageContent, VideoContent } = Components; const { useStyleSet } = hooks; -const AnimationCardAttachment = ({ - adaptiveCardHostConfig, - adaptiveCards, - attachment, - attachment: { content: { media = [] } } = {} -}) => { +const AnimationCardAttachment = ({ attachment, attachment: { content: { media = [] } } = {} }) => { const [{ animationCardAttachment: animationCardAttachmentStyleSet }] = useStyleSet(); return ( @@ -27,18 +22,12 @@ const AnimationCardAttachment = ({ ))} - + ); }; AnimationCardAttachment.propTypes = { - adaptiveCardHostConfig: PropTypes.any.isRequired, - adaptiveCards: PropTypes.any.isRequired, attachment: PropTypes.shape({ content: PropTypes.shape({ media: PropTypes.arrayOf( diff --git a/packages/component/src/Activity/CarouselFilmStrip.js b/packages/component/src/Activity/CarouselFilmStrip.js index 45a6183ee4..b1a5a0d632 100644 --- a/packages/component/src/Activity/CarouselFilmStrip.js +++ b/packages/component/src/Activity/CarouselFilmStrip.js @@ -119,6 +119,7 @@ const WebChatCarouselFilmStrip = ({ const indented = fromUser ? bubbleFromUserNubSize : bubbleNubSize; const initials = fromUser ? userInitials : botInitials; const roleLabel = fromUser ? userRoleLabel : botRoleLabel; + return (
{ return ( ); }; @@ -31,10 +31,7 @@ ScrollToEndButton.defaultProps = { }; ScrollToEndButton.propTypes = { - className: PropTypes.string, - styleSet: PropTypes.shape({ - scrollToEndButton: PropTypes.any.isRequired - }).isRequired + className: PropTypes.string }; const ConnectedScrollToEndButton = props => ( diff --git a/packages/component/src/BasicSendBox.js b/packages/component/src/BasicSendBox.js index a3f60d7963..7406d979a3 100644 --- a/packages/component/src/BasicSendBox.js +++ b/packages/component/src/BasicSendBox.js @@ -37,7 +37,7 @@ function activityIsSpeakingOrQueuedToSpeak({ channelData: { speak } = {} }) { return !!speak; } -function useSendBoxDictationStarted() { +function useSendBoxSpeechInterimsVisible() { const [activities] = useActivities(); const [dictateState] = useDictateState(); @@ -51,7 +51,7 @@ const BasicSendBox = ({ className }) => { const [{ hideUploadButton }] = useStyleOptions(); const [{ sendBox: sendBoxStyleSet }] = useStyleSet(); const [{ SpeechRecognition } = {}] = useWebSpeechPonyfill(); - const [dictationStarted] = useSendBoxDictationStarted(); + const [speechInterimsVisible] = useSendBoxSpeechInterimsVisible(); const supportSpeechRecognition = !!SpeechRecognition; @@ -62,7 +62,7 @@ const BasicSendBox = ({ className }) => {
{!hideUploadButton && } - {dictationStarted ? ( + {speechInterimsVisible ? ( ) : ( @@ -85,4 +85,4 @@ BasicSendBox.propTypes = { export default BasicSendBox; -export { useSendBoxDictationStarted }; +export { useSendBoxSpeechInterimsVisible }; diff --git a/packages/component/src/BasicTranscript.js b/packages/component/src/BasicTranscript.js index 0bfa715684..5532684314 100644 --- a/packages/component/src/BasicTranscript.js +++ b/packages/component/src/BasicTranscript.js @@ -3,13 +3,14 @@ import { css } from 'glamor'; import { Panel as ScrollToBottomPanel } from 'react-scroll-to-bottom'; import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useMemo } from 'react'; -import connectToWebChat from './connectToWebChat'; import ScrollToEndButton from './Activity/ScrollToEndButton'; import SpeakActivity from './Activity/Speak'; import useActivities from './hooks/useActivities'; import useGroupTimestamp from './hooks/useGroupTimestamp'; +import useRenderActivity from './hooks/useRenderActivity'; +import useRenderAttachment from './hooks/useRenderAttachment'; import useStyleOptions from './hooks/useStyleOptions'; import useStyleSet from './hooks/useStyleSet'; import useWebSpeechPonyfill from './hooks/useWebSpeechPonyfill'; @@ -61,31 +62,54 @@ function sameTimestampGroup(activityX, activityY, groupTimestamp) { return false; } -const BasicTranscript = ({ activityRenderer, attachmentRenderer, className }) => { +const BasicTranscript = ({ className }) => { const [{ activities: activitiesStyleSet, activity: activityStyleSet }] = useStyleSet(); const [{ hideScrollToEndButton }] = useStyleOptions(); + const [{ speechSynthesis, SpeechSynthesisUtterance } = {}] = useWebSpeechPonyfill(); const [activities] = useActivities(); const [groupTimestamp] = useGroupTimestamp(); - const [{ speechSynthesis, SpeechSynthesisUtterance } = {}] = useWebSpeechPonyfill(); + const renderAttachment = useRenderAttachment(); + const renderActivity = useRenderActivity(renderAttachment); // We use 2-pass approach for rendering activities, for show/hide timestamp grouping. // Until the activity pass thru middleware, we never know if it is going to show up. // After we know which activities will show up, we can compute which activity will show timestamps. // If the activity does not render, it will not be spoken if text-to-speech is enabled. - const activityElements = activities.reduce((activityElements, activity) => { - const element = activityRenderer({ - activity, - timestampClassName: 'transcript-timestamp' - })(({ attachment }) => attachmentRenderer({ activity, attachment })); + const activityElements = useMemo( + () => + activities.reduce((activityElements, activity) => { + const element = renderActivity({ + activity, + timestampClassName: 'transcript-timestamp' + }); + + element && activityElements.push({ activity, element }); + + return activityElements; + }, []), + [activities, renderActivity] + ); + + const activityElementsWithMetadata = useMemo( + () => + activityElements.map((activityElement, index) => { + const { activity } = activityElement; + const { activity: nextActivity } = activityElements[index + 1] || {}; - element && - activityElements.push({ - activity, - element - }); + return { + ...activityElement, - return activityElements; - }, []); + key: (activity.channelData && activity.channelData.clientActivityID) || activity.id || index, + + // TODO: [P2] We should use core/definitions/speakingActivity for this predicate instead + shouldSpeak: activity.channelData && activity.channelData.speak, + + // Hide timestamp if same timestamp group with the next activity + timestampVisible: !sameTimestampGroup(activity, nextActivity, groupTimestamp) + }; + }), + [activityElements, groupTimestamp] + ); return (
@@ -103,24 +127,19 @@ const BasicTranscript = ({ activityRenderer, attachmentRenderer, className }) => className={classNames(LIST_CSS + '', activitiesStyleSet + '')} role="list" > - {activityElements.map(({ activity, element }, index) => ( + {activityElementsWithMetadata.map(({ activity, element, key, timestampVisible, shouldSpeak }) => (
  • {element} - {// TODO: [P2] We should use core/definitions/speakingActivity for this predicate instead - activity.channelData && activity.channelData.speak && } + {shouldSpeak && }
  • ))} @@ -136,12 +155,7 @@ BasicTranscript.defaultProps = { }; BasicTranscript.propTypes = { - activityRenderer: PropTypes.func.isRequired, - attachmentRenderer: PropTypes.func.isRequired, className: PropTypes.string }; -export default connectToWebChat(({ activityRenderer, attachmentRenderer }) => ({ - activityRenderer, - attachmentRenderer -}))(BasicTranscript); +export default BasicTranscript; diff --git a/packages/component/src/Dictation.js b/packages/component/src/Dictation.js index a72daaea26..9e26ffe155 100644 --- a/packages/component/src/Dictation.js +++ b/packages/component/src/Dictation.js @@ -3,12 +3,11 @@ import { Constants } from 'botframework-webchat-core'; import PropTypes from 'prop-types'; import React, { useCallback, useMemo } from 'react'; -import connectToWebChat from './connectToWebChat'; - import useActivities from './hooks/useActivities'; import useDictateInterims from './hooks/useDictateInterims'; import useDictateState from './hooks/useDictateState'; 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'; @@ -22,7 +21,7 @@ const { DictateState: { DICTATING, IDLE, STARTING } } = Constants; -const Dictation = ({ emitTypingIndicator, onError }) => { +const Dictation = ({ onError }) => { const [, setDictateInterims] = useDictateInterims(); const [, setSendBox] = useSendBoxValue(); const [, setShouldSpeakIncomingActivity] = useShouldSpeakIncomingActivity(); @@ -32,6 +31,7 @@ const Dictation = ({ emitTypingIndicator, onError }) => { const [disabled] = useDisabled(); const [language] = useLanguage(); const [sendTypingIndicator] = useSendTypingIndicator(); + const emitTypingIndicator = useEmitTypingIndicator(); const setDictateState = useSetDictateState(); const stopDictate = useStopDictate(); const submitSendBox = useSubmitSendBox(); @@ -103,10 +103,7 @@ Dictation.defaultProps = { }; Dictation.propTypes = { - emitTypingIndicator: PropTypes.func.isRequired, onError: PropTypes.func }; -export default connectToWebChat(({ emitTypingIndicator }) => ({ - emitTypingIndicator -}))(Dictation); +export default Dictation; diff --git a/packages/component/src/SendBox/DictationInterims.js b/packages/component/src/SendBox/DictationInterims.js index 4ed56b1140..a8a0656c73 100644 --- a/packages/component/src/SendBox/DictationInterims.js +++ b/packages/component/src/SendBox/DictationInterims.js @@ -8,9 +8,9 @@ import React from 'react'; import { Constants } from 'botframework-webchat-core'; import connectToWebChat from '../connectToWebChat'; -import Localize from '../Localization/Localize'; import useDictateInterims from '../hooks/useDictateInterims'; import useDictateState from '../hooks/useDictateState'; +import useLocalize from '../hooks/useLocalize'; import useStyleSet from '../hooks/useStyleSet'; const { @@ -37,9 +37,12 @@ const DictationInterims = ({ className }) => { const [dictateState] = useDictateState(); const [{ dictationInterims: dictationInterimsStyleSet }] = useStyleSet(); + const listeningText = useLocalize('Listening\u2026'); + const startingText = useLocalize('Starting\u2026'); + return dictateState === STARTING || dictateState === STOPPING ? (

    - {dictateState === STARTING && } + {dictateState === STARTING && startingText}

    ) : ( dictateState === DICTATING && @@ -54,7 +57,7 @@ const DictationInterims = ({ className }) => {

    ) : (

    - + {listeningText}

    )) ); diff --git a/packages/component/src/SendBox/MicrophoneButton.js b/packages/component/src/SendBox/MicrophoneButton.js index 22ee23839d..e2ae7c5519 100644 --- a/packages/component/src/SendBox/MicrophoneButton.js +++ b/packages/component/src/SendBox/MicrophoneButton.js @@ -132,7 +132,6 @@ const useMicrophoneButtonClick = () => { ]); }; -// TODO: [P1] We temporarily not exporting this hook because it requires legacy HOC code function useMicrophoneButtonDisabled() { const [dictateState] = useDictateState(); const [disabled] = useDisabled(); diff --git a/packages/component/src/SendBox/TextBox.js b/packages/component/src/SendBox/TextBox.js index 307d8730c9..b0b14d0d2b 100644 --- a/packages/component/src/SendBox/TextBox.js +++ b/packages/component/src/SendBox/TextBox.js @@ -10,6 +10,7 @@ import useFocusSendBox from '../hooks/useFocusSendBox'; import useLocalize from '../hooks/useLocalize'; import useScrollToEnd from '../hooks/useScrollToEnd'; import useSendBoxValue from '../hooks/useSendBoxValue'; +import useStopDictate from '../hooks/useStopDictate'; import useStyleOptions from '../hooks/useStyleOptions'; import useStyleSet from '../hooks/useStyleSet'; import useSubmitSendBox from '../hooks/useSubmitSendBox'; @@ -75,12 +76,9 @@ function useTextBoxSubmit(setFocus) { }, [focusSendBox, scrollToEnd, sendBoxValue, setFocus, submitSendBox]); } -// TODO: [P1] Hook is temporarily not exporting until fully implemented -function useTextBoxValue({ stopDictate }) { +function useTextBoxValue() { const [value, setSendBox] = useSendBoxValue(); - - // TODO: [P1] Temporarily using non-hook version - // const stopDictate = useStopDictate(); + const stopDictate = useStopDictate(); const setter = useCallback( value => { @@ -93,11 +91,11 @@ function useTextBoxValue({ stopDictate }) { return [value, setter]; } -const TextBox = ({ className, stopDictate }) => { +const TextBox = ({ className }) => { const [{ sendBoxTextWrap }] = useStyleOptions(); const [{ sendBoxTextArea: sendBoxTextAreaStyleSet, sendBoxTextBox: sendBoxTextBoxStyleSet }] = useStyleSet(); const [disabled] = useDisabled(); - const [textBoxValue, setTextBoxValue] = useTextBoxValue({ stopDictate }); + const [textBoxValue, setTextBoxValue] = useTextBoxValue(); const submitTextBox = useTextBoxSubmit(); const sendBoxString = useLocalize('Sendbox'); @@ -177,13 +175,9 @@ TextBox.defaultProps = { }; TextBox.propTypes = { - className: PropTypes.string, - stopDictate: PropTypes.func.isRequired + className: PropTypes.string }; -export default connectToWebChat(({ stopDictate }) => ({ stopDictate }))(TextBox); - -// TODO: [P1] Export useTextBoxValue when it is fully implemented -// export { connectSendTextBox, useTextBoxSubmit, useTextBoxValue }; +export default TextBox; -export { connectSendTextBox, useTextBoxSubmit }; +export { connectSendTextBox, useTextBoxSubmit, useTextBoxValue }; diff --git a/packages/component/src/hooks/index.js b/packages/component/src/hooks/index.js index 57e1b47aad..16e6e6f303 100644 --- a/packages/component/src/hooks/index.js +++ b/packages/component/src/hooks/index.js @@ -17,6 +17,8 @@ import useMarkActivityAsSpoken from './useMarkActivityAsSpoken'; import usePerformCardAction from './usePerformCardAction'; import usePostActivity from './usePostActivity'; import useReferenceGrammarID from './useReferenceGrammarID'; +import useRenderActivity from './useRenderActivity'; +import useRenderAttachment from './useRenderAttachment'; import useRenderMarkdownAsHTML from './useRenderMarkdownAsHTML'; import useScrollToEnd from './useScrollToEnd'; import useSendBoxValue from './useSendBoxValue'; @@ -40,8 +42,8 @@ import useVoiceSelector from './useVoiceSelector'; import useWebSpeechPonyfill from './useWebSpeechPonyfill'; import { useMicrophoneButtonClick, useMicrophoneButtonDisabled } from '../SendBox/MicrophoneButton'; -import { useSendBoxDictationStarted } from '../BasicSendBox'; -import { useTextBoxSubmit } from '../SendBox/TextBox'; +import { useSendBoxSpeechInterimsVisible } from '../BasicSendBox'; +import { useTextBoxSubmit, useTextBoxValue } from '../SendBox/TextBox'; import { useTypingIndicatorVisible } from '../SendBox/TypingIndicator'; export { @@ -66,9 +68,11 @@ export { usePerformCardAction, usePostActivity, useReferenceGrammarID, + useRenderActivity, + useRenderAttachment, useRenderMarkdownAsHTML, useScrollToEnd, - useSendBoxDictationStarted, + useSendBoxSpeechInterimsVisible, useSendBoxValue, useSendEvent, useSendFiles, @@ -84,6 +88,7 @@ export { useSubmitSendBox, useSuggestedActions, useTextBoxSubmit, + useTextBoxValue, useTimeoutForSend, useTypingIndicatorVisible, useUserID, diff --git a/packages/component/src/hooks/useRenderActivity.js b/packages/component/src/hooks/useRenderActivity.js new file mode 100644 index 0000000000..5e2897df8a --- /dev/null +++ b/packages/component/src/hooks/useRenderActivity.js @@ -0,0 +1,21 @@ +import { useCallback, useContext } from 'react'; + +import WebChatUIContext from '../WebChatUIContext'; + +export default function useRenderActivity(renderAttachment) { + const { activityRenderer } = useContext(WebChatUIContext); + + return useCallback( + ({ activity, ...renderActivityArgs }) => + activityRenderer({ + activity, + ...renderActivityArgs + })(renderAttachmentArgs => + renderAttachment({ + activity, + ...renderAttachmentArgs + }) + ), + [activityRenderer, renderAttachment] + ); +} diff --git a/packages/component/src/hooks/useRenderAttachment.js b/packages/component/src/hooks/useRenderAttachment.js new file mode 100644 index 0000000000..0a295fa3a6 --- /dev/null +++ b/packages/component/src/hooks/useRenderAttachment.js @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import WebChatUIContext from '../WebChatUIContext'; + +export default function useRenderAttachment() { + return useContext(WebChatUIContext).attachmentRenderer; +} diff --git a/packages/component/src/hooks/useRenderMarkdownAsHTML.js b/packages/component/src/hooks/useRenderMarkdownAsHTML.js index 2879216ac1..21c61aeb45 100644 --- a/packages/component/src/hooks/useRenderMarkdownAsHTML.js +++ b/packages/component/src/hooks/useRenderMarkdownAsHTML.js @@ -5,7 +5,7 @@ import WebChatUIContext from '../WebChatUIContext'; export default function useRenderMarkdownAsHTML() { const { renderMarkdown } = useContext(WebChatUIContext); - const styleOptions = useStyleOptions(); + const [styleOptions] = useStyleOptions(); return useCallback(markdown => renderMarkdown(markdown, styleOptions), [renderMarkdown, styleOptions]); } diff --git a/packages/component/src/hooks/useShouldSpeakIncomingActivity.js b/packages/component/src/hooks/useShouldSpeakIncomingActivity.js index 6bda739e29..27bf0b2313 100644 --- a/packages/component/src/hooks/useShouldSpeakIncomingActivity.js +++ b/packages/component/src/hooks/useShouldSpeakIncomingActivity.js @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useCallback, useContext } from 'react'; import { useSelector } from '../WebChatReduxContext'; import WebChatUIContext from '../WebChatUIContext'; @@ -8,8 +8,11 @@ export default function useShouldSpeakIncomingActivity() { return [ useSelector(({ shouldSpeakIncomingActivity }) => shouldSpeakIncomingActivity), - value => { - value ? startSpeakingActivity() : stopSpeakingActivity(); - } + useCallback( + value => { + value ? startSpeakingActivity() : stopSpeakingActivity(); + }, + [startSpeakingActivity, stopSpeakingActivity] + ) ]; } diff --git a/packages/component/src/hooks/useSuggestedActions.js b/packages/component/src/hooks/useSuggestedActions.js index c80cd1f91c..52375865e2 100644 --- a/packages/component/src/hooks/useSuggestedActions.js +++ b/packages/component/src/hooks/useSuggestedActions.js @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useCallback, useContext } from 'react'; import { useSelector } from '../WebChatReduxContext'; import WebChatUIContext from '../WebChatUIContext'; @@ -9,12 +9,15 @@ export default function useSuggestedActions() { return [ value, - value => { - if (value && value.length) { - throw new Error('SuggestedActions cannot be set to values other than empty.'); - } + useCallback( + value => { + if (value && value.length) { + throw new Error('SuggestedActions cannot be set to values other than empty.'); + } - clearSuggestedActions(); - } + clearSuggestedActions(); + }, + [clearSuggestedActions] + ) ]; }