From 21728701879efd414ba8313d48743b9f6e02c7c7 Mon Sep 17 00:00:00 2001 From: William Wong Date: Fri, 21 Feb 2020 05:25:29 +0800 Subject: [PATCH] Customizing typing indicator (#2912) * Add activeTyping * Fix send typing momentarily not showing after post * Add sample * Doc * Fix build break * Doc * Format * Add entry * Add GitHub CDN * Fix tests * Add tests and doc * Add tests * Remove dead code * Use useLocalizer * Format * Apply suggestions from code review Co-Authored-By: TJ Durnford * Remove deprecated code Co-authored-by: TJ Durnford --- CHANGELOG.md | 5 + ...g-indicator-duration-on-the-fly-1-snap.png | Bin 0 -> 16869 bytes ...g-indicator-duration-on-the-fly-2-snap.png | Bin 0 -> 15326 bytes ...g-indicator-duration-on-the-fly-3-snap.png | Bin 0 -> 16869 bytes __tests__/hooks/useActiveTyping.js | 150 ++++++++++++++ __tests__/sendTypingIndicator.js | 30 +++ .../setup/conditions/typingIndicatorShown.js | 5 + docs/HOOKS.md | 70 ++++++- .../Attachment/AdaptiveCardRenderer.js | 6 +- .../component/src/Assets/TypingAnimation.js | 5 +- .../component/src/BasicTypingIndicator.js | 53 +---- packages/component/src/BasicWebChat.js | 28 +++ packages/component/src/Composer.js | 5 + .../TypingIndicator/createCoreMiddleware.js | 24 +++ .../src/Styles/StyleSet/TypingIndicator.js | 5 +- packages/component/src/hooks/index.js | 6 + .../component/src/hooks/useActiveTyping.js | 42 ++++ .../component/src/hooks/useLastTypingAt.js | 10 + .../src/hooks/useRenderTypingIndicator.js | 7 + packages/core/src/reducer.ts | 8 +- packages/core/src/reducers/lastTypingAt.js | 10 +- packages/core/src/reducers/typing.js | 32 +++ .../sendTypingIndicatorOnSetSendBoxSaga.js | 6 +- .../b.cognitive-speech-services-js/index.html | 3 +- .../index.html | 3 +- .../index.html | 3 +- samples/03.speech/e.select-voice/index.html | 3 +- samples/03.speech/g.hybrid-speech/index.html | 3 +- .../j.typing-indicator/README.md | 191 ++++++++++++++++++ .../j.typing-indicator/index.html | 81 ++++++++ samples/README.md | 1 + 31 files changed, 730 insertions(+), 65 deletions(-) create mode 100644 __tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-1-snap.png create mode 100644 __tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-2-snap.png create mode 100644 __tests__/__image_snapshots__/chrome-docker/send-typing-indicator-js-changing-typing-indicator-duration-on-the-fly-3-snap.png create mode 100644 __tests__/hooks/useActiveTyping.js create mode 100644 __tests__/setup/conditions/typingIndicatorShown.js create mode 100644 packages/component/src/Middleware/TypingIndicator/createCoreMiddleware.js create mode 100644 packages/component/src/hooks/useActiveTyping.js create mode 100644 packages/component/src/hooks/useRenderTypingIndicator.js create mode 100644 packages/core/src/reducers/typing.js create mode 100644 samples/05.custom-components/j.typing-indicator/README.md create mode 100644 samples/05.custom-components/j.typing-indicator/index.html 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 0000000000000000000000000000000000000000..1060848fc013431a2ca645869bc705f284de964a GIT binary patch literal 16869 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 SkH5EpLOr8=I_1=*yZ;Z_Eq1>E literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4ffc32d080ea4389d7159866e30cb1d421387d34 GIT binary patch literal 15326 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1060848fc013431a2ca645869bc705f284de964a GIT binary patch literal 16869 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 SkH5EpLOr8=I_1=*yZ;Z_Eq1>E literal 0 HcmV?d00001 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) |