diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec3fd8958..f554dad2f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - If the new strings are undesirable, please use the [`overideLocalizedStrings` prop](https://github.com/microsoft/BotFramework-WebChat/tree/master/docs/LOCALIZATION.md#overriding-localization-strings) for customization - String IDs have been refreshed and now use a standard format - `useLocalize` and `useLocalizeDate` is deprecated. Please use `useLocalizer` and `useDateFormatter` instead +- Customizable typing indicator: data and hook related to typing indicator are being revamped in PR [#2912](https://github.com/microsoft/BotFramework-WebChat/pull/2912) + - `lastTypingAt` reducer is deprecated, use `typing` instead. The newer reducer contains typing indicator from the user + - `useLastTypingAt()` hook is deprecated, use `useActiveTyping(duration?: number)` instead. For all typing information, pass `Infinity` to `duration` argument ### Added @@ -45,6 +48,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Resolves [#2756](https://github.com/microsoft/BotFramework-WebChat/issues/2756). Improved localizability and add override support for localized strings, by [@compulim](https://github.com/compulim) in PR [#2894](https://github.com/microsoft/BotFramework-WebChat/pull/2894) - Will be translated into 44 languages, plus 2 community-contributed translations - For details, please read the [documentation on the localization](https://github.com/microsoft/BotFramework-WebChat/tree/master/docs/LOCALIZATION.md) +- Resolves [#2213](https://github.com/microsoft/BotFramework-WebChat/issues/2213). Added customization for typing activity, by [@compulim](https://github.com/compulim), in PR [#2912](https://github.com/microsoft/BotFramework-WebChat/pull/2912) ### Fixed @@ -137,6 +141,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Bump samples to Web Chat 4.7.0, by [@compulim](https://github.com/compulim) in PR [#2726](https://github.com/microsoft/BotFramework-WebChat/issues/2726) - Resolves [#2641](https://github.com/microsoft/BotFramework-WebChat/issues/2641). Reorganize Web Chat samples, by [@corinagum](https://github.com/corinagum), in PR [#2762](https://github.com/microsoft/BotFramework-WebChat/pull/2762) - Resolves [#2755](https://github.com/microsoft/BotFramework-WebChat/issues/2755), added "how to use notification and customize the toast UI" sample, by [@compulim](https://github.com/compulim), in PR [#2883](https://github.com/microsoft/BotFramework-WebChat/pull/2883) +- Resolves [#2213](https://github.com/microsoft/BotFramework-WebChat/issues/2213). Added [Customize Typing Indicator Demo](https://microsoft.github.io/BotFramework-WebChat/05.custom-components/j.typing-indicator), by [@compulim](https://github.com/compulim), in PR [#2912](https://github.com/microsoft/BotFramework-WebChat/pull/2912) ## [4.7.1] - 2019-12-13 diff --git a/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-1-snap.png b/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-1-snap.png new file mode 100644 index 0000000000..1060848fc0 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-1-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-2-snap.png b/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-2-snap.png new file mode 100644 index 0000000000..4ffc32d080 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-2-snap.png differ diff --git a/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-3-snap.png b/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-3-snap.png new file mode 100644 index 0000000000..1060848fc0 Binary files /dev/null and b/__tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-3-snap.png differ diff --git a/__tests__/hooks/useActiveTyping.js b/__tests__/hooks/useActiveTyping.js new file mode 100644 index 0000000000..e9959ca2b9 --- /dev/null +++ b/__tests__/hooks/useActiveTyping.js @@ -0,0 +1,150 @@ +import { Key } from 'selenium-webdriver'; + +import { 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('getter should represent bot and user typing respectively', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { sendTypingIndicator: true }, + setup: () => + Promise.all([ + window.WebChatTest.loadScript('https://unpkg.com/core-js@2.6.3/client/core.min.js'), + window.WebChatTest.loadScript('https://unpkg.com/lolex@4.0.1/lolex.js') + ]).then(() => { + window.WebChatTest.clock = lolex.install(); + }) + }); + + await driver.wait(uiConnected(), timeouts.directLine); + + let activeTyping; + + await expect(pageObjects.runHook('useActiveTyping')).resolves.toEqual([{}]); + + await pageObjects.typeOnSendBox('typing 1'); + + activeTyping = await pageObjects.runHook('useActiveTyping'); + + expect(Object.values(activeTyping[0])).toEqual([ + { + at: 0, + expireAt: 5000, + name: 'Happy Web Chat user', + role: 'user' + } + ]); + + await pageObjects.typeOnSendBox(Key.ENTER); + await driver.wait(minNumActivitiesShown(2), timeouts.directLine); + + activeTyping = await pageObjects.runHook('useActiveTyping'); + + expect(Object.values(activeTyping[0])).toEqual([ + { + at: 0, + expireAt: 5000, + name: 'bot', + role: 'bot' + } + ]); + + await pageObjects.typeOnSendBox('.'); + + activeTyping = await pageObjects.runHook('useActiveTyping'); + + expect(Object.values(activeTyping[0]).sort(({ role: x }, { role: y }) => x - y)).toEqual([ + { + at: 0, + expireAt: 5000, + name: 'bot', + role: 'bot' + }, + { + at: 0, + expireAt: 5000, + name: 'Happy Web Chat user', + role: 'user' + } + ]); +}); + +test('getter should filter out inactive typing', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { sendTypingIndicator: true }, + setup: () => + Promise.all([ + window.WebChatTest.loadScript('https://unpkg.com/core-js@2.6.3/client/core.min.js'), + window.WebChatTest.loadScript('https://unpkg.com/lolex@4.0.1/lolex.js') + ]).then(() => { + window.WebChatTest.clock = lolex.install(); + }) + }); + + await driver.wait(uiConnected(), timeouts.directLine); + + let activeTyping; + + await expect(pageObjects.runHook('useActiveTyping')).resolves.toEqual([{}]); + + await pageObjects.typeOnSendBox('Hello, World!'); + + activeTyping = await pageObjects.runHook('useActiveTyping'); + + expect(Object.values(activeTyping[0])).toEqual([ + { + at: 0, + expireAt: 5000, + name: 'Happy Web Chat user', + role: 'user' + } + ]); + + // We need to wait for 6000 ms because: + // 1. t=0: Typed letter "H" + // 2. t=0: Send typing activity + // 3. t=10: Typed letter "a" + // 4. t=10: Scheduled another typing indicator at t=3000 + // 5. t=3000: Send typing activity + await driver.executeScript(() => window.WebChatTest.clock.tick(3000)); + + activeTyping = await pageObjects.runHook('useActiveTyping'); + + expect(Object.values(activeTyping[0])).toEqual([ + { + at: 3000, + expireAt: 8000, + name: 'Happy Web Chat user', + role: 'user' + } + ]); + + await driver.executeScript(() => window.WebChatTest.clock.tick(8000)); + + await expect(pageObjects.runHook('useActiveTyping')).resolves.toEqual([{}]); + + // Even it is filtered out, when setting a longer expiration, it should come back. + activeTyping = await pageObjects.runHook('useActiveTyping', [10000]); + + expect(Object.values(activeTyping[0])).toEqual([ + { + at: 3000, + expireAt: 13000, + name: 'Happy Web Chat user', + role: 'user' + } + ]); +}); + +test('setter should be falsy', async () => { + const { pageObjects } = await setupWebDriver(); + const [_, setActiveTyping] = await pageObjects.runHook('useActiveTyping'); + + expect(setActiveTyping).toBeFalsy(); +}); diff --git a/__tests__/sendTypingIndicator.js b/__tests__/sendTypingIndicator.js index fae387f3a4..b3d3a8d48b 100644 --- a/__tests__/sendTypingIndicator.js +++ b/__tests__/sendTypingIndicator.js @@ -2,8 +2,10 @@ import { By } from 'selenium-webdriver'; import { imageSnapshotOptions, timeouts } from './constants.json'; import minNumActivitiesShown from './setup/conditions/minNumActivitiesShown'; +import negationOf from './setup/conditions/negationOf'; import typingActivityReceived from './setup/conditions/typingActivityReceived'; import typingAnimationBackgroundImage from './setup/assets/typingIndicator'; +import typingIndicatorShown from './setup/conditions/typingIndicatorShown'; import uiConnected from './setup/conditions/uiConnected'; // selenium-webdriver API doc: @@ -54,3 +56,31 @@ test('typing indicator should not display after second activity', async () => { const base64PNG = await driver.takeScreenshot(); expect(base64PNG).toMatchImageSnapshot(imageSnapshotOptions); }); + +test('changing typing indicator duration on-the-fly', async () => { + const { driver, pageObjects } = await setupWebDriver({ + props: { + styleOptions: { typingAnimationBackgroundImage, typingAnimationDuration: 1000 } + } + }); + + await driver.wait(uiConnected(), timeouts.directLine); + + await pageObjects.sendMessageViaSendBox('typing 1', { waitForSend: true }); + await driver.wait(minNumActivitiesShown(2), timeouts.directLine); + await driver.wait(typingIndicatorShown(), timeouts.ui); + + expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); + + await driver.wait(negationOf(typingIndicatorShown()), 2000); + + expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); + + await pageObjects.updateProps({ + styleOptions: { typingAnimationBackgroundImage, typingAnimationDuration: 5000 } + }); + + await driver.wait(typingIndicatorShown(), timeouts.ui); + + expect(await driver.takeScreenshot()).toMatchImageSnapshot(imageSnapshotOptions); +}); diff --git a/__tests__/setup/conditions/typingIndicatorShown.js b/__tests__/setup/conditions/typingIndicatorShown.js new file mode 100644 index 0000000000..534d36928d --- /dev/null +++ b/__tests__/setup/conditions/typingIndicatorShown.js @@ -0,0 +1,5 @@ +import { By, until } from 'selenium-webdriver'; + +export default function typingIndicatorShown() { + return until.elementLocated(By.css('.webchat__typingIndicator')); +} diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 23446729c1..a845720ef9 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -47,6 +47,7 @@ setSendBoxValue('Hello, World!'); Following is the list of hooks supported by Web Chat API. +- [`useActiveTyping`](#useactivetyping) - [`useActivities`](#useactivities) - [`useAdaptiveCardsHostConfig`](#useadaptivecardshostconfig) - [`useAdaptiveCardsPackage`](#useadaptivecardspackage) @@ -69,6 +70,7 @@ Following is the list of hooks supported by Web Chat API. - [`useLastTypingAt`](#uselasttypingat) - [`useLocalize`](#uselocalize) (Deprecated) - [`useLocalizer`](#useLocalizer) +- [`useLastTypingAt`](#uselasttypingat) (Deprecated) - [`useMarkActivityAsSpoken`](#usemarkactivityasspoken) - [`useNotification`](#usenotification) - [`usePerformCardAction`](#useperformcardaction) @@ -79,6 +81,8 @@ Following is the list of hooks supported by Web Chat API. - [`useRenderActivityStatus`](#userenderactivitystatus) - [`useRenderAttachment`](#userenderattachment) - [`useRenderMarkdownAsHTML`](#userendermarkdownashtml) +- [`useRenderToast`](#userendertoast) +- [`useRenderTypingIndicator`](#userendertypingindicator) - [`useScrollToEnd`](#usescrolltoend) - [`useSendBoxValue`](#usesendboxvalue) - [`useSendEvent`](#usesendevent) @@ -102,6 +106,27 @@ Following is the list of hooks supported by Web Chat API. - [`useVoiceSelector`](#usevoiceselector) - [`useWebSpeechPonyfill`](#usewebspeechponyfill) +## `useActiveTyping` + +```js +interface Typing { + at: number; + expireAt: number; + name: string; + role: 'bot' | 'user'; +} + +useActiveTyping(expireAfter?: number): [{ [id: string]: Typing }] +``` + +This function will return a list of participants who are actively typing, including the start typing time (`at`) and expiration time (`expireAt`), the name and the role of the participant. + +If the participant sends a message after the typing activity, the participant will be explicitly removed from the list. If no messages or typing activities are received, the participant is considered inactive and not listed in the result. To keep the typing indicator active, participants should continuously send the typing activity. + +The `expireAfter` argument can override the inactivity timer. If `expireAfter` is `Infinity`, it will return all participants who did not explicitly remove from the list. In other words, it will return participants who sent a typing activity, but did not send a message activity afterward. + +> This hook will trigger render of your component if one or more typing information is expired or removed. + ## `useActivities` ```js @@ -199,7 +224,7 @@ This function will return a function that, when called with a `Date` object, `nu interface Notification { alt?: string; id: string; - level: 'error' | 'info' | 'success' | 'warn'; + level: 'error' | 'info' | 'success' | 'warn' | string; message: string; } @@ -420,7 +445,7 @@ When called, this function will mark the activity as spoken and remove it from t interface Notification { alt?: string; id: string; - level: 'error' | 'info' | 'success' | 'warn'; + level: 'error' | 'info' | 'success' | 'warn' | string; message: string; } @@ -544,6 +569,45 @@ renderMarkdown('Hello, World!') === '

Hello, World!

\n'; To modify this value, change the value in the style options prop passed to Web Chat. +## `useRenderToast` + +```js +interface Notification { + alt?: string; + id: string; + level: 'error' | 'info' | 'success' | 'warn' | string; + message: string; +} + +useRenderToast(): ({ notification: Notification }) => React.Element +``` + +This function is for rendering a toast for the notification toaster. The caller will need to pass `notification` as parameter to the function. This function is a composition of `toastMiddleware`, which is passed as a prop to Web Chat. + +## `useRenderTypingIndicator` + +```js +interface Typing { + at: number; + expireAt: number; + name: string; + role: 'bot' | 'user'; +} + +useRenderTypingIndicator(): + ({ + activeTyping: { [id: string]: Typing }, + typing: { [id: string]: Typing }, + visible: boolean + }) => React.Element +``` + +This function is for rendering typing indicator for all participants of the conversation. This function is a composition of `typingIndicatorMiddleware`, which is passed as a prop to Web Chat. The caller will pass the following arguments: + +- `activeTyping` lists of participants who are actively typing. +- `typing` lists participants who did not explicitly stopped typing. This list is a superset of `activeTyping`. +- `visible` indicates whether typing indicator should be shown in normal case. This is based on participants in `activeTyping` and their `role` (role not equal to `"user"`). + ## `useScrollToEnd` ```js @@ -631,7 +695,7 @@ To modify this value, change the value in the style options prop passed to Web C interface Notification { alt?: string; id: string; - level: 'error' | 'info' | 'success' | 'warn'; + level: 'error' | 'info' | 'success' | 'warn' | string; message: string; } diff --git a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js index 303cf98420..5acd289d8d 100644 --- a/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js +++ b/packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardRenderer.js @@ -9,7 +9,7 @@ import useAdaptiveCardsHostConfig from '../hooks/useAdaptiveCardsHostConfig'; import useAdaptiveCardsPackage from '../hooks/useAdaptiveCardsPackage'; const { ErrorBox } = Components; -const { useDisabled, useLocalize, usePerformCardAction, useRenderMarkdownAsHTML, useStyleSet } = hooks; +const { useDisabled, useLocalizer, usePerformCardAction, useRenderMarkdownAsHTML, useStyleSet } = hooks; function isPlainObject(obj) { return Object.getPrototypeOf(obj) === Object.prototype; @@ -69,7 +69,7 @@ const AdaptiveCardRenderer = ({ adaptiveCard, tapAction }) => { const [{ HostConfig }] = useAdaptiveCardsPackage(); const [adaptiveCardsHostConfig] = useAdaptiveCardsHostConfig(); const [disabled] = useDisabled(); - const errorMessage = useLocalize('Adaptive Card render error'); + const localize = useLocalizer(); const performCardAction = usePerformCardAction(); const renderMarkdownAsHTML = useRenderMarkdownAsHTML(); @@ -206,7 +206,7 @@ const AdaptiveCardRenderer = ({ adaptiveCard, tapAction }) => { ]); return error ? ( - +
{JSON.stringify(error, null, 2)}
) : ( diff --git a/packages/component/src/Assets/TypingAnimation.js b/packages/component/src/Assets/TypingAnimation.js index c2149c8926..558907c0aa 100644 --- a/packages/component/src/Assets/TypingAnimation.js +++ b/packages/component/src/Assets/TypingAnimation.js @@ -19,7 +19,10 @@ const TypingAnimation = () => { return ( -
+
); }; diff --git a/packages/component/src/BasicTypingIndicator.js b/packages/component/src/BasicTypingIndicator.js index a48b933991..dba311c6c8 100644 --- a/packages/component/src/BasicTypingIndicator.js +++ b/packages/component/src/BasicTypingIndicator.js @@ -1,52 +1,21 @@ -import classNames from 'classnames'; -import React, { useEffect, useState } from 'react'; - -import TypingAnimation from './Assets/TypingAnimation'; -import useDirection from './hooks/useDirection'; -import useLastTypingAt from './hooks/useLastTypingAt'; -import useStyleOptions from './hooks/useStyleOptions'; -import useStyleSet from './hooks/useStyleSet'; +import useActiveTyping from './hooks/useActiveTyping'; +import useRenderTypingIndicator from './hooks/useRenderTypingIndicator'; function useTypingIndicatorVisible() { - const [lastTypingAt] = useLastTypingAt(); - - const [{ typingAnimationDuration }] = useStyleOptions(); - - const last = Math.max(Object.values(lastTypingAt)); - const typingAnimationTimeRemaining = last ? Math.max(0, typingAnimationDuration - Date.now() + last) : 0; - - const [value, setValue] = useState(typingAnimationTimeRemaining > 0); - - useEffect(() => { - let timeout; - - if (typingAnimationTimeRemaining > 0) { - setValue(true); - timeout = setTimeout(() => setValue(false), typingAnimationTimeRemaining); - } else { - setValue(false); - } - - return () => clearTimeout(timeout); - }, [typingAnimationTimeRemaining]); + const [activeTyping] = useActiveTyping(); - return [value]; + return [!!Object.values(activeTyping).filter(({ role }) => role !== 'user').length]; } -const TypingIndicator = () => { - const [{ typingIndicator: typingIndicatorStyleSet }] = useStyleSet(); - const [direction] = useDirection(); - const [showTyping] = useTypingIndicatorVisible(); +const BasicTypingIndicator = () => { + const [activeTyping] = useActiveTyping(); + const [visible] = useTypingIndicatorVisible(); + const [typing] = useActiveTyping(Infinity); + const renderTypingIndicator = useRenderTypingIndicator(); - return ( - showTyping && ( -
- -
- ) - ); + return renderTypingIndicator({ activeTyping, typing, visible }); }; -export default TypingIndicator; +export default BasicTypingIndicator; export { useTypingIndicatorVisible }; diff --git a/packages/component/src/BasicWebChat.js b/packages/component/src/BasicWebChat.js index 814202e693..bf29540b97 100644 --- a/packages/component/src/BasicWebChat.js +++ b/packages/component/src/BasicWebChat.js @@ -16,6 +16,7 @@ import createCoreActivityMiddleware from './Middleware/Activity/createCoreMiddle import createCoreActivityStatusMiddleware from './Middleware/ActivityStatus/createCoreMiddleware'; import createCoreAttachmentMiddleware from './Middleware/Attachment/createCoreMiddleware'; import createCoreToastMiddleware from './Middleware/Toast/createCoreMiddleware'; +import createCoreTypingIndicatorMiddleware from './Middleware/TypingIndicator/createCoreMiddleware'; import ErrorBox from './ErrorBox'; import TypeFocusSinkBox from './Utils/TypeFocusSink'; @@ -122,12 +123,35 @@ function createToastRenderer(additionalMiddleware) { }; } +function createTypingIndicatorRenderer(additionalMiddleware) { + const typingIndicatorMiddleware = concatMiddleware(additionalMiddleware, createCoreTypingIndicatorMiddleware())({}); + + return (...args) => { + try { + return typingIndicatorMiddleware(({ activeTyping, typing, visible }) => ( + +
{JSON.stringify({ activeTyping, typing, visible }, null, 2)}
+
+ ))(...args); + } catch ({ message, stack }) { + console.error({ message, stack }); + + return ( + +
{JSON.stringify({ message, stack }, null, 2)}
+
+ ); + } + }; +} + const BasicWebChat = ({ activityMiddleware, activityStatusMiddleware, attachmentMiddleware, className, toastMiddleware, + typingIndicatorMiddleware, ...otherProps }) => { const sendBoxRef = useRef(); @@ -137,6 +161,9 @@ const BasicWebChat = ({ ]); const attachmentRenderer = useMemo(() => createAttachmentRenderer(attachmentMiddleware), [attachmentMiddleware]); const toastRenderer = useMemo(() => createToastRenderer(toastMiddleware), [toastMiddleware]); + const typingIndicatorRenderer = useMemo(() => createTypingIndicatorRenderer(typingIndicatorMiddleware), [ + typingIndicatorMiddleware + ]); return ( {({ styleSet }) => ( diff --git a/packages/component/src/Composer.js b/packages/component/src/Composer.js index 40abe45b6b..7917aa45e8 100644 --- a/packages/component/src/Composer.js +++ b/packages/component/src/Composer.js @@ -174,6 +174,7 @@ const Composer = ({ styleOptions, styleSet, toastRenderer, + typingIndicatorRenderer, userID, username, webSpeechPonyfillFactory @@ -343,6 +344,7 @@ const Composer = ({ styleOptions, styleSet: patchedStyleSet, toastRenderer, + typingIndicatorRenderer, userID, username, webSpeechPonyfill @@ -373,6 +375,7 @@ const Composer = ({ setDictateAbortable, styleOptions, toastRenderer, + typingIndicatorRenderer, userID, username, webSpeechPonyfill @@ -440,6 +443,7 @@ Composer.defaultProps = { styleOptions: {}, styleSet: undefined, toastRenderer: undefined, + typingIndicatorRenderer: undefined, userID: '', username: '', webSpeechPonyfillFactory: undefined @@ -482,6 +486,7 @@ Composer.propTypes = { styleOptions: PropTypes.any, styleSet: PropTypes.any, toastRenderer: PropTypes.func, + typingIndicatorRenderer: PropTypes.func, userID: PropTypes.string, username: PropTypes.string, webSpeechPonyfillFactory: PropTypes.func diff --git a/packages/component/src/Middleware/TypingIndicator/createCoreMiddleware.js b/packages/component/src/Middleware/TypingIndicator/createCoreMiddleware.js new file mode 100644 index 0000000000..47e459081f --- /dev/null +++ b/packages/component/src/Middleware/TypingIndicator/createCoreMiddleware.js @@ -0,0 +1,24 @@ +import classNames from 'classnames'; +import React from 'react'; + +import TypingAnimation from '../../Assets/TypingAnimation'; +import useDirection from '../../hooks/useDirection'; +import useLocalizer from '../../hooks/useLocalizer'; +import useStyleSet from '../../hooks/useStyleSet'; + +const DotIndicator = () => { + const [{ typingIndicator: typingIndicatorStyleSet }] = useStyleSet(); + const [direction] = useDirection(); + const localize = useLocalizer(); + + return ( +
+ +
+ ); +}; + +// TODO: [P4] Rename this file or the whole middleware, it looks either too simple or too comprehensive now +export default function createCoreMiddleware() { + return () => () => ({ activeTyping }) => !!Object.keys(activeTyping).length && ; +} diff --git a/packages/component/src/Styles/StyleSet/TypingIndicator.js b/packages/component/src/Styles/StyleSet/TypingIndicator.js index e4f1c844fa..c615238eac 100644 --- a/packages/component/src/Styles/StyleSet/TypingIndicator.js +++ b/packages/component/src/Styles/StyleSet/TypingIndicator.js @@ -2,10 +2,11 @@ export default function createTypingIndicatorStyle({ paddingRegular }) { return { paddingBottom: paddingRegular, - '&:not(.rtl)': { + '&:not(.webchat__typingIndicator--rtl)': { paddingLeft: paddingRegular }, - '&.rtl': { + + '&.webchat__typingIndicator--rtl': { paddingRight: paddingRegular } }; diff --git a/packages/component/src/hooks/index.js b/packages/component/src/hooks/index.js index 69854eaa17..0b2fd00e74 100644 --- a/packages/component/src/hooks/index.js +++ b/packages/component/src/hooks/index.js @@ -1,3 +1,4 @@ +import useActiveTyping from './useActiveTyping'; import useActivities from './useActivities'; import useAvatarForBot from './useAvatarForBot'; import useAvatarForUser from './useAvatarForUser'; @@ -28,6 +29,8 @@ import useRenderActivity from './useRenderActivity'; import useRenderActivityStatus from './useRenderActivityStatus'; import useRenderAttachment from './useRenderAttachment'; import useRenderMarkdownAsHTML from './useRenderMarkdownAsHTML'; +import useRenderToast from './useRenderToast'; +import useRenderTypingIndicator from './useRenderTypingIndicator'; import useScrollToEnd from './useScrollToEnd'; import useSendBoxValue from './useSendBoxValue'; import useSendEvent from './useSendEvent'; @@ -57,6 +60,7 @@ import { useTextBoxSubmit, useTextBoxValue } from '../SendBox/TextBox'; import { useTypingIndicatorVisible } from '../BasicTypingIndicator'; export { + useActiveTyping, useActivities, useAvatarForBot, useAvatarForUser, @@ -89,6 +93,8 @@ export { useRenderActivityStatus, useRenderAttachment, useRenderMarkdownAsHTML, + useRenderToast, + useRenderTypingIndicator, useScrollToEnd, useSendBoxSpeechInterimsVisible, useSendBoxValue, diff --git a/packages/component/src/hooks/useActiveTyping.js b/packages/component/src/hooks/useActiveTyping.js new file mode 100644 index 0000000000..23617155f5 --- /dev/null +++ b/packages/component/src/hooks/useActiveTyping.js @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; + +import { useSelector } from '../WebChatReduxContext'; +import useForceRender from './internal/useForceRender'; +import useStyleOptions from './useStyleOptions'; + +function useActiveTyping(expireAfter) { + const now = Date.now(); + + const [{ typingAnimationDuration }] = useStyleOptions(); + const forceRender = useForceRender(); + const typing = useSelector(({ typing }) => typing); + + if (typeof expireAfter !== 'number') { + expireAfter = typingAnimationDuration; + } + + const activeTyping = Object.entries(typing).reduce((activeTyping, [id, { at, name, role }]) => { + const until = at + expireAfter; + + if (until > now) { + return { ...activeTyping, [id]: { at, expireAt: until, name, role } }; + } + + return activeTyping; + }, {}); + + const earliestExpireAt = Math.min(...Object.values(activeTyping).map(({ expireAt }) => expireAt)); + const timeToRender = earliestExpireAt && earliestExpireAt - now; + + useEffect(() => { + if (timeToRender && isFinite(timeToRender)) { + const timeout = setTimeout(forceRender, Math.max(0, timeToRender)); + + return () => clearTimeout(timeout); + } + }, [forceRender, timeToRender]); + + return [activeTyping]; +} + +export default useActiveTyping; diff --git a/packages/component/src/hooks/useLastTypingAt.js b/packages/component/src/hooks/useLastTypingAt.js index 8a8a62bef8..7a48a57fa5 100644 --- a/packages/component/src/hooks/useLastTypingAt.js +++ b/packages/component/src/hooks/useLastTypingAt.js @@ -1,5 +1,15 @@ import { useSelector } from '../WebChatReduxContext'; +let showDeprecationNotes; + export default function useLastTypingAt() { + if (!showDeprecationNotes) { + console.warn( + 'botframework-webchat: "useLastTypingAt" is deprecated. Please use "useActiveTyping" instead. They will be removed on or after 2022-02-16.' + ); + + showDeprecationNotes = true; + } + return [useSelector(({ lastTypingAt }) => lastTypingAt)]; } diff --git a/packages/component/src/hooks/useRenderTypingIndicator.js b/packages/component/src/hooks/useRenderTypingIndicator.js new file mode 100644 index 0000000000..8d9bf7998e --- /dev/null +++ b/packages/component/src/hooks/useRenderTypingIndicator.js @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import WebChatUIContext from '../WebChatUIContext'; + +export default function useRenderTypingIndicator() { + return useContext(WebChatUIContext).typingIndicatorRenderer; +} diff --git a/packages/core/src/reducer.ts b/packages/core/src/reducer.ts index 3251adf98b..0f0d537448 100644 --- a/packages/core/src/reducer.ts +++ b/packages/core/src/reducer.ts @@ -15,6 +15,7 @@ import sendTimeout from './reducers/sendTimeout'; import sendTypingIndicator from './reducers/sendTypingIndicator'; import shouldSpeakIncomingActivity from './reducers/shouldSpeakIncomingActivity'; import suggestedActions from './reducers/suggestedActions'; +import typing from './reducers/typing'; export default combineReducers({ activities, @@ -23,7 +24,6 @@ export default combineReducers({ dictateInterims, dictateState, language, - lastTypingAt, notifications, readyState, referenceGrammarID, @@ -31,5 +31,9 @@ export default combineReducers({ sendTimeout, sendTypingIndicator, shouldSpeakIncomingActivity, - suggestedActions + suggestedActions, + typing, + + // TODO: [P3] Take this deprecation code out when releasing on or after 2022-02-16 + lastTypingAt }); diff --git a/packages/core/src/reducers/lastTypingAt.js b/packages/core/src/reducers/lastTypingAt.js index 5d7c4288cf..1f1618aa7f 100644 --- a/packages/core/src/reducers/lastTypingAt.js +++ b/packages/core/src/reducers/lastTypingAt.js @@ -1,6 +1,8 @@ /*eslint no-case-declarations: "off"*/ /*eslint no-unused-vars: "off"*/ +import updateIn from 'simple-update-in'; + import { INCOMING_ACTIVITY } from '../actions/incomingActivity'; const DEFAULT_STATE = {}; @@ -14,13 +16,11 @@ export default function lastTypingAt(state = DEFAULT_STATE, { payload, type }) { } } = payload; - if (role === 'bot') { + if (role !== 'user') { if (activityType === 'typing') { - state = { ...state, [id]: Date.now() }; + state = updateIn(state, [id], () => Date.now()); } else if (activityType === 'message') { - const { [id]: last, ...nextState } = state; - - state = nextState; + state = updateIn(state, [id]); } } } diff --git a/packages/core/src/reducers/typing.js b/packages/core/src/reducers/typing.js new file mode 100644 index 0000000000..136d3a15e6 --- /dev/null +++ b/packages/core/src/reducers/typing.js @@ -0,0 +1,32 @@ +/*eslint no-case-declarations: "off"*/ +/*eslint no-unused-vars: "off"*/ + +import updateIn from 'simple-update-in'; + +import { INCOMING_ACTIVITY } from '../actions/incomingActivity'; +import { POST_ACTIVITY_PENDING } from '../actions/postActivity'; + +const DEFAULT_STATE = {}; + +export default function lastTyping(state = DEFAULT_STATE, { payload, type }) { + if (type === INCOMING_ACTIVITY || type === POST_ACTIVITY_PENDING) { + const { + activity: { + from: { id, name, role }, + type: activityType + } + } = payload; + + if (activityType === 'typing') { + state = updateIn(state, [id], () => ({ + at: Date.now(), + name, + role + })); + } else if (activityType === 'message') { + state = updateIn(state, [id]); + } + } + + return state; +} diff --git a/packages/core/src/sagas/sendTypingIndicatorOnSetSendBoxSaga.js b/packages/core/src/sagas/sendTypingIndicatorOnSetSendBoxSaga.js index 11027de4d4..35cbfe72e6 100644 --- a/packages/core/src/sagas/sendTypingIndicatorOnSetSendBoxSaga.js +++ b/packages/core/src/sagas/sendTypingIndicatorOnSetSendBoxSaga.js @@ -25,7 +25,7 @@ function* sendTypingIndicatorOnSetSendBox() { } for (;;) { - let lastSend = 0; + let lastSend = -Infinity; const task = yield takeLatest( ({ payload, type }) => (type === SET_SEND_BOX && payload.text) || @@ -34,7 +34,7 @@ function* sendTypingIndicatorOnSetSendBox() { // When the user type, and then post the activity at t = 1500, we still have a pending typing indicator at t = 3000. // This code is to cancel the typing indicator at t = 3000. (type === POST_ACTIVITY && payload.activity.type !== 'typing'), - function*({ type }) { + function*({ payload, type }) { if (type === SET_SEND_BOX) { const interval = SEND_INTERVAL - Date.now() + lastSend; @@ -45,6 +45,8 @@ function* sendTypingIndicatorOnSetSendBox() { yield put(emitTypingIndicator()); lastSend = Date.now(); + } else if (payload.activity.type === 'message') { + lastSend = -Infinity; } } ); diff --git a/samples/03.speech/b.cognitive-speech-services-js/index.html b/samples/03.speech/b.cognitive-speech-services-js/index.html index c670750f1f..f9c02542bb 100644 --- a/samples/03.speech/b.cognitive-speech-services-js/index.html +++ b/samples/03.speech/b.cognitive-speech-services-js/index.html @@ -78,7 +78,8 @@ // The following code is needed only for Web Chat < 4.8. // Starting from 4.8, we will support the newer "credentials" option, which is preferred over "authorizationToken" and "region". - authorizationToken: () => fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), + authorizationToken: () => + fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), region: fetchSpeechServicesCredentials().then(({ region }) => region) }); diff --git a/samples/03.speech/c.cognitive-speech-services-with-lexical-result/index.html b/samples/03.speech/c.cognitive-speech-services-with-lexical-result/index.html index bfa7d7c0b8..6e75f6207c 100644 --- a/samples/03.speech/c.cognitive-speech-services-with-lexical-result/index.html +++ b/samples/03.speech/c.cognitive-speech-services-with-lexical-result/index.html @@ -79,7 +79,8 @@ // The following code is needed only for Web Chat < 4.8. // Starting from 4.8, we will support the newer "credentials" option, which is preferred over "authorizationToken" and "region". - authorizationToken: () => fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), + authorizationToken: () => + fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), region: fetchSpeechServicesCredentials().then(({ region }) => region) }); diff --git a/samples/03.speech/d.cognitive-speech-services-speech-recognition-only/index.html b/samples/03.speech/d.cognitive-speech-services-speech-recognition-only/index.html index c4645eb78d..0ef0d7b470 100644 --- a/samples/03.speech/d.cognitive-speech-services-speech-recognition-only/index.html +++ b/samples/03.speech/d.cognitive-speech-services-speech-recognition-only/index.html @@ -84,7 +84,8 @@ // The following code is needed only for Web Chat < 4.8. // Starting from 4.8, we will support the newer "credentials" option, which is preferred over "authorizationToken" and "region". - authorizationToken: () => fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), + authorizationToken: () => + fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), region: fetchSpeechServicesCredentials().then(({ region }) => region) } ); diff --git a/samples/03.speech/e.select-voice/index.html b/samples/03.speech/e.select-voice/index.html index 613e45dcf3..e38cabce2b 100644 --- a/samples/03.speech/e.select-voice/index.html +++ b/samples/03.speech/e.select-voice/index.html @@ -80,7 +80,8 @@ // The following code is needed only for Web Chat < 4.8. // Starting from 4.8, we will support the newer "credentials" option, which is preferred over "authorizationToken" and "region". - authorizationToken: () => fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), + authorizationToken: () => + fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), region: fetchSpeechServicesCredentials().then(({ region }) => region) }); diff --git a/samples/03.speech/g.hybrid-speech/index.html b/samples/03.speech/g.hybrid-speech/index.html index e9ea9228a3..a246c6a966 100644 --- a/samples/03.speech/g.hybrid-speech/index.html +++ b/samples/03.speech/g.hybrid-speech/index.html @@ -83,7 +83,8 @@ // The following code is needed only for Web Chat < 4.8. // Starting from 4.8, we will support the newer "credentials" option, which is preferred over "authorizationToken" and "region". - authorizationToken: () => fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), + authorizationToken: () => + fetchSpeechServicesCredentials().then(({ authorizationToken }) => authorizationToken), region: fetchSpeechServicesCredentials().then(({ region }) => region) } ); diff --git a/samples/05.custom-components/j.typing-indicator/README.md b/samples/05.custom-components/j.typing-indicator/README.md new file mode 100644 index 0000000000..9e57181834 --- /dev/null +++ b/samples/05.custom-components/j.typing-indicator/README.md @@ -0,0 +1,191 @@ +# Sample - Customizing typing indicator + +This sample shows how to customize the typing indicator + +# Test out the hosted sample + +- [Try out MockBot](https://microsoft.github.io/BotFramework-WebChat/05.custom-components/j.typing-indicator) + +# How to run + +- Fork this repository +- Navigate to `/Your-Local-WebChat/samples/05.custom-components/j.typing-indicator` in command line +- Run `npx serve` +- Browse to [http://localhost:5000/](http://localhost:5000/) + +# Things to try out + +- Type in the message box +- Send `typing` or `typing 1` to the bot + +# Code + +> Jump to [completed code](#completed-code) to see the end-result `index.html`. + +## Overview + +This sample is based on [`01.getting-started/e.host-with-react`](https://microsoft.github.io/BotFramework-WebChat/01.getting-started/e.host-with-react). + +In this sample, when the bot or the user is typing, it will show a prompt, "Currently typing: bot, user". The customization is done by registering a custom component using the `typingIndicatorMiddleware`. + +> Note: the default typing indicator UI in Web Chat does not display the typing indicator for the user. + +### Send typing activity when user start typing + +First, enable sending typing indicator to the bot, by passing `true` to the `sendTypingIndicator` prop: + +```diff + window.ReactDOM.render( +- , ++ , + document.getElementById('webchat') + ); +``` + +### Registering a custom component for typing indicator + +Then, register a custom component to override the existing typing indicator: + +```diff + window.ReactDOM.render( + next => ({ activeTyping }) => { ++ activeTyping = Object.values(activeTyping); ++ ++ return ( ++ !!activeTyping.length && ( ++ ++ Currently typing:{' '} ++ {activeTyping ++ .map(({ role }) => role) ++ .sort() ++ .join(', ')} ++ ++ ) ++ ); ++ }} + />, + document.getElementById('webchat') + ); +``` + +The `activeTyping` argument is a map of participants who are actively typing: + +```js +{ + mockbot: { + name: 'MockBot', + role: 'bot', + start: 1581905716840, + end: 1581905766840 + }, + dl_a1b2c3d: { + name: 'John Doe', + role: 'user', + start: 1581905716840, + end: 1581905766840 + } +} +``` + +`start` is the time when Web Chat receive the typing indicator from this participant. `end` is the time when the typing indicator should be hidden because the participant stopped typing, but did not send their message. + +When a message is received from a participant who is actively typing, their entry in the `activeTyping` argument will be removed, indicating the typing indicator should be removed for them. + +### Styling the typing indicator + +Add the following CSS for styling the typing indicator: + +```css +.webchat__typingIndicator { + font-family: 'Calibri', 'Helvetica Neue', 'Arial', 'sans-serif'; + font-size: 14px; + padding: 10px; +} +``` + +## Completed code + +Here is the finished `index.html`: + +```diff + + + + Web Chat: Customizing typing indicator + + + + + + + + +
+ + + +``` + +# Further reading + +## Full list of Web Chat hosted samples + +View the list of [available Web Chat samples](https://github.com/microsoft/BotFramework-WebChat/tree/master/samples) diff --git a/samples/05.custom-components/j.typing-indicator/index.html b/samples/05.custom-components/j.typing-indicator/index.html new file mode 100644 index 0000000000..fe01d61906 --- /dev/null +++ b/samples/05.custom-components/j.typing-indicator/index.html @@ -0,0 +1,81 @@ + + + + Web Chat: Customizing typing indicator + + + + + + + + + + + + +
+ + + diff --git a/samples/README.md b/samples/README.md index 8fb9b835bd..7593f2bb3a 100644 --- a/samples/README.md +++ b/samples/README.md @@ -57,6 +57,7 @@ Here you can find all hosted samples of [Web Chat](https://github.com/microsoft/ | [`05.custom-components/f.password-input`](https://github.com/microsoft/BotFramework-WebChat/tree/master/samples/05.custom-components/f.password-input) | Demonstrates how to create custom activity for password input. | [Password Input Demo](https://microsoft.github.io/BotFramework-WebChat/05.custom-components/f.password-input) | | [`05.custom-components/g.activity-status`](https://github.com/microsoft/BotFramework-WebChat/tree/master/samples/05.custom-components/g.activity-status/) | Demonstrates how to customize the activity status by including sender's name. | [Customize Activity Status Demo](https://microsoft.github.io/BotFramework-WebChat/05.custom-components/g.activity-status) | | [`05.custom-components/i.notification`](https://github.com/microsoft/BotFramework-WebChat/tree/master/samples/05.custom-components/i.notification/) | Demonstrates how to use notification and customize the toast UI. | [Notification Demo](https://microsoft.github.io/BotFramework-WebChat/05.custom-components/i.notification) | +| [`05.custom-components/j.typing-indicator`](https://github.com/microsoft/BotFramework-WebChat/tree/master/samples/05.custom-components/j.typing-indicator/) | Demonstrates how to customize the typing indicator. | [Customize Typing Indicator Demo](https://microsoft.github.io/BotFramework-WebChat/05.custom-components/j.typing-indicator) | | **Recomposing UI** | | | | [`06.recomposing-ui/a.minimizable-web-chat`](https://github.com/microsoft/BotFramework-WebChat/tree/master/samples/06.recomposing-ui/a.minimizable-web-chat) | Advanced tutorial: Demonstrates how to add the Web Chat interface to your website as a minimizable show/hide chat box. | [Minimizable Web Chat Demo](https://microsoft.github.io/BotFramework-WebChat/06.recomposing-ui/a.minimizable-web-chat) | | [`06.recomposing-ui/b.speech-ui`](https://github.com/microsoft/BotFramework-WebChat/tree/master/samples/06.recomposing-ui/b.speech-ui) | Advanced tutorial: Demonstrates how to fully customize key components of your bot, in this case speech, which entirely replaces the text-based transcript UI and instead shows a simple speech button with the bot's response. | [Speech UI Demo](https://microsoft.github.io/BotFramework-WebChat/06.recomposing-ui/b.speech-ui) |