├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .github ├── pull_request_template.md ├── stale.yml └── workflows │ └── test.yaml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── FEA_open_source_sm.png ├── arrow.js ├── clear-button-grey.svg ├── clear-button.svg ├── fullscreen_button.svg ├── fullscreen_exit_button.svg ├── launcher_button.svg ├── logo.png ├── logo_1024_white_bg256.jpg ├── send_button.js └── unread_count_pastille.png ├── circle.yml ├── cloudbuild.yaml ├── commitlint.config.js ├── dev └── src │ └── index.html ├── index.js ├── index_for_react_app.js ├── mocks ├── fileMock.js ├── localStorageMock.js └── styleMock.js ├── npmrc.enc ├── package-lock.json ├── package.json ├── rasa_webchat.gif ├── src ├── components │ └── Widget │ │ ├── ThemeContext.js │ │ ├── components │ │ ├── Conversation │ │ │ ├── components │ │ │ │ ├── Header │ │ │ │ │ ├── index.js │ │ │ │ │ ├── style.scss │ │ │ │ │ └── test │ │ │ │ │ │ └── index.test.js │ │ │ │ ├── Messages │ │ │ │ │ ├── components │ │ │ │ │ │ ├── Buttons │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── styles.scss │ │ │ │ │ │ │ └── test │ │ │ │ │ │ │ │ └── index.test.js │ │ │ │ │ │ ├── Carousel │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── index.test.js │ │ │ │ │ │ │ └── styles.scss │ │ │ │ │ │ ├── ImgReply │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── styles.scss │ │ │ │ │ │ ├── Message │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── styles.scss │ │ │ │ │ │ ├── Snippet │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── styles.scss │ │ │ │ │ │ ├── VidReply │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ ├── styles.scoped.scss │ │ │ │ │ │ │ └── styles.scss │ │ │ │ │ │ ├── docViewer │ │ │ │ │ │ │ ├── index.js │ │ │ │ │ │ │ └── style.scss │ │ │ │ │ │ └── index.js │ │ │ │ │ ├── index.js │ │ │ │ │ ├── styles.scss │ │ │ │ │ └── test │ │ │ │ │ │ └── index.test.js │ │ │ │ └── Sender │ │ │ │ │ ├── index.js │ │ │ │ │ └── style.scss │ │ │ ├── index.js │ │ │ └── style.scss │ │ └── Launcher │ │ │ ├── components │ │ │ └── Badge │ │ │ │ ├── index.js │ │ │ │ └── style.scss │ │ │ ├── index.js │ │ │ ├── style.scss │ │ │ └── test │ │ │ └── index.test.js │ │ ├── index.js │ │ ├── layout.js │ │ ├── msgProcessor.js │ │ ├── style.scss │ │ └── test │ │ ├── index.test.js │ │ ├── metadataBehavior.test.js │ │ ├── metadataCustomCss.test.js │ │ ├── metadataInput.test.js │ │ ├── metadataLinkTarget.test.js │ │ ├── metadataMessagetarget.test.js │ │ └── metadataStore.test.js ├── constants.js ├── index.js ├── pro-src │ ├── index.css │ ├── question-solid.svg │ ├── rules-wrapper.js │ ├── rules.js │ └── utils.js ├── scss │ ├── animation.scss │ ├── common.scss │ └── variables.scss ├── socket-socketio.js ├── socket-sockjs.js ├── socket.js ├── store │ ├── actions │ │ ├── actionTypes.js │ │ ├── dispatcher.js │ │ ├── dispatcher.test.js │ │ └── index.js │ ├── reducers │ │ ├── behaviorReducer.js │ │ ├── helper.js │ │ ├── messagesReducer.js │ │ └── metadataReducer.js │ └── store.js └── utils │ ├── dom.js │ ├── messages.js │ └── portal.js ├── test-setup.js ├── umd.js ├── webpack.dev.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "transform-class-properties", 8 | "@babel/plugin-proposal-object-rest-spread", 9 | ["module-resolver", { 10 | "root": ["./src"], 11 | "alias": { 12 | "constants": "./src/constants.js", 13 | "assets": "./assets", 14 | "tests-mocks": "./mocks", 15 | "actions": "./src/store/actions", 16 | "helper": "./src/store/reducers/helper.js", 17 | "messagesComponents": "./src/components/Widget/components/Conversation/components/Messages/components" 18 | } 19 | }] 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "browser": true, 6 | "jest": true 7 | }, 8 | "parser": "babel-eslint", 9 | "parserOptions": { 10 | "ecmaFeatures": { 11 | "experimentalObjectRestSpread": true, 12 | "jsx": true 13 | }, 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "react", 18 | "flowtype" 19 | ], 20 | "extends": [ 21 | "eslint:recommended", 22 | "airbnb", 23 | "plugin:react/recommended", 24 | ], 25 | "globals": { 26 | "__DEV__": true, 27 | }, 28 | "rules": { 29 | "comma-dangle": ["error", "never"], 30 | "no-invalid-this": "off", 31 | "no-return-assign": "off", 32 | "no-param-reassign": "off", 33 | "no-nested-ternary": "off", 34 | "no-confusing-arrow": "off", 35 | "react/require-default-props": "off", 36 | "react/jsx-filename-extension": ["error", { "extensions": [".js"] }], 37 | "react/prop-types": [2, { ignore: ["style", "children", "dispatch"] } ], 38 | "react/prefer-stateless-function": "off", 39 | "react/no-array-index-key": "off", 40 | "import/prefer-default-export": "off", 41 | "import/no-unresolved": "error", 42 | "import/extensions": ["error", { js: "never" }], 43 | "import/named": "error", 44 | "import/default": "error", 45 | "import/namespace": "error", 46 | "import/no-absolute-path": "error" 47 | }, 48 | "settings": { 49 | "import/resolver": { 50 | "babel-module": {} 51 | } 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Proposed changes**: 2 | - ... 3 | 4 | **Status (please check what you already did)**: 5 | - [ ] made PR ready for code review 6 | - [ ] added some tests for the functionality 7 | - [ ] updated the documentation 8 | - [ ] updated the changelog 9 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 10 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - enhancement 8 | - wip 9 | - bug 10 | # Label to use when marking an issue as stale 11 | staleLabel: wontfix 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: false 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: push 4 | 5 | jobs: 6 | unit-tests: 7 | runs-on: ubuntu-latest 8 | # if: contains(github.event.head_commit.modified, 'api/') 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: '10.x' 14 | - run: npm ci 15 | - run: npm run test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Package manager files 2 | node_modules 3 | npm-debug.log 4 | yarn-error.log 5 | 6 | # OSX 7 | .DS_Store 8 | 9 | # Build files 10 | lib 11 | module 12 | 13 | # Test files 14 | coverage 15 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | index_for_react_app.js 3 | yarn.lock 4 | assets 5 | mocks 6 | coverage 7 | circle.yml 8 | .babelrc 9 | .eslintrc.js 10 | .eslintignore 11 | webpack.config.js 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": true, 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "trailingComma": "es5" 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.autoFixOnSave": true, 3 | "prettier.singleQuote": true, 4 | "prettier.jsxBracketSameLine": true, 5 | "prettier.jsxSingleQuote": true, 6 | "eslint.enable": true, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": true 9 | } 10 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First off, thank you for considering contributing to this project! Now, to start contributing: 4 | 5 | ## Issues and suggestions 6 | 7 | If you either find a bug or have any suggestion or opinion you want to discuss and share, please open an issue and add the proper label to it so we can get in contact with you. 8 | There are no wrong opinions! All feedback is welcome to make this the most suitable tool for you to use and for us to grow. 9 | 10 | ## How to contribute 11 | 12 | If you have a new feature you want to add or a bug you think you can fix, follow this steps: 13 | 14 | 1. Fork the repo 15 | 2. Create your feature branch (`git checkout -b my-new-feature`) 16 | 3. Commit your changes (`git commit -am 'Add some feature'`) 17 | 4. Push to the branch (`git push origin my-new-feature`) 18 | 5. Create new Pull Request with **clear title and description** 19 | 20 | ## Installation 21 | 22 | To get this project up and running, you need to build it with 23 | 24 | ```bash 25 | npm run build 26 | ``` 27 | 28 | and then, add it to a dummy project using the file instead of the package. You can use either yarn or npm: 29 | 30 | ```bash 31 | npm i /rasa-webchat 32 | 33 | yarn add file:/rasa-webchat 34 | ``` 35 | 36 | ## Testing 37 | 38 | Your new feature **must** be tested with the proper tools. In this project, we use Jest and Enzyme. Once your tests are written, run: 39 | 40 | ```bash 41 | npm run test 42 | ``` 43 | 44 | If there any view changes, you need to add screenshots for what's been changed for us to see any improvement or your new feature. 45 | -------------------------------------------------------------------------------- /assets/FEA_open_source_sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botfront/rasa-webchat/d76b096aa6736dfbebe129a366f1d6b560c97b29/assets/FEA_open_source_sm.png -------------------------------------------------------------------------------- /assets/arrow.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import PropTypes from 'prop-types'; 4 | import React, { useContext } from 'react'; 5 | import ThemeContext from '../src/components/Widget/ThemeContext'; 6 | 7 | function Arrow() { 8 | const { assistBackgoundColor } = useContext(ThemeContext); 9 | 10 | return ( 11 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | 33 | Arrow.propTypes = { 34 | ready: PropTypes.bool 35 | }; 36 | 37 | export default Arrow; 38 | -------------------------------------------------------------------------------- /assets/clear-button-grey.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /assets/clear-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /assets/fullscreen_button.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/fullscreen_exit_button.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/launcher_button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botfront/rasa-webchat/d76b096aa6736dfbebe129a366f1d6b560c97b29/assets/logo.png -------------------------------------------------------------------------------- /assets/logo_1024_white_bg256.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botfront/rasa-webchat/d76b096aa6736dfbebe129a366f1d6b560c97b29/assets/logo_1024_white_bg256.jpg -------------------------------------------------------------------------------- /assets/send_button.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { useContext } from 'react'; 3 | import ThemeContext from '../src/components/Widget/ThemeContext'; 4 | 5 | function Send({ ready }) { 6 | const { mainColor } = useContext(ThemeContext); 7 | 8 | return ( 9 | 18 | 23 | 24 | ); 25 | } 26 | 27 | 28 | Send.propTypes = { 29 | ready: PropTypes.bool 30 | }; 31 | 32 | export default Send; 33 | -------------------------------------------------------------------------------- /assets/unread_count_pastille.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botfront/rasa-webchat/d76b096aa6736dfbebe129a366f1d6b560c97b29/assets/unread_count_pastille.png -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 8.4.0 4 | 5 | dependencies: 6 | override: 7 | - yarn 8 | test: 9 | override: 10 | - yarn run test 11 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/npm' 3 | args: ['ci'] 4 | 5 | - name: 'gcr.io/cloud-builders/npm' 6 | args: ['run','build'] 7 | 8 | - name: 'gcr.io/cloud-builders/gcloud' 9 | args: 10 | - kms 11 | - decrypt 12 | - --ciphertext-file=npmrc.enc 13 | - --plaintext-file=/root/.npmrc 14 | - --location=global 15 | - --keyring=my-keyring 16 | - --key=npm-key 17 | volumes: 18 | - name: 'home' 19 | path: /root/ 20 | 21 | - name: 'gcr.io/cloud-builders/npm' 22 | args: 23 | - publish 24 | env: 25 | - HOME=/root/ 26 | volumes: 27 | - name: 'home' 28 | path: /root/ 29 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /dev/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Botfront - Web Chat 5 | 16 | 17 | 18 | 19 | 20 | Send chitchat.greet without text 21 |
22 | Send chitchat.greet with text 23 | test 24 | test 25 | test 26 |

test

27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import PropTypes from 'prop-types'; 4 | import RasaWebchatPro from './src/pro-src/rules-wrapper'; 5 | 6 | import './src/pro-src/index.css'; 7 | 8 | class RasaWebchatProWithRules extends React.Component { 9 | constructor(props) { 10 | super(props); 11 | const { connectOn } = props; 12 | let { withRules } = props; 13 | if (connectOn === 'open' && withRules === true) { 14 | throw new Error( 15 | "You can't use rules and connect on open, you have to use connect on mount" 16 | ); 17 | } 18 | this.webchatRef = null; 19 | if (withRules === undefined) { 20 | withRules = true; 21 | } 22 | this.state = { 23 | propsRetrieved: !withRules, 24 | rulesApplied: !withRules 25 | }; 26 | this.setRef = this.setRef.bind(this); 27 | this.handleSessionConfirm = this.handleSessionConfirm.bind(this); 28 | } 29 | 30 | setRef(element) { 31 | const { innerRef } = this.props; 32 | if (!innerRef) { 33 | this.webchatRef = element; 34 | } else if (innerRef && innerRef.constructor && innerRef.call && innerRef.apply) { 35 | // if this is true, innerRef is a function and thus it's a callback ref 36 | this.webchatRef = element; 37 | innerRef(element); 38 | } else { 39 | innerRef.current = element; 40 | } 41 | } 42 | 43 | handleSessionConfirm(sessionObject) { 44 | const { innerRef } = this.props; 45 | this.setState({ 46 | // The OR makes it work even without the augmented webchat channel 47 | propsRetrieved: { ...sessionObject.props } 48 | }); 49 | if ( 50 | ((innerRef && innerRef.current) || this.webchatRef.updateRules) && 51 | sessionObject.props && 52 | sessionObject.props.rules 53 | ) { 54 | setTimeout(() => { 55 | if (innerRef && innerRef.current) { 56 | innerRef.current.updateRules(sessionObject.props.rules); 57 | } else { 58 | this.webchatRef.updateRules(sessionObject.props.rules); 59 | } 60 | }, 100); 61 | this.setState({ rulesApplied: true }); 62 | } 63 | } 64 | 65 | render() { 66 | const { onSocketEvent } = this.props; 67 | let { withRules } = this.props; 68 | if (withRules === undefined) { 69 | withRules = true; 70 | } 71 | const { propsRetrieved } = this.state; 72 | let propsToApply = {}; 73 | if (propsRetrieved) propsToApply = propsRetrieved; 74 | delete propsToApply.rules; 75 | return ( 76 |
80 | 95 |
96 | ); 97 | } 98 | } 99 | 100 | export const rasaWebchatProTypes = { 101 | initPayload: PropTypes.string, 102 | title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 103 | subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 104 | protocol: PropTypes.string, 105 | socketUrl: PropTypes.string.isRequired, 106 | socketPath: PropTypes.string, 107 | protocolOptions: PropTypes.shape({}), 108 | customData: PropTypes.shape({}), 109 | handleNewUserMessage: PropTypes.func, 110 | profileAvatar: PropTypes.string, 111 | inputTextFieldHint: PropTypes.string, 112 | connectingText: PropTypes.string, 113 | showCloseButton: PropTypes.bool, 114 | showFullScreenButton: PropTypes.bool, 115 | hideWhenNotConnected: PropTypes.bool, 116 | connectOn: PropTypes.oneOf(['mount', 'open']), 117 | autoClearCache: PropTypes.bool, 118 | onSocketEvent: PropTypes.objectOf(PropTypes.func), 119 | fullScreenMode: PropTypes.bool, 120 | badge: PropTypes.number, 121 | embedded: PropTypes.bool, 122 | // eslint-disable-next-line react/forbid-prop-types 123 | params: PropTypes.object, 124 | openLauncherImage: PropTypes.string, 125 | closeImage: PropTypes.string, 126 | docViewer: PropTypes.bool, 127 | customComponent: PropTypes.func, 128 | displayUnreadCount: PropTypes.bool, 129 | showMessageDate: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), 130 | customMessageDelay: PropTypes.func, 131 | tooltipPayload: PropTypes.string, 132 | tooltipDelay: PropTypes.number, 133 | withRules: PropTypes.bool, 134 | rules: PropTypes.arrayOf( 135 | PropTypes.shape({ 136 | payload: PropTypes.string.isRequired, 137 | text: PropTypes.string, 138 | trigger: PropTypes.shape({ 139 | url: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), 140 | timeOnPage: PropTypes.number, 141 | numberOfVisits: PropTypes.number, 142 | numberOfPageVisits: PropTypes.number, 143 | device: PropTypes.string, 144 | when: PropTypes.oneOf(['always', 'init']), 145 | queryString: PropTypes.arrayOf( 146 | PropTypes.shape({ 147 | param: PropTypes.string, 148 | value: PropTypes.string, 149 | sendAsEntity: PropTypes.bool 150 | }) 151 | ), 152 | eventListeners: PropTypes.arrayOf( 153 | PropTypes.shape({ 154 | selector: PropTypes.string.isRequired, 155 | event: PropTypes.string.isRequired 156 | }) 157 | ) 158 | }) 159 | }) 160 | ), 161 | triggerEventListenerUpdateRate: PropTypes.number 162 | }; 163 | 164 | RasaWebchatProWithRules.propTypes = { 165 | ...rasaWebchatProTypes, 166 | innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.object })]) 167 | }; 168 | 169 | export const rasaWebchatProDefaultTypes = { 170 | title: 'Welcome', 171 | customData: {}, 172 | inputTextFieldHint: 'Type a message...', 173 | connectingText: 'Waiting for server...', 174 | fullScreenMode: false, 175 | hideWhenNotConnected: true, 176 | autoClearCache: false, 177 | connectOn: 'mount', 178 | onSocketEvent: {}, 179 | protocol: 'socketio', 180 | socketUrl: 'http://localhost', 181 | protocolOptions: {}, 182 | badge: 0, 183 | embedded: false, 184 | params: { 185 | storage: 'local' 186 | }, 187 | docViewer: false, 188 | showCloseButton: true, 189 | showFullScreenButton: false, 190 | displayUnreadCount: false, 191 | showMessageDate: false, 192 | customMessageDelay: (message) => { 193 | let delay = message.length * 30; 194 | if (delay > 3 * 1000) delay = 3 * 1000; 195 | if (delay < 800) delay = 800; 196 | return delay; 197 | }, 198 | tooltipPayload: null, 199 | tooltipDelay: 500, 200 | withRules: true, 201 | rules: null, 202 | triggerEventListenerUpdateRate: 500 203 | }; 204 | 205 | export default React.forwardRef((props, ref) => ( 206 | 207 | )); 208 | 209 | export const selfMount = (props, element = null) => { 210 | const load = () => { 211 | if (element === null) { 212 | const node = document.createElement('div'); 213 | node.setAttribute('id', 'rasaWebchatPro'); 214 | document.body.appendChild(node); 215 | } 216 | const mountElement = element || document.getElementById('rasaWebchatPro') 217 | const webchatPro = React.createElement(RasaWebchatProWithRules, props); 218 | ReactDOM.render(webchatPro, mountElement); 219 | }; 220 | if (document.readyState === 'complete') { 221 | load(); 222 | } else { 223 | window.addEventListener('load', () => { 224 | load(); 225 | }); 226 | } 227 | }; 228 | -------------------------------------------------------------------------------- /index_for_react_app.js: -------------------------------------------------------------------------------- 1 | import ConnectedWidget from './src'; 2 | import { 3 | addUserMessage, 4 | addResponseMessage, 5 | addCarousel, 6 | addVideoSnippet, 7 | addImageSnippet, 8 | addButtons, 9 | renderCustomComponent, 10 | isOpen, 11 | isVisible, 12 | openChat, 13 | closeChat, 14 | toggleChat, 15 | showChat, 16 | hideChat, 17 | toggleFullScreen, 18 | toggleInputDisabled, 19 | dropMessages, 20 | send 21 | } from './src/store/actions/dispatcher'; 22 | 23 | export { 24 | ConnectedWidget as Widget, 25 | addUserMessage, 26 | addResponseMessage, 27 | addCarousel, 28 | addVideoSnippet, 29 | addImageSnippet, 30 | addButtons, 31 | renderCustomComponent, 32 | isOpen, 33 | isVisible, 34 | openChat, 35 | closeChat, 36 | toggleChat, 37 | showChat, 38 | hideChat, 39 | toggleFullScreen, 40 | toggleInputDisabled, 41 | dropMessages, 42 | send 43 | }; 44 | -------------------------------------------------------------------------------- /mocks/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /mocks/localStorageMock.js: -------------------------------------------------------------------------------- 1 | class LocalStorageMock { 2 | constructor() { 3 | this.store = {}; 4 | } 5 | 6 | clear() { 7 | this.store = {}; 8 | } 9 | 10 | getItem(key) { 11 | return this.store[key] || null; 12 | } 13 | 14 | setItem(key, value) { 15 | this.store[key] = value.toString(); 16 | } 17 | 18 | removeItem(key) { 19 | delete this.store[key]; 20 | } 21 | } 22 | 23 | export default LocalStorageMock 24 | ; 25 | -------------------------------------------------------------------------------- /mocks/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /npmrc.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botfront/rasa-webchat/d76b096aa6736dfbebe129a366f1d6b560c97b29/npmrc.enc -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rasa-webchat", 3 | "version": "1.0.2", 4 | "description": "Chat web widget for React apps and Rasa Core chatbots", 5 | "module": "module/index.js", 6 | "main": "lib/index.js", 7 | "repository": "git@https://github.com/botfront/rasa-webchat.git", 8 | "author": "", 9 | "license": "Apache-2.0", 10 | "scripts": { 11 | "dev": "webpack-dev-server --config webpack.dev.js", 12 | "build": "webpack --config webpack.prod.js -p", 13 | "test": "jest", 14 | "prepare": "npm run build", 15 | "release": "standard-version" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "chat", 20 | "widget", 21 | "javascript" 22 | ], 23 | "dependencies": { 24 | "@popperjs/core": "^2.4.0", 25 | "@stomp/stompjs": "^5.4.2", 26 | "html-webpack-plugin": "^3.2.0", 27 | "immutable": "^3.8.2", 28 | "object-hash": "^1.1.5", 29 | "prop-types": "^15.7.2", 30 | "react-immutable-proptypes": "^2.2.0", 31 | "react-markdown": "^4.2.2", 32 | "react-popper": "^2.2.3", 33 | "react-redux": "^7.1.3", 34 | "react-slick": "^0.26.1", 35 | "react-textarea-autosize": "^7.1.2", 36 | "redux": "^4.0.5", 37 | "slick-carousel": "^1.8.1", 38 | "socket.io": "^3.1.2", 39 | "socket.io-client": "^3.1.2", 40 | "sockjs-client": "^1.4.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "^7.8.4", 44 | "@babel/core": "^7.9.0", 45 | "@babel/plugin-proposal-object-rest-spread": "^7.9.0", 46 | "@babel/preset-env": "^7.9.0", 47 | "@babel/preset-react": "^7.9.1", 48 | "@commitlint/cli": "^8.2.0", 49 | "@commitlint/config-conventional": "^8.2.0", 50 | "babel-eslint": "^10.1.0", 51 | "babel-jest": "^25.5.1", 52 | "babel-loader": "^8.1.0", 53 | "babel-plugin-module-resolver": "^4.0.0", 54 | "babel-plugin-transform-class-properties": "^6.24.1", 55 | "babel-preset-stage-0": "^6.24.1", 56 | "clean-webpack-plugin": "^0.1.19", 57 | "css-loader": "^0.28.11", 58 | "enzyme": "^3.11.0", 59 | "enzyme-adapter-react-16": "^1.15.2", 60 | "eslint": "^4.18.2", 61 | "eslint-config-airbnb": "^14.1.0", 62 | "eslint-config-prettier": "^1.6.0", 63 | "eslint-import-resolver-babel-module": "^5.1.2", 64 | "eslint-plugin-flowtype": "^2.50.3", 65 | "eslint-plugin-import": "^2.18.2", 66 | "eslint-plugin-jsx-a11y": "^4.0.0", 67 | "eslint-plugin-prettier": "^2.7.0", 68 | "eslint-plugin-react": "^6.10.3", 69 | "husky": "^3.0.7", 70 | "jest": "^25.5.4", 71 | "lodash-webpack-plugin": "^0.11.5", 72 | "node-sass": "^7.0.3", 73 | "prettier": "^1.18.2", 74 | "prettier-eslint": "^5.1.0", 75 | "react": "^16.12.0", 76 | "react-dom": "^16.12.0", 77 | "redux-mock-store": "^1.5.4", 78 | "sass-loader": "^6.0.7", 79 | "standard-version": "^9.0.0", 80 | "string-replace-loader": "^2.3.0", 81 | "style-loader": "^0.18.2", 82 | "url-loader": "^0.5.9", 83 | "webpack": "^4.39.3", 84 | "webpack-cli": "^3.3.7", 85 | "webpack-dev-server": "^3.8.0" 86 | }, 87 | "peerDependencies": { 88 | "react": "^16.8.3", 89 | "react-dom": "^16.8.3" 90 | }, 91 | "jest": { 92 | "verbose": true, 93 | "moduleNameMapper": { 94 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/mocks/fileMock.js", 95 | "\\.(css|scss)$": "/mocks/styleMock.js" 96 | }, 97 | "setupTestFrameworkScriptFile": "/test-setup.js" 98 | }, 99 | "husky": { 100 | "hooks": { 101 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /rasa_webchat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/botfront/rasa-webchat/d76b096aa6736dfbebe129a366f1d6b560c97b29/rasa_webchat.gif -------------------------------------------------------------------------------- /src/components/Widget/ThemeContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ThemeContext = React.createContext({ 4 | mainColor: '', 5 | conversationBackgroundColor: '', 6 | userTextColor: '', 7 | userBackgroundColor: '', 8 | assistTextColor: '', 9 | assistBackgoundColor: '' 10 | }); 11 | 12 | export default ThemeContext; 13 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Header/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import close from 'assets/clear-button.svg'; 5 | import fullscreen from 'assets/fullscreen_button.svg'; 6 | import fullscreenExit from 'assets/fullscreen_exit_button.svg'; 7 | import './style.scss'; 8 | import ThemeContext from '../../../../ThemeContext'; 9 | 10 | const Header = ({ 11 | title, 12 | subtitle, 13 | fullScreenMode, 14 | toggleFullScreen, 15 | toggleChat, 16 | showCloseButton, 17 | showFullScreenButton, 18 | connected, 19 | connectingText, 20 | closeImage, 21 | profileAvatar 22 | }) => { 23 | const { mainColor } = useContext(ThemeContext); 24 | return ( 25 |
26 |
27 | { 28 | profileAvatar && ( 29 | chat avatar 30 | ) 31 | } 32 |
33 | { 34 | showFullScreenButton && 35 | 42 | } 43 | { 44 | showCloseButton && 45 | 52 | } 53 |
54 |

{title}

55 | {subtitle && {subtitle}} 56 |
57 | { 58 | !connected && 59 | 60 | {connectingText} 61 | 62 | } 63 |
); 64 | }; 65 | 66 | Header.propTypes = { 67 | title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 68 | subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), 69 | fullScreenMode: PropTypes.bool, 70 | toggleFullScreen: PropTypes.func, 71 | toggleChat: PropTypes.func, 72 | showCloseButton: PropTypes.bool, 73 | showFullScreenButton: PropTypes.bool, 74 | connected: PropTypes.bool, 75 | connectingText: PropTypes.string, 76 | closeImage: PropTypes.string, 77 | profileAvatar: PropTypes.string 78 | }; 79 | 80 | export default Header; 81 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Header/style.scss: -------------------------------------------------------------------------------- 1 | @import "variables.scss"; 2 | 3 | @import "common.scss"; 4 | 5 | .#{$namespace}conversation-container { 6 | .#{$namespace}header { 7 | background-color: $tertiary; 8 | color: $white; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | text-align: center; 13 | height: 55px; 14 | font-family: $fontfamily; 15 | position: relative; 16 | .#{$namespace}avatar { 17 | all: initial; 18 | height: 31px; 19 | width: 31px; 20 | position: absolute; 21 | top: 12px; 22 | left: 14px; 23 | } 24 | } 25 | 26 | .#{$namespace}header.#{$namespace}with-subtitle { 27 | height: 70px; 28 | .#{$namespace}avatar { 29 | top: 20px; 30 | } 31 | .#{$namespace}title { 32 | top:11px; 33 | } 34 | span { 35 | bottom: 12px; 36 | position: absolute; 37 | left: 22px; 38 | &.#{$namespace}with-avatar { 39 | left: 60px; 40 | } 41 | } 42 | } 43 | 44 | .#{$namespace}title { 45 | font-size: 20px; 46 | margin: 0; 47 | font-family: $fontfamily; 48 | position: absolute; 49 | left: 20px; 50 | font-weight: 700; 51 | &.#{$namespace}with-avatar { 52 | left: 58px; 53 | } 54 | } 55 | 56 | .#{$namespace}header-buttons { 57 | @include header-buttons-fs; 58 | } 59 | 60 | .#{$namespace}close-button { 61 | display: none; 62 | } 63 | 64 | .#{$namespace}toggle-fullscreen-button { 65 | @include header-button-fs; 66 | } 67 | 68 | .#{$namespace}toggle-fullscreen { 69 | @include header-icon-fs; 70 | } 71 | 72 | .#{$namespace}loading { 73 | background-color: $grey-3; 74 | color: $white; 75 | display: flex; 76 | flex-direction: column; 77 | text-align: center; 78 | padding: 5px 0 5px; 79 | font-family: $fontfamily; 80 | font-size: 14px; 81 | } 82 | } 83 | 84 | &.#{$namespace}widget-embedded { 85 | .#{$namespace}header { 86 | display: none; 87 | } 88 | } 89 | 90 | .#{$namespace}full-screen { 91 | 92 | .#{$namespace}header { 93 | @include header-fs; 94 | } 95 | 96 | .#{$namespace}title { 97 | @include title-fs; 98 | } 99 | 100 | .#{$namespace}close-button, 101 | .#{$namespace}toggle-fullscreen-button { 102 | @include header-button-fs; 103 | } 104 | 105 | .#{$namespace}close, 106 | .#{$namespace}toggle-fullscreen { 107 | @include header-icon-fs; 108 | } 109 | 110 | .#{$namespace}loading { 111 | @include loading-fs; 112 | } 113 | &.#{$namespace}widget-container{ 114 | .#{$namespace}conversation-container{ 115 | margin-bottom: 0px; 116 | }} 117 | 118 | } 119 | 120 | @media screen and (max-width: 800px) { 121 | .#{$namespace}conversation-container { 122 | .#{$namespace}header { 123 | @include header-fs; 124 | } 125 | 126 | .#{$namespace}title { 127 | @include title-fs; 128 | } 129 | 130 | .#{$namespace}close-button { 131 | @include header-button-fs; 132 | } 133 | 134 | .#{$namespace}close { 135 | @include header-icon-fs; 136 | } 137 | 138 | .#{$namespace}toggle-fullscreen-button, .#{$namespace}w-.toggle-fullscreen { 139 | display: none; 140 | } 141 | 142 | .#{$namespace}loading { 143 | @include loading-fs; 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Header/test/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import Header from '../index'; 5 | 6 | describe('
', () => { 7 | const createHeader = ({ toggle, fullScreenMode, showFullScreenButton }) => 8 | shallow(
); 13 | 14 | it('should call toggle prop when clicked', () => { 15 | const toggle = jest.fn(); 16 | const fullScreenMode = false; 17 | const showFullScreenButton = true; 18 | const headerComponent = createHeader({ toggle, fullScreenMode, showFullScreenButton }); 19 | headerComponent.find('.rw-toggle-fullscreen-button').simulate('click'); 20 | expect(toggle).toBeCalled(); 21 | }); 22 | 23 | it('should render the fullscreen image when fullScreenMode = false', () => { 24 | const toggle = jest.fn(); 25 | const fullScreenMode = false; 26 | const showFullScreenButton = true; 27 | const headerComponent = createHeader({ toggle, fullScreenMode, showFullScreenButton }); 28 | expect(headerComponent.find('.rw-fullScreenImage')).toHaveLength(1); 29 | }); 30 | 31 | it('should render the fullscreen exit image when fullScreenMode = true', () => { 32 | const toggle = jest.fn(); 33 | const fullScreenMode = true; 34 | const showFullScreenButton = true; 35 | const headerComponent = createHeader({ toggle, fullScreenMode, showFullScreenButton }); 36 | expect(headerComponent.find('.rw-fullScreenExitImage')).toHaveLength(1); 37 | }); 38 | 39 | it('should not render the fullscreen toggle button when showFullScreenButton = false', () => { 40 | const toggle = jest.fn(); 41 | const fullScreen = true; 42 | const showFullScreenButton = false; 43 | const headerComponent = createHeader({ toggle, fullScreen, showFullScreenButton }); 44 | expect(headerComponent.find('.rw-toggle-fullscreen-button')).toHaveLength(0); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Buttons/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { PROP_TYPES } from 'constants'; 5 | import { addUserMessage, emitUserMessage, setButtons, toggleInputDisabled } from 'actions'; 6 | import Message from '../Message/index'; 7 | 8 | import './styles.scss'; 9 | import ThemeContext from '../../../../../../ThemeContext'; 10 | 11 | 12 | class Buttons extends PureComponent { 13 | constructor(props) { 14 | super(props); 15 | this.handleClick = this.handleClick.bind(this); 16 | 17 | const { 18 | message, 19 | getChosenReply, 20 | inputState, 21 | id 22 | } = this.props; 23 | 24 | const hint = message.get('hint'); 25 | const chosenReply = getChosenReply(id); 26 | if (!chosenReply && !inputState) { 27 | // this.props.toggleInputDisabled(); 28 | } 29 | } 30 | 31 | handleClick(reply) { 32 | const { 33 | chooseReply, 34 | id 35 | } = this.props; 36 | 37 | const payload = reply.get('payload'); 38 | const title = reply.get('title'); 39 | chooseReply(payload, title, id); 40 | } 41 | 42 | renderButtons(message, buttons, persit) { 43 | const { isLast, linkTarget, separateButtons 44 | } = this.props; 45 | const { userTextColor, userBackgroundColor } = this.context; 46 | const buttonStyle = { 47 | color: userTextColor, 48 | backgroundColor: userBackgroundColor, 49 | borderColor: userBackgroundColor 50 | }; 51 | return ( 52 |
53 | 54 | {separateButtons && (
) } 55 | {(isLast || persit) && ( 56 |
57 | {buttons.map((reply, index) => { 58 | if (reply.get('type') === 'web_url') { 59 | return ( 60 | e.stopPropagation()} 68 | > 69 | {reply.get('title')} 70 | 71 | ); 72 | } 73 | return ( 74 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions 75 |
{ e.stopPropagation(); this.handleClick(reply); }} 79 | style={buttonStyle} 80 | onMouseUp={e => e.stopPropagation()} 81 | > 82 | {reply.get('title')} 83 |
84 | ); 85 | })} 86 |
87 | )} 88 |
89 | ); 90 | } 91 | 92 | 93 | render() { 94 | const { 95 | message, 96 | getChosenReply, 97 | id 98 | } = this.props; 99 | const chosenReply = getChosenReply(id); 100 | if (message.get('quick_replies') !== undefined) { 101 | const buttons = message.get('quick_replies'); 102 | if (chosenReply) { 103 | return ; 104 | } 105 | return this.renderButtons(message, buttons, false); 106 | } else if (message.get('buttons') !== undefined) { 107 | const buttons = message.get('buttons'); 108 | return this.renderButtons(message, buttons, true); 109 | } 110 | return ; 111 | } 112 | } 113 | 114 | Buttons.contextType = ThemeContext; 115 | 116 | const mapStateToProps = state => ({ 117 | getChosenReply: id => state.messages.get(id).get('chosenReply'), 118 | inputState: state.behavior.get('disabledInput'), 119 | linkTarget: state.metadata.get('linkTarget') 120 | }); 121 | 122 | const mapDispatchToProps = dispatch => ({ 123 | toggleInputDisabled: () => dispatch(toggleInputDisabled()), 124 | chooseReply: (payload, title, id) => { 125 | dispatch(setButtons(id, title)); 126 | dispatch(addUserMessage(title)); 127 | dispatch(emitUserMessage(payload)); 128 | // dispatch(toggleInputDisabled()); 129 | } 130 | }); 131 | 132 | Buttons.propTypes = { 133 | getChosenReply: PropTypes.func, 134 | chooseReply: PropTypes.func, 135 | id: PropTypes.number, 136 | isLast: PropTypes.bool, 137 | message: PROP_TYPES.BUTTONS, 138 | linkTarget: PropTypes.string 139 | }; 140 | 141 | export default connect(mapStateToProps, mapDispatchToProps)(Buttons); 142 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Buttons/styles.scss: -------------------------------------------------------------------------------- 1 | @import "variables.scss"; 2 | 3 | @import "common.scss"; 4 | 5 | .#{$namespace}conversation-container { 6 | 7 | .#{$namespace}replies { 8 | @include replies; 9 | } 10 | 11 | .#{$namespace}reply { 12 | @include reply; 13 | } 14 | 15 | .#{$namespace}response { 16 | @include message-bubble($grey-2, #000, 0 15px 15px 15px); 17 | max-width: 85%; 18 | } 19 | 20 | .#{$namespace}avatar { 21 | width: 17px; 22 | height: 17px; 23 | border-radius: 100%; 24 | margin-right: 6px; 25 | position: relative; 26 | bottom: 5px; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Buttons/test/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import configureMockStore from 'redux-mock-store'; 4 | import { render } from 'enzyme'; 5 | 6 | import { createButtons } from 'helper'; 7 | import Buttons from '../index'; 8 | 9 | 10 | describe('', () => { 11 | const buttons = createButtons({ 12 | text: 'test', 13 | quick_replies: [ 14 | { 15 | type: 'postback', 16 | content_type: 'text', 17 | title: 'Button title 1', 18 | payload: '/payload1' 19 | }, 20 | { 21 | type: 'web_url', 22 | content_type: 'text', 23 | title: 'google', 24 | url: 'http://www.google.ca' 25 | } 26 | ] 27 | }); 28 | 29 | buttons.set('docViewer', false); 30 | const mockStore = configureMockStore(); 31 | const store = mockStore({ getChosenReply: () => undefined, 32 | inputState: false, 33 | messages: new Map([[1, new Map([['chosenReply', undefined]])]]), 34 | behavior: new Map([['disabledInput', false]]), 35 | metadata: new Map() }); 36 | 37 | const buttonsComponent = render( 38 | 39 | 45 | 46 | ); 47 | 48 | it('should render a quick reply with a link to google', () => { 49 | expect(buttonsComponent.find('a.rw-reply')).toHaveLength(1); 50 | expect(buttonsComponent.find('a.rw-reply').html()).toEqual('google'); 51 | expect(buttonsComponent.find('a.rw-reply').prop('href')).toEqual('http://www.google.ca'); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Carousel/index.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useContext } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import { addUserMessage, emitUserMessage } from 'actions'; 6 | import { PROP_TYPES } from 'constants'; 7 | import Arrow from 'assets/arrow'; 8 | import ThemeContext from '../../../../../../ThemeContext'; 9 | 10 | import './styles.scss'; 11 | 12 | const Carousel = (props) => { 13 | const carousel = props.message.toJS(); 14 | 15 | const handleClick = (action) => { 16 | if (!action || action.type !== 'postback') return; 17 | const { chooseReply } = props; 18 | chooseReply(action.payload, action.title); 19 | }; 20 | 21 | const scrollContainer = useRef(); 22 | const [leftButton, setLeftButton] = useState(false); 23 | const [rightButton, setRightButton] = useState(true); 24 | const { mainColor, assistTextColor } = useContext(ThemeContext); 25 | 26 | 27 | const handleScroll = () => { 28 | const current = scrollContainer.current; 29 | if (current.scrollLeft > 0) { 30 | setLeftButton(true); 31 | } else { 32 | setLeftButton(false); 33 | } 34 | if (current.clientWidth === current.scrollWidth - current.scrollLeft) { 35 | setRightButton(false); 36 | } else { 37 | setRightButton(true); 38 | } 39 | }; 40 | 41 | const handleLeftArrow = () => { 42 | scrollContainer.current.scrollTo({ 43 | left: scrollContainer.current.scrollLeft - 230, 44 | behavior: 'smooth' 45 | }); 46 | }; 47 | 48 | const handleRightArrow = () => { 49 | scrollContainer.current.scrollTo({ 50 | left: scrollContainer.current.scrollLeft + 230, 51 | behavior: 'smooth' 52 | }); 53 | }; 54 | 55 | const { linkTarget } = props; 56 | 57 | return ( 58 | 59 |
handleScroll()}> 60 | {carousel.elements.map((carouselCard, index) => { 61 | const defaultActionUrl = 62 | carouselCard.default_action && carouselCard.default_action.type === 'web_url' 63 | ? carouselCard.default_action.url 64 | : null; 65 | return ( 66 |
67 | handleClick(carouselCard.default_action)} 72 | > 73 | {carouselCard.image_url ? ( 74 | {`${carouselCard.title} 79 | ) : ( 80 |
81 | )} 82 | 83 | handleClick(carouselCard.default_action)} 89 | style={{ color: assistTextColor }} 90 | > 91 | {carouselCard.title} 92 | 93 | handleClick(carouselCard.default_action)} 99 | style={{ color: assistTextColor }} 100 | > 101 | {carouselCard.subtitle} 102 | 103 |
104 | {carouselCard.buttons.map((button, buttonIndex) => { 105 | if (button.type === 'web_url') { 106 | return ( 107 | 115 | {button.title} 116 | 117 | ); 118 | } 119 | return ( 120 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions 121 |
handleClick(button)} 125 | role="button" 126 | tabIndex={0} 127 | style={{ borderColor: mainColor, color: mainColor }} 128 | > 129 | {button.title} 130 |
131 | ); 132 | })} 133 |
134 |
135 | ); 136 | })} 137 |
138 |
139 | {leftButton && ( 140 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions 141 |
147 |
148 |
149 | )} 150 | {rightButton && ( 151 | // eslint-disable-next-line jsx-a11y/no-static-element-interactions 152 |
158 |
159 |
160 | )} 161 |
162 | 163 | ); 164 | }; 165 | 166 | 167 | Carousel.propTypes = { 168 | message: PROP_TYPES.CAROUSEL, 169 | // completely bugged, it's actually used in handle click 170 | // eslint-disable-next-line react/no-unused-prop-types 171 | chooseReply: PropTypes.func.isRequired, 172 | linkTarget: PropTypes.string 173 | }; 174 | 175 | const mapStateToProps = state => ({ 176 | linkTarget: state.metadata.get('linkTarget') 177 | }); 178 | 179 | const mapDispatchToProps = dispatch => ({ 180 | chooseReply: (payload, title) => { 181 | if (title) dispatch(addUserMessage(title)); 182 | dispatch(emitUserMessage(payload)); 183 | } 184 | }); 185 | 186 | export default connect(mapStateToProps, mapDispatchToProps)(Carousel); 187 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Carousel/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Provider } from 'react-redux'; 4 | 5 | import { createCarousel } from 'helper'; 6 | import { List } from 'immutable'; 7 | 8 | import Messages from '../../index'; 9 | import { initStore } from '../../../../../../../../store/store'; 10 | import LocalStorageMock from '../../../../../../../../../mocks/localStorageMock'; 11 | 12 | describe('', () => { 13 | const carousel = createCarousel( 14 | { 15 | attachment: { 16 | type: 'template', 17 | payload: { 18 | template_type: 'generic', 19 | elements: [ 20 | { 21 | title: 'test', 22 | subtitle: 'test test test test test test test test test test test tes t', 23 | image_url: 'https://source.unsplash.com/random/4000x400/?portrait', 24 | default_action: { type: 'web_url', url: 'https://google.com' }, 25 | buttons: [ 26 | { title: 'bouton uno', type: 'postback', payload: '/chitchat.bye' }, 27 | { title: 'bouton 2', type: 'postback', payload: '/get_started' }, 28 | { 29 | title: 'un dernier bouton', 30 | type: 'web_url', 31 | url: 'https://facebook.com' 32 | } 33 | ] 34 | }, 35 | { 36 | title: 'test', 37 | subtitle: 'test', 38 | image_url: 'https://source.unsplash.com/random/330x300/?wine', 39 | default_action: null, 40 | buttons: [] 41 | }, 42 | { 43 | title: 'another test', 44 | subtitle: '', 45 | image_url: 'https://source.unsplash.com/random/400x400/?code', 46 | default_action: null, 47 | buttons: [] 48 | } 49 | ] 50 | } 51 | }, 52 | text: 'undefined' 53 | }, 54 | 'response' 55 | ); 56 | 57 | const responseMessage = List([carousel]); 58 | 59 | const localStorage = new LocalStorageMock(); 60 | 61 | const store = initStore('dummy', 'dummy', localStorage); 62 | 63 | store.dispatch({ 64 | type: 'CONNECT' 65 | }); 66 | 67 | const messagesComponent = shallow( 68 | 69 | 70 | 71 | ); 72 | 73 | it('should render a Carousel component and buttons and default actions', () => { 74 | expect(messagesComponent.render().find('.rw-carousel-card')).toHaveLength(3); 75 | expect(messagesComponent.render().find('a[href^="https://google"]')).toHaveLength(3); 76 | expect(messagesComponent.render().find('.rw-reply')).toHaveLength(3); 77 | 78 | expect(messagesComponent.render().find('.rw-reply[href^="https://facebook"]')).toHaveLength(1); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Carousel/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'variables.scss'; 2 | @import 'common.scss'; 3 | 4 | .#{$namespace}carousel-container { 5 | // @include message-bubble($grey-2, #000); 6 | //max-height: 345px; 7 | min-height: 345px; 8 | padding-bottom: 10px; 9 | overflow-x: hidden; 10 | overflow-y: hidden; 11 | white-space: nowrap; 12 | padding-left: 5px; 13 | padding-right: 0; 14 | margin-top: 8px; 15 | position: relative; 16 | display: flex; 17 | 18 | .#{$namespace}carousel-card { 19 | display: inline-block; 20 | max-width: 220px; 21 | width: 220px; 22 | min-width: 220px; 23 | min-height: 324px; 24 | margin: 3px 13px 3px 1px; 25 | box-shadow: 4px 2px 12px 1px rgba(0, 0, 0, 0.1); 26 | border-radius: 8px; 27 | overflow: hidden; 28 | position: relative; 29 | 30 | .#{$namespace}carousel-card-image { 31 | width: 100%; 32 | height: 150px; 33 | object-fit: cover; 34 | display: block; 35 | // this is so that it's not empty while loading 36 | background-color: $grey-3; 37 | cursor: pointer; 38 | } 39 | 40 | .#{$namespace}carousel-card-title { 41 | display: block; 42 | margin: 7px 10px 2px 9px; 43 | font-weight: 500; 44 | overflow: hidden; 45 | text-overflow: ellipsis; 46 | cursor: pointer; 47 | text-decoration: none; 48 | color: inherit; 49 | } 50 | 51 | .#{$namespace}carousel-card-subtitle { 52 | display: block; 53 | margin: 0 9px 8px 9px; 54 | opacity: 0.5; 55 | font-size: 0.8em; 56 | overflow: hidden; 57 | white-space: normal; 58 | line-height: initial; 59 | cursor: pointer; 60 | text-decoration: none; 61 | color: inherit; 62 | } 63 | 64 | .#{$namespace}carousel-buttons-container { 65 | margin-bottom: 1.5rem; 66 | width: 100%; 67 | .#{$namespace}reply { 68 | min-height: 21px; 69 | margin: 5px 10px; 70 | font-size: 0.9em; 71 | justify-content: center; 72 | outline: none; 73 | span { 74 | white-space: nowrap; 75 | text-overflow: ellipsis; 76 | overflow: hidden; 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | .#{$namespace}carousel-arrows-container { 84 | max-height: 345px; 85 | height: 345px; 86 | width: 100%; 87 | padding-left: -12px; 88 | padding-right: 0; 89 | margin-top: 8px; 90 | position: absolute; 91 | pointer-events: none; 92 | 93 | .#{$namespace}carousel-arrow { 94 | pointer-events: initial; 95 | position: absolute; 96 | width: 30px; 97 | height: 30px; 98 | background-color: white; 99 | top: 40%; 100 | border-radius: 11px; 101 | transition: all 0.2s ease-in-out; 102 | cursor: pointer; 103 | box-shadow: 0px 2px 8px 4px rgba(200, 200, 200, 0.35); 104 | &:hover { 105 | box-shadow: 0px 3px 8px 4px rgba(200, 200, 200, 0.2); 106 | top: calc(40% - 3px); 107 | } 108 | &:active { 109 | box-shadow: 0px 2px 8px 4px rgba(200, 200, 200, 0.3); 110 | top: calc(40% - 2px); 111 | } 112 | outline: none; 113 | img.#{$namespace}arrow { 114 | position: absolute; 115 | height: 100%; 116 | left: 3px; 117 | } 118 | } 119 | 120 | 121 | .#{$namespace}left-arrow { 122 | left: 10px; 123 | } 124 | 125 | .#{$namespace}right-arrow { 126 | right: 10px; 127 | div.#{$namespace}arrow { 128 | transform: rotate(180deg); 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/ImgReply/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { PROP_TYPES } from 'constants'; 3 | 4 | import './styles.scss'; 5 | 6 | class ImgReply extends PureComponent { 7 | render() { 8 | const { params: { images: { dims = {} } = {} } } = this.props; 9 | const { width, height } = dims; 10 | // Convert map to object 11 | const message = this.props.message.toJS(); 12 | const { title, image } = message; 13 | const customCss = this.props.message.get('customCss') && this.props.message.get('customCss').toJS(); 14 | 15 | if (customCss && customCss.style === 'class') { 16 | customCss.css = customCss.css.replace(/^\./, ''); 17 | } 18 | 19 | return ( 20 | 21 |
29 | 30 | { title } 31 | 32 |
33 | 34 |
35 |
36 | ); 37 | } 38 | } 39 | 40 | ImgReply.propTypes = { 41 | message: PROP_TYPES.IMGREPLY 42 | }; 43 | 44 | ImgReply.defaultProps = { 45 | params: {} 46 | }; 47 | 48 | export default ImgReply; 49 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/ImgReply/styles.scss: -------------------------------------------------------------------------------- 1 | @import "variables.scss"; 2 | 3 | @import "common.scss"; 4 | 5 | .#{$namespace}conversation-container { 6 | .#{$namespace}image-details { 7 | object-fit: scale-down; 8 | max-width: 100%; 9 | margin-top: 10px; 10 | } 11 | 12 | .#{$namespace}image-frame { 13 | object-position: 0 0; 14 | object-fit: cover; 15 | width: 100%; 16 | height: 100%; 17 | border-radius: 15px; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Message/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import ReactMarkdown from 'react-markdown'; 3 | import { connect } from 'react-redux'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import { PROP_TYPES } from 'constants'; 7 | import DocViewer from '../docViewer'; 8 | import './styles.scss'; 9 | import ThemeContext from '../../../../../../ThemeContext'; 10 | 11 | class Message extends PureComponent { 12 | render() { 13 | const { docViewer, linkTarget } = this.props; 14 | const sender = this.props.message.get('sender'); 15 | const text = this.props.message.get('text'); 16 | const customCss = this.props.message.get('customCss') && this.props.message.get('customCss').toJS(); 17 | 18 | if (customCss && customCss.style === 'class') { 19 | customCss.css = customCss.css.replace(/^\./, ''); 20 | } 21 | 22 | const { userTextColor, userBackgroundColor, assistTextColor, assistBackgoundColor } = this.context; 23 | let style; 24 | if (sender === 'response' && customCss && customCss.style === 'class') { 25 | style = undefined; 26 | } else if (sender === 'response' && customCss && customCss.style) { 27 | style = { cssText: customCss.css }; 28 | } else if (sender === 'response') { 29 | style = { color: assistTextColor, backgroundColor: assistBackgoundColor }; 30 | } else if (sender === 'client') { 31 | style = { color: userTextColor, backgroundColor: userBackgroundColor }; 32 | } 33 | 34 | return ( 35 |
41 |
44 | {sender === 'response' ? ( 45 | { 49 | if (!url.startsWith('mailto') && !url.startsWith('javascript')) return '_blank'; 50 | return undefined; 51 | }} 52 | transformLinkUri={null} 53 | renderers={{ 54 | link: props => 55 | docViewer ? ( 56 | {props.children} 57 | ) : ( 58 | e.stopPropagation()}>{props.children} 59 | ) 60 | }} 61 | /> 62 | ) : ( 63 | text 64 | )} 65 |
66 |
67 | ); 68 | } 69 | } 70 | 71 | 72 | Message.contextType = ThemeContext; 73 | Message.propTypes = { 74 | message: PROP_TYPES.MESSAGE, 75 | docViewer: PropTypes.bool, 76 | linkTarget: PropTypes.string 77 | }; 78 | 79 | Message.defaultTypes = { 80 | docViewer: false, 81 | linkTarget: '_blank' 82 | }; 83 | 84 | const mapStateToProps = state => ({ 85 | linkTarget: state.metadata.get('linkTarget'), 86 | docViewer: state.behavior.get('docViewer') 87 | }); 88 | 89 | export default connect(mapStateToProps)(Message); 90 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Message/styles.scss: -------------------------------------------------------------------------------- 1 | @import "variables.scss"; 2 | 3 | @import "common.scss"; 4 | 5 | .#{$namespace}conversation-container { 6 | 7 | .#{$namespace}message { 8 | margin: 10px; 9 | font-size: 16px; 10 | line-height: 20px; 11 | display: flex; 12 | font-family: $fontfamily; 13 | flex-wrap: wrap; 14 | position: relative; 15 | 16 | .#{$namespace}markdown { 17 | p { 18 | margin: 0; 19 | } 20 | img { 21 | max-width: 100%; 22 | } 23 | } 24 | } 25 | 26 | .#{$namespace}client { 27 | @include message-bubble($blue-1, $white); 28 | background-color: $tertiary ; 29 | max-width: 85%; 30 | margin-left: auto; 31 | overflow-wrap: break-word; 32 | a { 33 | color: $turqois-1; 34 | } 35 | } 36 | 37 | .#{$namespace}response { 38 | @include message-bubble($grey-2, #000, 0 15px 15px 15px); 39 | overflow-wrap: break-word; 40 | } 41 | 42 | /* For markdown elements created with default styles */ 43 | .#{$namespace}message-text { 44 | margin: 0; 45 | .#{$namespace}markdown{ 46 | p{ 47 | margin-bottom: 1em; 48 | &:last-child{ 49 | margin-bottom: 0; 50 | } 51 | } 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Snippet/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { PROP_TYPES } from 'constants'; 3 | 4 | import './styles.scss'; 5 | 6 | class Snippet extends PureComponent { 7 | render() { 8 | return ( 9 |
10 | 11 | { this.props.message.get('title') } 12 | 13 | 18 |
19 | ); 20 | } 21 | } 22 | 23 | Snippet.propTypes = { 24 | message: PROP_TYPES.SNIPPET 25 | }; 26 | 27 | export default Snippet; 28 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Snippet/styles.scss: -------------------------------------------------------------------------------- 1 | @import "variables.scss"; 2 | 3 | @import "common.scss"; 4 | 5 | .#{$namespace}conversation-container { 6 | .#{$namespace}snippet { 7 | @include message-bubble($grey-2, #000); 8 | } 9 | 10 | .#{$namespace}snippet-title { 11 | margin: 0; 12 | } 13 | 14 | .#{$namespace}snippet-details { 15 | border-left: 2px solid $green-1; 16 | margin-top: 5px; 17 | padding-left: 10px; 18 | } 19 | 20 | .#{$namespace}link { 21 | font-family: $fontfamily; 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/VidReply/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { PROP_TYPES } from 'constants'; 3 | 4 | import './styles.scss'; 5 | 6 | class VidReply extends PureComponent { 7 | render() { 8 | return ( 9 |
10 | 11 | { this.props.message.get('title') } 12 | 13 |
14 |