diff --git a/CHANGELOG.md b/CHANGELOG.md
index d60ebe0325..880fb92092 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,10 +29,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Changed
-- `*`: Bumps all dev dependencies to latest version, by [@compulim](https://github.com/compulim), in PR [#2182](https://github.com/microsoft/BotFramework-WebChat/pull/2182), notably
+- `*`: Bumps all dev dependencies to latest version, by [@compulim](https://github.com/compulim), in PR [#2182](https://github.com/microsoft/BotFramework-WebChat/pull/2182) and PR [#2308](https://github.com/compulim/BotFramework-WebChat/pull/2308)
- [`@babel/*@7.5.4`](https://www.npmjs.com/package/@babel/core)
- [`jest@24.8.0`](https://www.npmjs.com/package/jest)
- [`lerna@3.15.0`](https://www.npmjs.com/package/lerna)
+ - [`react-redux@7.1.0`](https://www.npmjs.com/package/react-redux)
- [`typescript@3.5.3`](https://www.npmjs.com/package/typescript)
- [`webpack@4.35.3`](https://www.npmjs.com/package/webpack)
- `*`: Bumps [`@babel/runtime@7.5.4`](https://www.npmjs.com/package/@babel/runtime), by [@compulim](https://github.com/compulim), in PR [#2182](https://github.com/microsoft/BotFramework-WebChat/pull/2182)
@@ -63,6 +64,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- `playground`: Remove [`react`](https://www.npmjs.com/package/react) and [`react-dom`](https://www.npmjs.com/package/react-dom) from `dependencies`
- `samples/*`: Move to production version of Web Chat, and bump to [`react@16.8.6`](https://www.npmjs.com/package/react) and [`react-dom@16.8.6`](https://www.npmjs.com/package/react-dom)
- Moved the typing indicator to the send box and removed the typing indicator logic from the sagas, by [@tdurnford](https://github.com/tdurnford), in PR [#2321](https://github.com/microsoft/BotFramework-WebChat/pull/2321)
+- `component`: Move `Composer` to React hooks and functional components, by [@compulim](https://github.com), in PR [#2308](https://github.com/compulim/BotFramework-WebChat/pull/2308)
### Fixed
diff --git a/packages/component/package-lock.json b/packages/component/package-lock.json
index 2c3d09f384..49efc03118 100644
--- a/packages/component/package-lock.json
+++ b/packages/component/package-lock.json
@@ -4398,23 +4398,27 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz",
"integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA=="
},
- "react-lifecycles-compat": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
- "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
- },
"react-redux": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.1.1.tgz",
- "integrity": "sha512-LE7Ned+cv5qe7tMV5BPYkGQ5Lpg8gzgItK07c67yHvJ8t0iaD9kPFPAli/mYkiyJYrs2pJgExR2ZgsGqlrOApg==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.1.0.tgz",
+ "integrity": "sha512-hyu/PoFK3vZgdLTg9ozbt7WF3GgX5+Yn3pZm5/96/o4UueXA+zj08aiSC9Mfj2WtD1bvpIb3C5yvskzZySzzaw==",
"requires": {
- "@babel/runtime": "^7.1.2",
- "hoist-non-react-statics": "^3.1.0",
+ "@babel/runtime": "^7.4.5",
+ "hoist-non-react-statics": "^3.3.0",
"invariant": "^2.2.4",
- "loose-envify": "^1.1.0",
- "prop-types": "^15.6.1",
- "react-is": "^16.6.0",
- "react-lifecycles-compat": "^3.0.0"
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.7.2",
+ "react-is": "^16.8.6"
+ },
+ "dependencies": {
+ "loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "requires": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ }
+ }
}
},
"react-say": {
diff --git a/packages/component/package.json b/packages/component/package.json
index dc79a2d2c5..41b4c972f3 100644
--- a/packages/component/package.json
+++ b/packages/component/package.json
@@ -55,7 +55,7 @@
"prop-types": "^15.7.2",
"react-dictate-button": "^1.1.3",
"react-film": "1.2.1-master.db29968",
- "react-redux": "^5.1.1",
+ "react-redux": "^7.1.0",
"react-say": "^1.2.0",
"react-scroll-to-bottom": "~1.3.2",
"redux": "^4.0.4",
diff --git a/packages/component/src/Composer.js b/packages/component/src/Composer.js
index a63f5081f8..216b59d7a4 100644
--- a/packages/component/src/Composer.js
+++ b/packages/component/src/Composer.js
@@ -5,11 +5,10 @@ import {
FunctionContext as ScrollToBottomFunctionContext
} from 'react-scroll-to-bottom';
-import { connect } from 'react-redux';
+import { connect, Provider } from 'react-redux';
import { css } from 'glamor';
-import memoize from 'memoize-one';
import PropTypes from 'prop-types';
-import React from 'react';
+import React, { useEffect, useMemo } from 'react';
import {
clearSuggestedActions,
@@ -43,11 +42,9 @@ import createStyleSet from './Styles/createStyleSet';
import Dictation from './Dictation';
import mapMap from './Utils/mapMap';
import observableToPromise from './Utils/observableToPromise';
-import shallowEquals from './Utils/shallowEquals';
-
-// Flywheel object
-const EMPTY_ARRAY = [];
+import WebChatReduxContext from './WebChatReduxContext';
+// List of Redux actions factory we are hoisting as Web Chat functions
const DISPATCHERS = {
clearSuggestedActions,
markActivity,
@@ -72,7 +69,7 @@ function styleSetToClassNames(styleSet) {
return mapMap(styleSet, (style, key) => (key === 'options' ? style : css(style)));
}
-function createCardActionLogic({ cardActionMiddleware, directLine, dispatch }) {
+function createCardActionContext({ cardActionMiddleware, directLine, dispatch }) {
const runMiddleware = concatMiddleware(cardActionMiddleware, createCoreCardActionMiddleware())({ dispatch });
return {
@@ -103,7 +100,7 @@ function createCardActionLogic({ cardActionMiddleware, directLine, dispatch }) {
};
}
-function createFocusSendBoxLogic({ sendBoxRef }) {
+function createFocusSendBoxContext({ sendBoxRef }) {
return {
focusSendBox: () => {
const { current } = sendBoxRef || {};
@@ -113,12 +110,6 @@ function createFocusSendBoxLogic({ sendBoxRef }) {
};
}
-function createStyleSetLogic({ styleOptions, styleSet }) {
- return {
- styleSet: styleSetToClassNames(styleSet || createStyleSet(styleOptions))
- };
-}
-
// TODO: [P3] Take this deprecation code out when releasing on or after 2019 December 11
function patchPropsForAvatarInitials({ botAvatarInitials, userAvatarInitials, ...props }) {
// This code will take out "botAvatarInitials" and "userAvatarInitials" from props
@@ -147,161 +138,164 @@ function patchPropsForAvatarInitials({ botAvatarInitials, userAvatarInitials, ..
};
}
-function createLogic(props) {
- // This is a heavy function, and it is expected to be only called when there is a need to recreate business logic, e.g.
- // - User ID changed, causing all send* functions to be updated
- // - send
-
- // TODO: [P4] We should break this into smaller pieces using memoization function, so we don't recreate styleSet if userID is changed
-
- // TODO: [P3] We should think about if we allow the user to change onSendBoxValueChanged/sendBoxValue, e.g.
- // 1. Turns text into UPPERCASE
- // 2. Filter out profanity
-
- // TODO: [P4] Revisit all members of context
- props = patchPropsForAvatarInitials(props);
-
- return {
- ...props,
- ...createCardActionLogic(props),
- ...createFocusSendBoxLogic(props),
- ...createStyleSetLogic(props)
- };
-}
-
-function dispatchSetLanguageFromProps({ dispatch, locale }) {
- dispatch(setLanguage(locale));
-}
+const Composer = ({
+ activityRenderer,
+ attachmentRenderer,
+ botAvatarInitials,
+ cardActionMiddleware,
+ children,
+ directLine,
+ disabled,
+ dispatch,
+ grammars,
+ groupTimestamp,
+ locale,
+ referenceGrammarID,
+ renderMarkdown,
+ scrollToEnd,
+ sendBoxRef,
+ sendTimeout,
+ sendTyping,
+ sendTypingIndicator,
+ styleOptions,
+ styleSet,
+ userAvatarInitials,
+ userID,
+ username,
+ webSpeechPonyfillFactory
+}) => {
+ const patchedGrammars = useMemo(() => grammars || []);
+ const patchedSendTypingIndicator = useMemo(() => {
+ if (typeof sendTyping === 'undefined') {
+ return sendTypingIndicator;
+ } else {
+ // TODO: [P3] Take this deprecation code out when releasing on or after January 13 2020
+ console.warn(
+ 'Web Chat: "sendTyping" has been renamed to "sendTypingIndicator". Please use "sendTypingIndicator" instead. This deprecation migration will be removed on or after January 13 2020.'
+ );
-function dispatchSetSendTimeoutFromProps({ dispatch, sendTimeout }) {
- dispatch(setSendTimeout(sendTimeout));
-}
+ return sendTyping;
+ }
+ }, [sendTyping, sendTypingIndicator]);
-function dispatchSetSendTypingIndicatorFromProps({ dispatch, sendTyping, sendTypingIndicator }) {
- if (typeof sendTyping === 'undefined') {
- dispatch(setSendTypingIndicator(!!sendTypingIndicator));
- } else {
- // TODO: [P3] Take this deprecation code out when releasing on or after January 13 2020
- console.warn(
- 'Web Chat: "sendTyping" has been renamed to "sendTypingIndicator". Please use "sendTypingIndicator" instead. This deprecation migration will be removed on or after January 13 2020.'
- );
- dispatch(setSendTypingIndicator(!!sendTyping));
- }
-}
+ const patchedStyleOptions = useMemo(
+ () => patchPropsForAvatarInitials({ botAvatarInitials, styleOptions, userAvatarInitials }),
+ [botAvatarInitials, styleOptions, userAvatarInitials]
+ );
-class Composer extends React.Component {
- constructor(props) {
- super(props);
+ useEffect(() => {
+ dispatch(setLanguage(locale));
+ }, [dispatch, locale]);
- this.createContextFromProps = memoize(createLogic, shallowEquals);
+ useEffect(() => {
+ dispatch(setSendTimeout(sendTimeout));
+ }, [dispatch, sendTimeout]);
- this.createWebSpeechPonyfill = memoize(
- (webSpeechPonyfillFactory, referenceGrammarID) =>
- webSpeechPonyfillFactory && webSpeechPonyfillFactory({ referenceGrammarID })
- );
+ useEffect(() => {
+ dispatch(setSendTypingIndicator(!!patchedSendTypingIndicator));
+ }, [dispatch, patchedSendTypingIndicator]);
- this.mergeContext = memoize((...contexts) => Object.assign({}, ...contexts), shallowEquals);
+ useEffect(() => {
+ dispatch(createConnectAction({ directLine, userID, username }));
- this.state = {
- hoistedDispatchers: mapMap(DISPATCHERS, dispatcher => (...args) => props.dispatch(dispatcher.apply(this, args)))
+ return () => {
+ // TODO: [P3] disconnect() is an async call (pending -> fulfilled), we need to wait, or change it to reconnect()
+ dispatch(disconnect());
};
- }
-
- UNSAFE_componentWillMount() {
- const { props } = this;
- const { directLine, userID, username } = props;
-
- dispatchSetLanguageFromProps(props);
- dispatchSetSendTimeoutFromProps(props);
- dispatchSetSendTypingIndicatorFromProps(props);
-
- props.dispatch(createConnectAction({ directLine, userID, username }));
- }
+ }, [dispatch, directLine, userID, username]);
- componentDidUpdate(prevProps) {
- const { props } = this;
- const { directLine, locale, sendTimeout, sendTyping, sendTypingIndicator, userID, username } = props;
+ const cardActionContext = useMemo(() => createCardActionContext({ cardActionMiddleware, directLine, dispatch }), [
+ cardActionMiddleware,
+ directLine,
+ dispatch
+ ]);
- if (prevProps.locale !== locale) {
- dispatchSetLanguageFromProps(props);
- }
+ const focusSendBoxContext = useMemo(() => createFocusSendBoxContext({ sendBoxRef }), [sendBoxRef]);
- if (prevProps.sendTimeout !== sendTimeout) {
- dispatchSetSendTimeoutFromProps(props);
- }
+ const patchedStyleSet = useMemo(() => styleSetToClassNames(styleSet || createStyleSet(patchedStyleOptions)), [
+ patchedStyleOptions,
+ styleSet
+ ]);
- if (
- !prevProps.sendTypingIndicator !== !sendTypingIndicator ||
- // TODO: [P3] Take this deprecation code out when releasing on or after January 13 2020
- !prevProps.sendTyping !== !sendTyping
- ) {
- dispatchSetSendTypingIndicatorFromProps(props);
- }
+ const hoistedDispatchers = useMemo(
+ () => mapMap(DISPATCHERS, dispatcher => (...args) => dispatch(dispatcher.apply(this, args))),
+ [dispatch]
+ );
- if (prevProps.directLine !== directLine || prevProps.userID !== userID || prevProps.username !== username) {
- // TODO: [P3] disconnect() is an async call (pending -> fulfilled), we need to wait, or change it to reconnect()
- props.dispatch(disconnect());
- props.dispatch(createConnectAction({ directLine, userID, username }));
- }
- }
+ const webSpeechPonyfill = useMemo(
+ () => webSpeechPonyfillFactory && webSpeechPonyfillFactory({ referenceGrammarID }),
+ [referenceGrammarID, webSpeechPonyfillFactory]
+ );
- render() {
- const {
- props: {
- activityRenderer,
- attachmentRenderer,
- children,
-
- // TODO: [P2] Add disable interactivity
- disabled,
-
- grammars,
- groupTimestamp,
- referenceGrammarID,
- renderMarkdown,
- scrollToEnd,
- store,
- userID: _userID, // Ignoring eslint no-unused-vars: we just want to remove userID and username from propsForLogic
- username: _username, // Ignoring eslint no-unused-vars: we just want to remove userID and username from propsForLogic
- webSpeechPonyfillFactory,
- ...propsForLogic
- },
- state
- } = this;
-
- const contextFromProps = this.createContextFromProps(propsForLogic);
-
- const context = this.mergeContext(
- contextFromProps,
- state.hoistedDispatchers,
-
- // TODO: [P4] Should we normalize empties here? Or should we let it thru?
- // If we let it thru, the code below become simplified and the user can plug in whatever they want for context, via Composer.props
- {
- activityRenderer,
- attachmentRenderer,
- groupTimestamp,
- disabled,
- grammars: grammars || EMPTY_ARRAY,
- renderMarkdown,
- scrollToEnd,
- store,
- webSpeechPonyfill: this.createWebSpeechPonyfill(webSpeechPonyfillFactory, referenceGrammarID)
- }
- );
+ // This is a heavy function, and it is expected to be only called when there is a need to recreate business logic, e.g.
+ // - User ID changed, causing all send* functions to be updated
+ // - send
- // TODO: [P3] Check how many times we do re-render context
+ // TODO: [P3] We should think about if we allow the user to change onSendBoxValueChanged/sendBoxValue, e.g.
+ // 1. Turns text into UPPERCASE
+ // 2. Filter out profanity
- return (
-
- {typeof children === 'function' ? children(context) : children}
-
-
- );
- }
-}
+ // TODO: [P4] Revisit all members of context
+ const context = useMemo(
+ () => ({
+ ...cardActionContext,
+ ...focusSendBoxContext,
+ ...hoistedDispatchers,
+ activityRenderer,
+ attachmentRenderer,
+ directLine,
+ disabled,
+ grammars: patchedGrammars,
+ groupTimestamp,
+ renderMarkdown,
+ scrollToEnd,
+ sendBoxRef,
+ sendTimeout,
+ sendTypingIndicator: patchedSendTypingIndicator,
+ styleOptions: patchedStyleOptions,
+ styleSet: patchedStyleSet,
+ userID,
+ username,
+ webSpeechPonyfill
+ }),
+ [
+ activityRenderer,
+ attachmentRenderer,
+ cardActionContext,
+ directLine,
+ disabled,
+ focusSendBoxContext,
+ grammars,
+ groupTimestamp,
+ hoistedDispatchers,
+ renderMarkdown,
+ scrollToEnd,
+ sendBoxRef,
+ sendTimeout,
+ sendTypingIndicator,
+ styleOptions,
+ styleSet,
+ userID,
+ username,
+ webSpeechPonyfill
+ ]
+ );
+
+ return (
+
+ {typeof children === 'function' ? children(context) : children}
+
+
+ );
+};
-const ConnectedComposer = connect(({ referenceGrammarID }) => ({ referenceGrammarID }))(props => (
+// TODO: [P1] When react-redux support useSelector with custom context, we should move to that architecture to simplify our code.
+const ConnectedComposer = connect(
+ ({ referenceGrammarID }) => ({ referenceGrammarID }),
+ null,
+ null,
+ { context: WebChatReduxContext }
+)(props => (
{({ scrollToEnd }) => }
@@ -310,19 +304,15 @@ const ConnectedComposer = connect(({ referenceGrammarID }) => ({ referenceGramma
));
// We will create a Redux store if it was not passed in
-class ConnectedComposerWithStore extends React.Component {
- constructor(props) {
- super(props);
-
- this.createMemoizedStore = memoize(() => createStore());
- }
-
- render() {
- const { props } = this;
-
- return ;
- }
-}
+const ConnectedComposerWithStore = ({ store, ...props }) => {
+ const memoizedStore = useMemo(() => store || createStore(), [store]);
+
+ return (
+
+
+
+ );
+};
ConnectedComposerWithStore.defaultProps = {
store: undefined
@@ -334,7 +324,7 @@ ConnectedComposerWithStore.propTypes = {
export default ConnectedComposerWithStore;
-// TODO: [P3] We should consider moving some props to Redux store
+// TODO: [P3] We should consider moving some data from Redux store to props
// Although we use `connectToWebChat` to hide the details of accessor of Redux store,
// we should clean up the responsibility between Context and Redux store
// We should decide which data is needed for React but not in other environment such as CLI/VSCode
diff --git a/packages/component/src/WebChatReduxContext.js b/packages/component/src/WebChatReduxContext.js
new file mode 100644
index 0000000000..860f106868
--- /dev/null
+++ b/packages/component/src/WebChatReduxContext.js
@@ -0,0 +1,3 @@
+import { createContext } from 'react';
+
+export default createContext();
diff --git a/packages/component/src/connectToWebChat.js b/packages/component/src/connectToWebChat.js
index 0b26923b7f..ec204ad89e 100644
--- a/packages/component/src/connectToWebChat.js
+++ b/packages/component/src/connectToWebChat.js
@@ -2,6 +2,7 @@ import { connect } from 'react-redux';
import React from 'react';
import Context from './Context';
+import WebChatReduxContext from './WebChatReduxContext';
function removeUndefinedValues(map) {
return Object.keys(map).reduce((result, key) => {
@@ -29,15 +30,20 @@ function combineSelectors(...selectors) {
export default function connectToWebChat(...selectors) {
const combinedSelector = combineSelectors(...selectors);
+ // TODO: [P1] Instead of exposing Redux store via props, we should consider exposing via Context.
+ // We should also hide dispatch function.
return Component => {
- const ConnectedComponent = connect((state, { context, _, ...ownProps }) =>
- combinedSelector({ ...state, ...context }, ownProps)
+ const ConnectedComponent = connect(
+ (state, { context, ...ownProps }) => combinedSelector({ ...state, ...context }, ownProps),
+ null,
+ null,
+ {
+ context: WebChatReduxContext
+ }
)(Component);
const WebChatConnectedComponent = props => (
-
- {context => }
-
+ {context => }
);
return WebChatConnectedComponent;