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;