├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── FEA_open_source_sm.png ├── chat-demonstration.gif ├── clear-button.svg ├── close.svg ├── icon-smiley.svg ├── launcher_button.svg ├── minus.svg ├── plus.svg ├── send_button.svg ├── zoom-in.svg └── zoom-out.svg ├── circle.yml ├── custom.d.ts ├── dev ├── App.tsx ├── index.html └── main.tsx ├── index.d.ts ├── index.js ├── mocks ├── fileMock.js └── styleMock.js ├── package-lock.json ├── package.json ├── src ├── components │ └── Widget │ │ ├── components │ │ ├── Conversation │ │ │ ├── components │ │ │ │ ├── Header │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── style.scss │ │ │ │ ├── Messages │ │ │ │ │ ├── components │ │ │ │ │ │ ├── Loader │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── styles.scss │ │ │ │ │ │ ├── Message │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── styles.scss │ │ │ │ │ │ │ └── test │ │ │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ │ └── index.test.js.snap │ │ │ │ │ │ │ │ └── index.test.js │ │ │ │ │ │ └── Snippet │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── styles.scss │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── styles.scss │ │ │ │ │ └── test │ │ │ │ │ │ └── index.test.js │ │ │ │ ├── QuickButtons │ │ │ │ │ ├── components │ │ │ │ │ │ └── QuickButton │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── styles.scss │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── style.scss │ │ │ │ └── Sender │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── style.scss │ │ │ ├── index.tsx │ │ │ └── style.scss │ │ ├── FullScreenPreview │ │ │ ├── index.tsx │ │ │ ├── styles.scss │ │ │ ├── usePortal.ts │ │ │ └── usePreview.ts │ │ └── Launcher │ │ │ ├── components │ │ │ └── Badge │ │ │ │ ├── index.tsx │ │ │ │ └── style.scss │ │ │ ├── index.tsx │ │ │ ├── style.scss │ │ │ └── test │ │ │ └── index.test.js │ │ ├── index.tsx │ │ ├── layout.tsx │ │ ├── style.scss │ │ └── test │ │ └── index.test.js ├── constants.ts ├── index.tsx ├── scss │ ├── _animation.scss │ ├── _common.scss │ └── variables │ │ ├── _colors.scss │ │ └── _sizes.scss ├── store │ ├── actions │ │ ├── index.ts │ │ └── types.ts │ ├── dispatcher.ts │ ├── index.ts │ ├── reducers │ │ ├── behaviorReducer.ts │ │ ├── fullscreenPreviewReducer.ts │ │ ├── messagesReducer.ts │ │ └── quickButtonsReducer.ts │ └── types.ts └── utils │ ├── contentEditable.ts │ ├── createReducer.ts │ ├── messages.ts │ ├── store.ts │ └── types.ts ├── tsconfig.json ├── tsconfig.paths.json ├── webpack.config.dev.js └── webpack.config.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "browsers": ["last 2 versions", "safari >= 7"] 6 | } 7 | }], 8 | ["@babel/preset-react", { 9 | "runtime": "automatic" 10 | }], 11 | "@babel/preset-typescript" 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-proposal-class-properties", 15 | "@babel/plugin-proposal-object-rest-spread" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | dev 3 | webpack.config* 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "wolox", 3 | "rules": { 4 | "import/order": "off", 5 | "react/jsx-uses-react": "off", 6 | "react/react-in-jsx-scope": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.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 | 12 | # Test files 13 | coverage 14 | 15 | # vscode 16 | .vscode 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | /dev 3 | index.js 4 | 5 | assets 6 | 7 | mocks 8 | coverage 9 | 10 | circle.yml 11 | .babelrc 12 | .eslintrc.js 13 | .eslintignore 14 | webpack.config* 15 | 16 | package-lock.json 17 | yarn.lock 18 | -------------------------------------------------------------------------------- /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 dev this project and see changes on the fly, simply use the script 23 | 24 | ```bash 25 | npm start 26 | ``` 27 | 28 | ## Testing 29 | 30 | Your new feature **must** be tested with the proper tools. In this project, we use Jest and Enzyme. Once your tests are written, run: 31 | 32 | ```bash 33 | npm run test 34 | ``` 35 | All new tests needs to be pass in order to approve the PR. 36 | 37 | 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. 38 | 39 | ## Documentation 40 | 41 | If you are adding a new feature, you **must** add the documentation for it, showing how to use it. 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Martín Callegari 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Chat Widget 2 | [![circle-ci](https://img.shields.io/circleci/project/github/Wolox/react-chat-widget.svg)](https://circleci.com/gh/Wolox/react-chat-widget) 3 | [![npm](https://img.shields.io/npm/v/react-chat-widget.svg)](https://www.npmjs.com/package/react-chat-widget) 4 | 5 | [![FEArmy](./assets/FEA_open_source_sm.png)](https://github.com/orgs/Wolox/teams/front-end-army/members) 6 | 7 | ## Features 8 | 9 | - Plain text message UI 10 | - Snippet style for links (only as responses for now) 11 | - Fully customizable 12 | - Easy to use 13 | 14 | ![demonstration](./assets/chat-demonstration.gif) 15 | 16 | ## Installation 17 | 18 | #### npm 19 | ```bash 20 | npm install --save react-chat-widget 21 | ``` 22 | 23 | #### yarn 24 | ```bash 25 | yarn add react-chat-widget 26 | ``` 27 | 28 | ## Usage 29 | 30 | 1- Add the Widget component to your root component 31 | 32 | ```js 33 | import React from 'react'; 34 | import { Widget } from 'react-chat-widget'; 35 | 36 | import 'react-chat-widget/lib/styles.css'; 37 | 38 | function App() { 39 | return ( 40 |
41 | 42 |
43 | ); 44 | } 45 | 46 | export default App; 47 | ``` 48 | 49 | 2- The only required prop you need to use is the `handleNewUserMessage`, which will receive the input from the user. 50 | 51 | ```js 52 | import React from 'react'; 53 | import { Widget } from 'react-chat-widget'; 54 | 55 | import 'react-chat-widget/lib/styles.css'; 56 | 57 | function App() { 58 | const handleNewUserMessage = (newMessage) => { 59 | console.log(`New message incoming! ${newMessage}`); 60 | // Now send the message throught the backend API 61 | }; 62 | 63 | return ( 64 |
65 | 68 |
69 | ); 70 | } 71 | 72 | export default App; 73 | ``` 74 | 75 | 3- Import the methods for you to add messages in the Widget. (See [messages](#messages)) 76 | 77 | ```js 78 | import React from 'react'; 79 | import { Widget, addResponseMessage } from 'react-chat-widget'; 80 | 81 | import 'react-chat-widget/lib/styles.css'; 82 | 83 | function App() { 84 | useEffect(() => { 85 | addResponseMessage('Welcome to this awesome chat!'); 86 | }, []); 87 | 88 | const handleNewUserMessage = (newMessage) => { 89 | console.log(`New message incoming! ${newMessage}`); 90 | // Now send the message throught the backend API 91 | addResponseMessage(response); 92 | }; 93 | 94 | return ( 95 |
96 | 99 |
100 | ); 101 | } 102 | 103 | export default App; 104 | ``` 105 | 106 | 4- Customize the widget to match your app design! You can add both props to manage the title of the widget and the avatar it will use. Of course, feel free to change the styles the widget will have in the CSS 107 | 108 | ```js 109 | import React, { useEffect } from 'react'; 110 | import { Widget, addResponseMessage } from 'react-chat-widget'; 111 | 112 | import 'react-chat-widget/lib/styles.css'; 113 | 114 | import logo from './logo.svg'; 115 | 116 | function App() { 117 | useEffect(() => { 118 | addResponseMessage('Welcome to this **awesome** chat!'); 119 | }, []); 120 | 121 | const handleNewUserMessage = (newMessage) => { 122 | console.log(`New message incoming! ${newMessage}`); 123 | // Now send the message throught the backend API 124 | }; 125 | 126 | return ( 127 |
128 | 134 |
135 | ); 136 | } 137 | 138 | export default App; 139 | 140 | ``` 141 | 142 | ## API 143 | 144 | #### Props 145 | 146 | |prop|type|required|default value|description| 147 | |---|--- |--- |--- |--- | 148 | |**handleNewUserMessage**|(...args: any[]) => any|YES| |Function to handle the user input, will receive the full text message when submitted| 149 | |**title**|string|NO|'Welcome'|Title of the widget| 150 | |**subtitle**|string|NO|'This is your chat subtitle'|Subtitle of the widget| 151 | |**senderPlaceHolder**|string|NO|'Type a message...'|The placeholder of the message input| 152 | |**profileAvatar**|string|NO| |The profile image that will be set on the responses| 153 | |**profileClientAvatar**|string|NO| |The profile image that will be set on the client messages| 154 | |**titleAvatar**|string|NO| |The picture image that will be shown next to the chat title| 155 | |**showCloseButton**|boolean|NO|false|Show or hide the close button in full screen mode| 156 | |**fullScreenMode**|boolean|NO|false|Allow the use of full screen in full desktop mode| 157 | |**autofocus**|boolean|NO|true|Autofocus or not the user input| 158 | |**launcher**|(handleToggle) => ElementType|NO||Custom Launcher component to use instead of the default| 159 | |**handleQuickButtonClicked**|(...args: any[]) => any|NO||Function to handle the user clicking a quick button, will receive the 'value' when clicked.| 160 | |**showTimeStamp**|boolean|NO|true|Show time stamp on messages| 161 | |**chatId**|string|NO|'rcw-chat-container'|Chat container id for a11y| 162 | |**handleToggle**|(...args: any[]) => any|NO|'rcw-chat-container'|Function to handle when the widget is toggled, will receive the toggle status| 163 | |**launcherOpenLabel**|string|NO|'Open chat'|Alt value for the laucher when closed| 164 | |**launcherCloseLabel**|string|NO|'Close chat'|Alt value for the laucher when open| 165 | |**launcherOpenImg**|string|NO|''|local or remote image url, if not provided it will show default image| 166 | |**launcherCloseImg**|string|NO|''|local or remote image url, if not provided it will show default image| 167 | |**sendButtonAlt**|string|NO|'Send'|Send button alt for a11y purposes| 168 | |**handleTextInputChange**|(event) => any|NO| |Prop that triggers on input change| 169 | |**handleSubmit**|(event) => any|NO| |Prop that triggers when a message is submitted, used for custom validation| 170 | |**resizable**|boolean|NO|false|Prop that allows to resize the widget by dragging it's left border| 171 | |**emojis**|boolean|NO|false|enable emoji picker| 172 | |**showBadge**|boolean|NO|true|Prop that allows to show or hide the unread message badge| 173 | 174 | #### Styles 175 | 176 | To change the styles you need the widget to have, simply override the CSS classes wrapping them within the containers and add your own style to them! All classes are prefixed with `rcw-` so they don't override your other classes in case you are not hasing them. 177 | To override, you can do, for expample: 178 | 179 | ```css 180 | .rcw-conversation-container > .rcw-header { 181 | background-color: red; 182 | } 183 | 184 | .rcw-message > .rcw-response { 185 | background-color: black; 186 | color: white; 187 | } 188 | ``` 189 | 190 | That way, you can leave the JS clean and keep the styles within the CSS. 191 | 192 | #### Messages 193 | 194 | As of v3.0, messages now have an optional ID that can be added on creation.If you want to add new messages, you can use the following methods: 195 | 196 | - **addResponseMessage** 197 | - params: 198 | - text: string (supports markdown) 199 | - id: string (optional) 200 | - Method to add a new message written as a response to a user input. 201 | 202 | - **addUserMessage** 203 | - params: 204 | - text: string (supports markdown) 205 | - id: string (optional) 206 | - This method will add a new message written as a user. Keep in mind it will not trigger the prop handleNewUserMessage() 207 | 208 | - **addLinkSnippet** 209 | - params: 210 | - link 211 | - Method to add a link snippet. You need to provide this method with a link object, which must be in the shape of: 212 | ```js 213 | { 214 | title: 'My awesome link', 215 | link: 'https://github.com/Wolox/react-chat-widget', 216 | target: '_blank' 217 | } 218 | ``` 219 | - By default, `target` value is `_blank` which will open the link in a new window. 220 | 221 | - **renderCustomComponent** 222 | - params: 223 | - component: Component to be render, 224 | - props: props the component needs, 225 | - showAvatar: boolean, default value: false; the component will be rendered with the avatar like the messages 226 | - Method to render a custom component inside the messages container. With this method, you can add whatever component you need the widget to have. 227 | 228 | - **setQuickButtons** 229 | - params: 230 | - buttons: An array of objects with the keys `label` and `value` 231 | 232 | **Markdown is supported for both the responses and user messages.** 233 | 234 | #### Widget behavior 235 | 236 | You can also control certain actions of the widget: 237 | 238 | - **toggleWidget** 239 | - params: No params expected 240 | - This method is to toggle the widget at will without the need to trigger the click event on the launcher 241 | 242 | - **toggleInputDisabled** 243 | - params: No params expected 244 | - Method to toggle the availability of the message input for the user to write on 245 | 246 | - **toggleMsgLoader** 247 | - Toggles the message loader that shows as a "typing..." style. 248 | 249 | - **deleteMessages*** 250 | - params: 251 | - count: messages to delete counting from last to first 252 | - id: message id to delete 253 | - Delete messages that either have an id you previously set with the `addResponseMessage` or delete based on position or both of them. For example `deleteMessages(2, 'myId')` will delete the message that has the id `myId` and the previous message. 254 | 255 | - **markAllAsRead** 256 | - Marks all response messages as read. The user messages doesn't have the read/unread property. 257 | 258 | - **setBadgeCount** 259 | - params: 260 | - count: number 261 | - As of v3.0, the `badge` prop is being changed to be managed from within the Widget. This method is manually set the badge number. 262 | 263 | #### Widget components 264 | 265 | ##### Custom Launcher 266 | 267 | You can use a custom component for the Launcher if you need one that's not the default, simply use the **launcher** prop: 268 | 269 | ```js 270 | import React from 'react'; 271 | import { Widget } from 'react-chat-widget'; 272 | 273 | ... 274 | 275 | function MyApp() { 276 | const getCustomLauncher = (handleToggle) => 277 | 278 | 279 | return ( 280 | getCustomLauncher(handleToggle)} 283 | /> 284 | ) 285 | } 286 | ``` 287 | 288 | `getCustomLauncher()` is a method that will return the `Launcher` component as seen in the example. By default, the function passed by that prop, will receive the `handleToggle` parameter which is the method that will toggle the widget. 289 | 290 | ## About 291 | 292 | This project is maintained by [Martín Callegari](https://github.com/mcallegari10) and it was written by [Wolox](http://www.wolox.com.ar). 293 | 294 | ![Wolox](https://raw.githubusercontent.com/Wolox/press-kit/master/logos/logo_banner.png) 295 | -------------------------------------------------------------------------------- /assets/FEA_open_source_sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/react-chat-widget/c9a2d074b82a231f84963367fdb99e6970a494c4/assets/FEA_open_source_sm.png -------------------------------------------------------------------------------- /assets/chat-demonstration.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wolox/react-chat-widget/c9a2d074b82a231f84963367fdb99e6970a494c4/assets/chat-demonstration.gif -------------------------------------------------------------------------------- /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/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icon-smiley.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/launcher_button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ic_button 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/minus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/send_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/zoom-in.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/zoom-out.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 8.4.0 4 | 5 | dependencies: 6 | override: 7 | - npm i 8 | test: 9 | override: 10 | - npm run test 11 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /dev/App.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | 3 | import { Widget, addResponseMessage, setQuickButtons, toggleMsgLoader, addLinkSnippet } from '../index'; 4 | import { addUserMessage } from '..'; 5 | 6 | export default class App extends Component { 7 | componentDidMount() { 8 | addResponseMessage('Welcome to this awesome chat!'); 9 | addLinkSnippet({ link: 'https://google.com', title: 'Google' }); 10 | addResponseMessage('![](https://raw.githubusercontent.com/Wolox/press-kit/master/logos/logo_banner.png)'); 11 | addResponseMessage('![vertical](https://d2sofvawe08yqg.cloudfront.net/reintroducing-react/hero2x?1556470143)'); 12 | } 13 | 14 | handleNewUserMessage = (newMessage: any) => { 15 | toggleMsgLoader(); 16 | setTimeout(() => { 17 | toggleMsgLoader(); 18 | if (newMessage === 'fruits') { 19 | setQuickButtons([ { label: 'Apple', value: 'apple' }, { label: 'Orange', value: 'orange' }, { label: 'Pear', value: 'pear' }, { label: 'Banana', value: 'banana' } ]); 20 | } else { 21 | addResponseMessage(newMessage); 22 | } 23 | }, 2000); 24 | } 25 | 26 | handleQuickButtonClicked = (e: any) => { 27 | addResponseMessage('Selected ' + e); 28 | setQuickButtons([]); 29 | } 30 | 31 | handleSubmit = (msgText: string) => { 32 | if(msgText.length < 80) { 33 | addUserMessage("Uh oh, please write a bit more."); 34 | return false; 35 | } 36 | return true; 37 | } 38 | 39 | render() { 40 | return ( 41 | 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Dev Widget 10 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /dev/main.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from 'react-dom'; 2 | 3 | import App from './App'; 4 | 5 | ReactDOM.render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for react-chat-widget v3.0.0 2 | // Project: 3 | // Definitions by: Martín Callegari 4 | 5 | import { ElementType } from 'react'; 6 | 7 | declare const Widget: ElementType; 8 | 9 | export function addUserMessage(text: string): void; 10 | export function addUserMessage(text: string, id: string): void; 11 | 12 | export function addResponseMessage(text: string): void; 13 | export function addResponseMessage(text: string, id: string): void; 14 | 15 | export function addLinkSnippet(link: { link: string, title: string, target?: string }): void; 16 | export function addLinkSnippet(link: { link: string, title: string, target?: string }, id: string): void; 17 | 18 | export function renderCustomComponent(component: ElementType, props: any): void; 19 | export function renderCustomComponent(component: ElementType, props: any, showAvatar: boolean): void; 20 | export function renderCustomComponent(component: ElementType, props: any, showAvatar: boolean, id: string): void; 21 | 22 | export function toggleMsgLoader(): void; 23 | export function toggleWidget(): void; 24 | export function toggleInputDisabled(): void; 25 | export function dropMessages(): void; 26 | export function isWidgetOpened(): boolean; 27 | export function setQuickButtons(buttons: Array<{ label: string, value: string | number }>): void; 28 | 29 | export function deleteMessages(count: number): void; 30 | export function deleteMessages(count: number, id: string): void; 31 | 32 | export function markAllAsRead(): void; 33 | export function setBadgeCount(count: number): void; 34 | 35 | export as namespace ReactChatWidget; 36 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import 'react/jsx-runtime'; 2 | 3 | import ConnectedWidget from './src'; 4 | import { 5 | addUserMessage, 6 | addResponseMessage, 7 | addLinkSnippet, 8 | renderCustomComponent, 9 | toggleWidget, 10 | toggleInputDisabled, 11 | toggleMsgLoader, 12 | dropMessages, 13 | isWidgetOpened, 14 | setQuickButtons, 15 | deleteMessages, 16 | markAllAsRead, 17 | setBadgeCount 18 | } from './src/store/dispatcher'; 19 | 20 | export { 21 | ConnectedWidget as Widget, 22 | addUserMessage, 23 | addResponseMessage, 24 | addLinkSnippet, 25 | renderCustomComponent, 26 | toggleWidget, 27 | toggleInputDisabled, 28 | toggleMsgLoader, 29 | dropMessages, 30 | isWidgetOpened, 31 | setQuickButtons, 32 | deleteMessages, 33 | markAllAsRead, 34 | setBadgeCount 35 | }; 36 | -------------------------------------------------------------------------------- /mocks/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /mocks/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-chat-widget", 3 | "version": "3.1.4", 4 | "description": "Chat web widget for React apps", 5 | "main": "lib/index.js", 6 | "repository": "git@github.com:Wolox/react-chat-widget.git", 7 | "author": "Martín Callegari ", 8 | "license": "MIT", 9 | "types": "./lib/index.d.ts", 10 | "scripts": { 11 | "start": "webpack serve --config webpack.config.dev.js", 12 | "build": "webpack --config ./webpack.config.prod.js", 13 | "test": "jest --coverage" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "chat", 18 | "widget", 19 | "javascript" 20 | ], 21 | "dependencies": { 22 | "classnames": "^2.2.6", 23 | "date-fns": "^2.11.1", 24 | "emoji-mart": "^3.0.1", 25 | "markdown-it": "^8.4.1", 26 | "markdown-it-link-attributes": "^2.1.0", 27 | "markdown-it-sanitizer": "^0.4.3", 28 | "markdown-it-sup": "^1.0.0", 29 | "react-redux": "^7.2.4", 30 | "redux": "^4.1.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/cli": "^7.8.4", 34 | "@babel/core": "^7.14.0", 35 | "@babel/plugin-proposal-class-properties": "^7.8.3", 36 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 37 | "@babel/plugin-transform-react-jsx": "^7.14.9", 38 | "@babel/preset-env": "^7.14.1", 39 | "@babel/preset-react": "^7.14.5", 40 | "@babel/preset-typescript": "^7.8.3", 41 | "@toycode/markdown-it-class": "^1.2.3", 42 | "@types/classnames": "^2.2.10", 43 | "@types/enzyme": "^3.10.5", 44 | "@types/jest": "^25.1.4", 45 | "@types/react": "^17.0.37", 46 | "@types/react-dom": "^17.0.11", 47 | "@types/react-redux": "^7.1.7", 48 | "@typescript-eslint/eslint-plugin": "^4.22.0", 49 | "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", 50 | "autoprefixer": "^8.2.0", 51 | "babel-jest": "^19.0.0", 52 | "babel-loader": "^8.2.2", 53 | "babel-plugin-module-resolver": "^4.0.0", 54 | "caniuse-lite": "^1.0.30001219", 55 | "clean-webpack-plugin": "^4.0.0-alpha.0", 56 | "css-loader": "^5.2.4", 57 | "enzyme": "^3.11.0", 58 | "eslint": "^6.8.0", 59 | "eslint-config-wolox": "^4.0.0", 60 | "eslint-loader": "^3.0.3", 61 | "eslint-plugin-flowtype": "^2.30.4", 62 | "eslint-plugin-import": "^2.7.0", 63 | "eslint-plugin-prettier": "^3.4.0", 64 | "file-loader": "^0.11.2", 65 | "html-webpack-plugin": "^5.3.1", 66 | "husky": "^4.2.3", 67 | "jest": "^25.5.4", 68 | "mini-css-extract-plugin": "^1.5.1", 69 | "node-sass": "^4.13.1", 70 | "optimize-css-assets-webpack-plugin": "^5.0.4", 71 | "postcss": "^8.2.13", 72 | "postcss-loader": "^5.2.0", 73 | "postcss-preset-env": "^6.7.0", 74 | "prettier": "^1.1.0", 75 | "prettier-eslint": "^9.0.2", 76 | "react": "^17.0.2", 77 | "react-dom": "^17.0.2", 78 | "redux-mock-store": "^1.5.4", 79 | "sass-loader": "^11.0.1", 80 | "source-map-loader": "^2.0.1", 81 | "style-loader": "^2.0.0", 82 | "ts-jest": "^25.3.1", 83 | "ts-loader": "^9.1.1", 84 | "typescript": "^4.2.4", 85 | "uglifyjs-webpack-plugin": "^1.2.7", 86 | "url-loader": "^4.1.1", 87 | "webpack": "^5.36.1", 88 | "webpack-cli": "^4.6.0", 89 | "webpack-dev-server": "^3.11.2" 90 | }, 91 | "peerDependencies": { 92 | "react": "^17.0.2", 93 | "react-dom": "^17.0.2" 94 | }, 95 | "jest": { 96 | "verbose": true, 97 | "transform": { 98 | "^.+\\.tsx?$": "ts-jest", 99 | "^.+\\.(js|jsx)$": "babel-jest" 100 | }, 101 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 102 | "moduleFileExtensions": [ 103 | "ts", 104 | "tsx", 105 | "js", 106 | "jsx", 107 | "json", 108 | "node" 109 | ], 110 | "testURL": "http://localhost/", 111 | "moduleNameMapper": { 112 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/mocks/fileMock.js", 113 | "\\.(css|scss)$": "/mocks/styleMock.js" 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | const close = require('../../../../../../../assets/clear-button.svg') as string; 2 | 3 | import './style.scss'; 4 | 5 | type Props = { 6 | title: string; 7 | subtitle: string; 8 | toggleChat: () => void; 9 | showCloseButton: boolean; 10 | titleAvatar?: string; 11 | } 12 | 13 | function Header({ title, subtitle, toggleChat, showCloseButton, titleAvatar }: Props) { 14 | return ( 15 |
16 | {showCloseButton && 17 | 20 | } 21 |

22 | {titleAvatar && profile} 23 | {title} 24 |

25 | {subtitle} 26 |
27 | ); 28 | } 29 | 30 | export default Header; 31 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Header/style.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | @import 'common'; 3 | 4 | .rcw-conversation-container { 5 | 6 | .rcw-header { 7 | background-color: $turqois-1; 8 | border-radius: 10px 10px 0 0; 9 | color: $white; 10 | display: flex; 11 | flex-direction: column; 12 | text-align: center; 13 | padding: 15px 0 25px; 14 | } 15 | 16 | .rcw-title { 17 | font-size: 24px; 18 | margin: 0; 19 | padding: 15px 0; 20 | } 21 | 22 | .rcw-close-button { 23 | display: none; 24 | } 25 | 26 | .avatar { 27 | width: 40px; 28 | height: 40px; 29 | border-radius: 100%; 30 | margin-right: 10px; 31 | vertical-align: middle; 32 | } 33 | } 34 | 35 | .rcw-full-screen { 36 | 37 | .rcw-header { 38 | @include header-fs; 39 | } 40 | 41 | .rcw-title { 42 | @include title-fs; 43 | } 44 | 45 | .rcw-close-button { 46 | @include close-button-fs; 47 | } 48 | 49 | .rcw-close { 50 | @include close-fs; 51 | } 52 | } 53 | 54 | @media screen and (max-width: 800px) { 55 | 56 | .rcw-conversation-container { 57 | 58 | .rcw-header { 59 | @include header-fs; 60 | } 61 | 62 | .rcw-title { 63 | @include title-fs; 64 | } 65 | 66 | .rcw-close-button { 67 | @include close-button-fs; 68 | } 69 | 70 | .rcw-close { 71 | @include close-fs; 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | import cn from 'classnames'; 2 | 3 | import './styles.scss'; 4 | 5 | type Props = { 6 | typing: boolean; 7 | } 8 | 9 | function Loader({ typing }: Props) { 10 | return ( 11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 | ); 19 | } 20 | 21 | export default Loader; 22 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Loader/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | 3 | .loader { 4 | margin: 10px; 5 | display: none; 6 | 7 | &.active { 8 | display: flex; 9 | } 10 | } 11 | 12 | .loader-container { 13 | background-color: $grey-2; 14 | border-radius: 10px; 15 | padding: 15px; 16 | max-width: 215px; 17 | text-align: left; 18 | } 19 | 20 | .loader-dots { 21 | display: inline-block; 22 | height: 4px; 23 | width: 4px; 24 | border-radius: 50%; 25 | background: $grey-0; 26 | margin-right: 2px; 27 | animation: bounce 0.5s ease infinite alternate; 28 | 29 | &:nth-child(1) { 30 | animation-delay: 0.2s; 31 | } 32 | 33 | &:nth-child(2) { 34 | animation-delay: 0.3s; 35 | } 36 | 37 | &:nth-child(3) { 38 | animation-delay: 0.4s; 39 | } 40 | } 41 | 42 | @keyframes bounce { 43 | 0% { 44 | transform: translateY(0px); 45 | } 46 | 100% { 47 | transform: translateY(5px); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Message/index.tsx: -------------------------------------------------------------------------------- 1 | import format from 'date-fns/format'; 2 | import markdownIt from 'markdown-it'; 3 | import markdownItSup from 'markdown-it-sup'; 4 | import markdownItSanitizer from 'markdown-it-sanitizer'; 5 | import markdownItClass from '@toycode/markdown-it-class'; 6 | import markdownItLinkAttributes from 'markdown-it-link-attributes'; 7 | 8 | import { MessageTypes } from 'src/store/types'; 9 | 10 | import './styles.scss'; 11 | 12 | type Props = { 13 | message: MessageTypes; 14 | showTimeStamp: boolean; 15 | } 16 | 17 | function Message({ message, showTimeStamp }: Props) { 18 | const sanitizedHTML = markdownIt({ break: true }) 19 | .use(markdownItClass, { 20 | img: ['rcw-message-img'] 21 | }) 22 | .use(markdownItSup) 23 | .use(markdownItSanitizer) 24 | .use(markdownItLinkAttributes, { attrs: { target: '_blank', rel: 'noopener' } }) 25 | .render(message.text); 26 | 27 | return ( 28 |
29 |
30 | {showTimeStamp && {format(message.timestamp, 'hh:mm')}} 31 |
32 | ); 33 | } 34 | 35 | export default Message; 36 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Message/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | @import 'common'; 3 | 4 | .rcw-message { 5 | margin: 10px; 6 | display: flex; 7 | white-space: pre-wrap; 8 | word-wrap: break-word; 9 | 10 | &-client { 11 | flex-direction: row-reverse; 12 | } 13 | } 14 | 15 | .rcw-timestamp { 16 | font-size: 10px; 17 | margin-top: 5px; 18 | } 19 | 20 | .rcw-client { 21 | display: flex; 22 | flex-direction: column; 23 | margin-left: auto; 24 | 25 | .rcw-message-text { 26 | @include message-bubble($turqois-2); 27 | 28 | white-space: pre-wrap; 29 | word-wrap: break-word; 30 | } 31 | 32 | .rcw-timestamp { 33 | align-self: flex-end; 34 | } 35 | } 36 | 37 | .rcw-response { 38 | display: flex; 39 | flex-direction: column; 40 | align-items: flex-start; 41 | 42 | .rcw-message-text { 43 | @include message-bubble($grey-2); 44 | } 45 | } 46 | 47 | /* For markdown elements created with default styles */ 48 | .rcw-message-text { 49 | p { 50 | margin: 0; 51 | } 52 | 53 | img { 54 | width: 100%; 55 | object-fit: contain; 56 | } 57 | } 58 | 59 | .rcw-avatar { 60 | width: 40px; 61 | height: 40px; 62 | border-radius: 100%; 63 | margin-right: 10px; 64 | 65 | &-client { 66 | margin: 0 0 0 10px; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Message/test/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should reder a element 1`] = ` 4 | "

New message with Markdown!

5 | " 6 | `; 7 | 8 | exports[` should render a element 1`] = ` 9 | "

New message with Markdown!

10 | " 11 | `; 12 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Message/test/index.test.js: -------------------------------------------------------------------------------- 1 | import { shallow, configure } from 'enzyme'; 2 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 3 | 4 | import { createNewMessage } from '../../../../../../../../../utils/messages'; 5 | import Message from '../index'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('', () => { 10 | /* eslint-disable no-underscore-dangle */ 11 | const createMessageComponent = message => shallow(); 12 | 13 | it('should render a element', () => { 14 | const message = createNewMessage('New message with **Markdown**!'); 15 | const messageComponent = createMessageComponent(message); 16 | expect(messageComponent.find('.rcw-message-text').getElement().props.dangerouslySetInnerHTML.__html).toMatchSnapshot(); 17 | }); 18 | 19 | it('should reder a element', () => { 20 | const message = createNewMessage('New message with *Markdown*!'); 21 | const messageComponent = createMessageComponent(message); 22 | expect(messageComponent.find('.rcw-message-text').getElement().props.dangerouslySetInnerHTML.__html).toMatchSnapshot(); 23 | }); 24 | /* eslint-enable */ 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Snippet/index.tsx: -------------------------------------------------------------------------------- 1 | import format from 'date-fns/format'; 2 | 3 | import { Link } from 'src/store/types'; 4 | 5 | import './styles.scss'; 6 | 7 | type Props = { 8 | message: Link; 9 | showTimeStamp: boolean; 10 | } 11 | 12 | function Snippet({ message, showTimeStamp }: Props) { 13 | return ( 14 |
15 |
16 |
{message.title}
17 | 22 |
23 | {showTimeStamp && {format(message.timestamp, 'hh:mm')}} 24 |
25 | ); 26 | } 27 | 28 | export default Snippet; 29 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/components/Snippet/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | @import 'common'; 3 | 4 | .rcw-snippet { 5 | @include message-bubble($grey-2); 6 | } 7 | 8 | .rcw-snippet-title { 9 | margin: 0; 10 | } 11 | 12 | .rcw-snippet-details { 13 | border-left: 2px solid $green-1; 14 | margin-top: 5px; 15 | padding-left: 10px; 16 | } 17 | 18 | .rcw-link { 19 | font-size: 12px; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState, ElementRef, ImgHTMLAttributes, MouseEvent } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import format from 'date-fns/format'; 4 | 5 | import { scrollToBottom } from '../../../../../../utils/messages'; 6 | import { MessageTypes, Link, CustomCompMessage, GlobalState } from '../../../../../../store/types'; 7 | import { setBadgeCount, markAllMessagesRead } from '../../../../../../store/actions'; 8 | import { MESSAGE_SENDER } from '../../../../../../constants'; 9 | 10 | import Loader from './components/Loader'; 11 | import './styles.scss'; 12 | 13 | type Props = { 14 | showTimeStamp: boolean, 15 | profileAvatar?: string; 16 | profileClientAvatar?: string; 17 | } 18 | 19 | function Messages({ profileAvatar, profileClientAvatar, showTimeStamp }: Props) { 20 | const dispatch = useDispatch(); 21 | const { messages, typing, showChat, badgeCount } = useSelector((state: GlobalState) => ({ 22 | messages: state.messages.messages, 23 | badgeCount: state.messages.badgeCount, 24 | typing: state.behavior.messageLoader, 25 | showChat: state.behavior.showChat 26 | })); 27 | 28 | const messageRef = useRef(null); 29 | useEffect(() => { 30 | // @ts-ignore 31 | scrollToBottom(messageRef.current); 32 | if (showChat && badgeCount) dispatch(markAllMessagesRead()); 33 | else dispatch(setBadgeCount(messages.filter((message) => message.unread).length)); 34 | }, [messages, badgeCount, showChat]); 35 | 36 | const getComponentToRender = (message: MessageTypes | Link | CustomCompMessage) => { 37 | const ComponentToRender = message.component; 38 | if (message.type === 'component') { 39 | return ; 40 | } 41 | return ; 42 | }; 43 | 44 | // TODO: Fix this function or change to move the avatar to last message from response 45 | // const shouldRenderAvatar = (message: Message, index: number) => { 46 | // const previousMessage = messages[index - 1]; 47 | // if (message.showAvatar && previousMessage.showAvatar) { 48 | // dispatch(hideAvatar(index)); 49 | // } 50 | // } 51 | 52 | const isClient = (sender) => sender === MESSAGE_SENDER.CLIENT; 53 | 54 | return ( 55 |
56 | {messages?.map((message, index) => 57 |
59 | {((profileAvatar && !isClient(message.sender)) || (profileClientAvatar && isClient(message.sender))) && 60 | message.showAvatar && 61 | profile 66 | } 67 | {getComponentToRender(message)} 68 |
69 | )} 70 | 71 |
72 | ); 73 | } 74 | 75 | export default Messages; 76 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | @import 'common'; 3 | 4 | .rcw-messages-container { 5 | background-color: $white; 6 | height: 50vh; 7 | max-height: 410px; 8 | overflow-y: scroll; 9 | padding-top: 10px; 10 | -webkit-overflow-scrolling: touch; 11 | } 12 | 13 | .rcw-full-screen { 14 | 15 | .rcw-messages-container { 16 | @include messages-container-fs; 17 | } 18 | } 19 | 20 | @media screen and (max-width: 800px) { 21 | .rcw-messages-container { 22 | @include messages-container-fs; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Messages/test/index.test.js: -------------------------------------------------------------------------------- 1 | import { mount, configure } from 'enzyme'; 2 | import { Provider } from 'react-redux' 3 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 4 | 5 | import { createNewMessage, createLinkSnippet, createComponentMessage } from '../../../../../../../utils/messages'; 6 | import { createMockStore } from '../../../../../../../utils/store'; 7 | 8 | import Messages from '../index'; 9 | import Message from '../components/Message'; 10 | import Snippet from '../components/Snippet'; 11 | 12 | configure({ adapter: new Adapter() }); 13 | 14 | describe('', () => { 15 | 16 | /* eslint-disable react/prop-types */ 17 | const Dummy = ({ text }) =>
{text}
; 18 | /* eslint-enable */ 19 | const customComp = createComponentMessage(Dummy, { text: 'This is a Dummy Component!' }); 20 | const message = createNewMessage('Response message 1'); 21 | const linkSnippet = createLinkSnippet({ title: 'link', link: 'link' }); 22 | const mockStore = createMockStore({ messages: {messages: [message, linkSnippet, customComp], badgeCount: 0 }}) 23 | 24 | const messagesComponent = mount( 25 | 26 | 27 | 28 | ); 29 | 30 | it('should render a Message component', () => { 31 | expect(messagesComponent.find(Message)).toHaveLength(1); 32 | }); 33 | 34 | it('should render a Snippet component', () => { 35 | expect(messagesComponent.find(Snippet)).toHaveLength(1); 36 | }); 37 | 38 | it('should render a custom component', () => { 39 | expect(messagesComponent.find(Dummy)).toHaveLength(1); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/QuickButtons/components/QuickButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { QuickButtonTypes } from 'src/store/types'; 2 | import './styles.scss'; 3 | 4 | type Props = { 5 | button: QuickButtonTypes; 6 | onQuickButtonClicked: (event: any, value: string | number) => any; 7 | } 8 | 9 | function QuickButton({ button, onQuickButtonClicked }: Props) { 10 | return ( 11 | 14 | ); 15 | } 16 | 17 | export default QuickButton; 18 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/QuickButtons/components/QuickButton/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | 3 | .quick-button { 4 | background: none; 5 | border-radius: 15px; 6 | border: 2px solid $turqois-1; 7 | font-weight: bold; 8 | padding: 5px 10px; 9 | cursor: pointer; 10 | outline: 0; 11 | 12 | &:active { 13 | background: $turqois-1; 14 | color: $white; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/QuickButtons/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | 3 | import { GlobalState, QuickButtonTypes } from 'src/store/types'; 4 | import { AnyFunction } from 'src/utils/types'; 5 | 6 | import './style.scss'; 7 | 8 | type Props = { 9 | onQuickButtonClicked?: AnyFunction; 10 | } 11 | 12 | function QuickButtons({ onQuickButtonClicked }: Props) { 13 | const buttons = useSelector((state: GlobalState) => state.quickButtons.quickButtons); 14 | 15 | const getComponentToRender = (button: QuickButtonTypes) => { 16 | const ComponentToRender = button.component; 17 | return ( 18 | 22 | ); 23 | } 24 | 25 | if (!buttons.length) return null; 26 | 27 | return ( 28 |
29 |
    30 | {buttons.map((button, index) => 31 |
  • 32 | {getComponentToRender(button)} 33 |
  • 34 | ) 35 | } 36 |
37 |
38 | ); 39 | } 40 | 41 | export default QuickButtons; 42 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/QuickButtons/style.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | 3 | .quick-buttons-container { 4 | background: $white; 5 | overflow-x: auto; 6 | white-space: nowrap; 7 | padding: 10px; 8 | 9 | .quick-buttons { 10 | list-style: none; 11 | padding: 0; 12 | margin: 0; 13 | text-align: center; 14 | 15 | .quick-list-button { 16 | display: inline-block; 17 | margin-right: 10px; 18 | } 19 | } 20 | 21 | @media screen and (max-width: 800px) { 22 | padding-bottom: 15px; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Sender/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useState, forwardRef, useImperativeHandle } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import cn from 'classnames'; 4 | 5 | import { GlobalState } from 'src/store/types'; 6 | 7 | import { getCaretIndex, isFirefox, updateCaret, insertNodeAtCaret, getSelection } from '../../../../../../utils/contentEditable' 8 | const send = require('../../../../../../../assets/send_button.svg') as string; 9 | const emoji = require('../../../../../../../assets/icon-smiley.svg') as string; 10 | const brRegex = /
/g; 11 | 12 | import './style.scss'; 13 | 14 | type Props = { 15 | placeholder: string; 16 | disabledInput: boolean; 17 | autofocus: boolean; 18 | sendMessage: (event: any) => void; 19 | buttonAlt: string; 20 | onPressEmoji: () => void; 21 | onChangeSize: (event: any) => void; 22 | onTextInputChange?: (event: any) => void; 23 | } 24 | 25 | function Sender({ sendMessage, placeholder, disabledInput, autofocus, onTextInputChange, buttonAlt, onPressEmoji, onChangeSize }: Props, ref) { 26 | const showChat = useSelector((state: GlobalState) => state.behavior.showChat); 27 | const inputRef = useRef(null!); 28 | const refContainer = useRef(null); 29 | const [enter, setEnter]= useState(false) 30 | const [firefox, setFirefox] = useState(false); 31 | const [height, setHeight] = useState(0) 32 | // @ts-ignore 33 | useEffect(() => { if (showChat && autofocus) inputRef.current?.focus(); }, [showChat]); 34 | useEffect(() => { setFirefox(isFirefox())}, []) 35 | 36 | useImperativeHandle(ref, () => { 37 | return { 38 | onSelectEmoji: handlerOnSelectEmoji, 39 | }; 40 | }); 41 | 42 | const handlerOnChange = (event) => { 43 | onTextInputChange && onTextInputChange(event) 44 | } 45 | 46 | const handlerSendMessage = () => { 47 | const el = inputRef.current; 48 | if(el.innerHTML) { 49 | sendMessage(el.innerText); 50 | el.innerHTML = '' 51 | } 52 | } 53 | 54 | const handlerOnSelectEmoji = (emoji) => { 55 | const el = inputRef.current; 56 | const { start, end } = getSelection(el) 57 | if(el.innerHTML) { 58 | const firstPart = el.innerHTML.substring(0, start); 59 | const secondPart = el.innerHTML.substring(end); 60 | el.innerHTML = (`${firstPart}${emoji.native}${secondPart}`) 61 | } else { 62 | el.innerHTML = emoji.native 63 | } 64 | updateCaret(el, start, emoji.native.length) 65 | } 66 | 67 | const handlerOnKeyPress = (event) => { 68 | const el = inputRef.current; 69 | 70 | if(event.charCode == 13 && !event.shiftKey) { 71 | event.preventDefault() 72 | handlerSendMessage(); 73 | } 74 | if(event.charCode === 13 && event.shiftKey) { 75 | event.preventDefault() 76 | insertNodeAtCaret(el); 77 | setEnter(true) 78 | } 79 | } 80 | 81 | // TODO use a context for checkSize and toggle picker 82 | const checkSize = () => { 83 | const senderEl = refContainer.current 84 | if(senderEl && height !== senderEl.clientHeight) { 85 | const {clientHeight} = senderEl; 86 | setHeight(clientHeight) 87 | onChangeSize(clientHeight ? clientHeight -1 : 0) 88 | } 89 | } 90 | 91 | const handlerOnKeyUp = (event) => { 92 | const el = inputRef.current; 93 | if(!el) return true; 94 | // Conditions need for firefox 95 | if(firefox && event.key === 'Backspace') { 96 | if(el.innerHTML.length === 1 && enter) { 97 | el.innerHTML = ''; 98 | setEnter(false); 99 | } 100 | else if(brRegex.test(el.innerHTML)){ 101 | el.innerHTML = el.innerHTML.replace(brRegex, ''); 102 | } 103 | } 104 | checkSize(); 105 | } 106 | 107 | const handlerOnKeyDown= (event) => { 108 | const el = inputRef.current; 109 | 110 | if( event.key === 'Backspace' && el){ 111 | const caretPosition = getCaretIndex(inputRef.current); 112 | const character = el.innerHTML.charAt(caretPosition - 1); 113 | if(character === "\n") { 114 | event.preventDefault(); 115 | event.stopPropagation(); 116 | el.innerHTML = (el.innerHTML.substring(0, caretPosition - 1) + el.innerHTML.substring(caretPosition)) 117 | updateCaret(el, caretPosition, -1) 118 | } 119 | } 120 | } 121 | 122 | const handlerPressEmoji = () => { 123 | onPressEmoji(); 124 | checkSize(); 125 | } 126 | 127 | return ( 128 |
129 | 132 |
136 |
148 | 149 |
150 | 153 |
154 | ); 155 | } 156 | 157 | export default forwardRef(Sender); 158 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/components/Sender/style.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | 3 | .rcw-sender { 4 | align-items: flex-end; 5 | background-color: $grey-2; 6 | border-radius: 0 0 10px 10px; 7 | display: flex; 8 | height: max-content; 9 | max-height: 95px; 10 | min-height: 45px; 11 | overflow: hidden; 12 | padding: 10px; 13 | position: relative; 14 | 15 | &.expand { 16 | height: 55px; 17 | } 18 | } 19 | 20 | .rcw-new-message { 21 | background-color: $white; 22 | border: 0; 23 | border-radius: 5px; 24 | padding: 10px 5px; 25 | resize: none; 26 | width: calc(100% - 75px); 27 | 28 | &:focus { 29 | outline: none; 30 | } 31 | 32 | &.expand { 33 | height: 40px; 34 | } 35 | } 36 | 37 | .rcw-input { 38 | display: block; 39 | height: 100%; 40 | line-height: 20px; 41 | max-height: 78px; 42 | overflow-y: auto; 43 | user-select: text; 44 | white-space: pre-wrap; 45 | word-wrap: break-word; 46 | 47 | &:focus-visible { 48 | outline: none; 49 | } 50 | 51 | &[placeholder]:empty::before { 52 | content: attr(placeholder); 53 | color: $grey-0; 54 | } 55 | } 56 | 57 | .rcw-send, .rcw-picker-btn { 58 | background: $grey-2; 59 | border: 0; 60 | cursor: pointer; 61 | 62 | .rcw-send-icon { 63 | height: 25px; 64 | } 65 | } 66 | 67 | .rcw-message-disable { 68 | background-color: $grey-2; 69 | cursor: not-allowed; 70 | } 71 | 72 | @media screen and (max-width: 800px) { 73 | .rcw-sender { 74 | border-radius: 0; 75 | flex-shrink: 0; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useEffect } from 'react'; 2 | import { Picker } from 'emoji-mart'; 3 | import cn from 'classnames'; 4 | 5 | import Header from './components/Header'; 6 | import Messages from './components/Messages'; 7 | import Sender from './components/Sender'; 8 | import QuickButtons from './components/QuickButtons'; 9 | 10 | import { AnyFunction } from '../../../../utils/types'; 11 | 12 | import './style.scss'; 13 | 14 | interface ISenderRef { 15 | onSelectEmoji: (event: any) => void; 16 | } 17 | 18 | type Props = { 19 | title: string; 20 | subtitle: string; 21 | senderPlaceHolder: string; 22 | showCloseButton: boolean; 23 | disabledInput: boolean; 24 | autofocus: boolean; 25 | className: string; 26 | sendMessage: AnyFunction; 27 | toggleChat: AnyFunction; 28 | profileAvatar?: string; 29 | profileClientAvatar?: string; 30 | titleAvatar?: string; 31 | onQuickButtonClicked?: AnyFunction; 32 | onTextInputChange?: (event: any) => void; 33 | sendButtonAlt: string; 34 | showTimeStamp: boolean; 35 | resizable?: boolean; 36 | emojis?: boolean; 37 | }; 38 | 39 | function Conversation({ 40 | title, 41 | subtitle, 42 | senderPlaceHolder, 43 | showCloseButton, 44 | disabledInput, 45 | autofocus, 46 | className, 47 | sendMessage, 48 | toggleChat, 49 | profileAvatar, 50 | profileClientAvatar, 51 | titleAvatar, 52 | onQuickButtonClicked, 53 | onTextInputChange, 54 | sendButtonAlt, 55 | showTimeStamp, 56 | resizable, 57 | emojis 58 | }: Props) { 59 | const [containerDiv, setContainerDiv] = useState(); 60 | let startX, startWidth; 61 | 62 | useEffect(() => { 63 | const containerDiv = document.getElementById('rcw-conversation-container'); 64 | setContainerDiv(containerDiv); 65 | }, []); 66 | 67 | const initResize = (e) => { 68 | if (resizable) { 69 | startX = e.clientX; 70 | if (document.defaultView && containerDiv){ 71 | startWidth = parseInt(document.defaultView.getComputedStyle(containerDiv).width); 72 | window.addEventListener('mousemove', resize, false); 73 | window.addEventListener('mouseup', stopResize, false); 74 | } 75 | } 76 | } 77 | 78 | const resize = (e) => { 79 | if (containerDiv) { 80 | containerDiv.style.width = (startWidth - e.clientX + startX) + 'px'; 81 | } 82 | } 83 | 84 | const stopResize = (e) => { 85 | window.removeEventListener('mousemove', resize, false); 86 | window.removeEventListener('mouseup', stopResize, false); 87 | } 88 | 89 | const [pickerOffset, setOffset] = useState(0) 90 | const senderRef = useRef(null!); 91 | const [pickerStatus, setPicket] = useState(false) 92 | 93 | const onSelectEmoji = (emoji) => { 94 | senderRef.current?.onSelectEmoji(emoji) 95 | } 96 | 97 | const togglePicker = () => { 98 | setPicket(prevPickerStatus => !prevPickerStatus) 99 | } 100 | 101 | const handlerSendMsn = (event) => { 102 | sendMessage(event) 103 | if(pickerStatus) setPicket(false) 104 | } 105 | 106 | return ( 107 |
109 | {resizable &&
} 110 |
117 | 122 | 123 | {emojis && pickerStatus && ()} 127 | 138 |
139 | ); 140 | } 141 | 142 | export default Conversation; 143 | -------------------------------------------------------------------------------- /src/components/Widget/components/Conversation/style.scss: -------------------------------------------------------------------------------- 1 | @import 'common'; 2 | @import 'variables/colors'; 3 | @import 'animation'; 4 | @import "~emoji-mart/css/emoji-mart.css"; 5 | 6 | .rcw-conversation-container { 7 | border-radius: 10px; 8 | box-shadow: 0px 2px 10px 1px $grey-3; 9 | min-width: 370px; 10 | max-width: 90vw; 11 | position: relative; 12 | 13 | &.active { 14 | opacity: 1; 15 | transform: translateY(0px); 16 | transition: opacity 0.3s ease, transform 0.3s ease; 17 | } 18 | 19 | &.hidden { 20 | z-index: -1; 21 | pointer-events: none; 22 | opacity: 0; 23 | transform: translateY(10px); 24 | transition: opacity 0.3s ease, transform 0.3s ease; 25 | } 26 | } 27 | 28 | .rcw-conversation-resizer { 29 | cursor: col-resize; 30 | height: 100%; 31 | left: 0; 32 | position: absolute; 33 | top: 0; 34 | width: 5px; 35 | } 36 | 37 | .emoji-mart-preview { 38 | display: none; 39 | } 40 | 41 | .rcw-full-screen { 42 | .rcw-conversation-container { 43 | @include conversation-container-fs; 44 | } 45 | } 46 | 47 | @media screen and (max-width: 800px) { 48 | .rcw-conversation-container { 49 | @include conversation-container-fs; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Widget/components/FullScreenPreview/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, ReactNode } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { useSelector, useDispatch } from 'react-redux'; 4 | import usePreview from './usePreview'; 5 | import usePortal from './usePortal'; 6 | import './styles.scss'; 7 | import { GlobalState } from '../../../../store/types'; 8 | import { closeFullscreenPreview } from '../../../../store/actions'; 9 | 10 | const close = require('../../../../../assets/close.svg') as string; 11 | const plus = require('../../../../../assets/plus.svg') as string; 12 | const minus = require('../../../../../assets/minus.svg') as string; 13 | const zoomIn = require('../../../../../assets/zoom-in.svg') as string; 14 | const zoomOut = require('../../../../../assets/zoom-out.svg') as string; 15 | 16 | type Props = { 17 | fullScreenMode?: boolean; 18 | zoomStep?: number 19 | } 20 | 21 | export default function FullScreenPreview({ fullScreenMode, zoomStep }:Props) { 22 | const { 23 | state, 24 | initFileSize, 25 | onZoomIn, 26 | onZoomOut, 27 | onResizePageZoom 28 | } = usePreview(zoomStep); 29 | 30 | const dispatch = useDispatch(); 31 | const { src, alt, width, height, visible } = useSelector((state: GlobalState) => ({ 32 | src: state.preview.src, 33 | alt: state.preview.alt, 34 | width: state.preview.width, 35 | height: state.preview.height, 36 | visible: state.preview.visible 37 | })); 38 | 39 | useEffect(() => { 40 | if(src) { 41 | initFileSize(width, height); 42 | } 43 | }, [src]) 44 | 45 | const pDom = usePortal() 46 | 47 | const onClosePreview = () => { 48 | dispatch(closeFullscreenPreview()) 49 | } 50 | 51 | const childNode: ReactNode = ( 52 |
53 |
54 | {alt} 55 |
56 | 62 |
63 | 73 | 74 | 80 | 86 |
87 |
88 | ) 89 | 90 | return visible ? ReactDOM.createPortal(childNode, pDom) : null; 91 | } 92 | -------------------------------------------------------------------------------- /src/components/Widget/components/FullScreenPreview/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | 3 | .rcw-previewer-container { 4 | width: 100vw; 5 | height: 100vh; 6 | background: rgba(0, 0, 0, 0.75); 7 | overflow: hidden; 8 | position: fixed; 9 | z-index: 9999; 10 | left: 0; 11 | top: 0; 12 | 13 | .rcw-previewer-image { 14 | position: absolute; 15 | top: 0; 16 | left: 0; 17 | right: 0; 18 | bottom: 0; 19 | margin: auto; 20 | transition: all 0.3s ease; 21 | } 22 | 23 | .rcw-previewer-tools { 24 | position: fixed; 25 | right: 16px; 26 | bottom: 16px; 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: center; 30 | align-items: center; 31 | } 32 | 33 | .rcw-previewer-button { 34 | padding: 0; 35 | margin: 16px; 36 | box-shadow: 0 3px 8px 0px rgba(0, 0, 0, 0.3); 37 | border-radius: 50%; 38 | width: 32px; 39 | height: 32px; 40 | display: flex; 41 | align-items: center; 42 | justify-content: center; 43 | outline: none; 44 | background-color: $white; 45 | border: none; 46 | } 47 | 48 | .rcw-previewer-close-button { 49 | position: absolute; 50 | right: 0; 51 | top: 0; 52 | } 53 | 54 | .rcw-previewer-veil { 55 | width: 100%; 56 | height: 100%; 57 | overflow: scroll; 58 | position: relative; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Widget/components/FullScreenPreview/usePortal.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | function createRootElement(id: string):HTMLDivElement { 4 | const rootContainer = document.createElement('div'); 5 | rootContainer.setAttribute('id', id); 6 | return rootContainer; 7 | } 8 | 9 | function addRootElement(rootElem: HTMLDivElement):void { 10 | document.body.appendChild(rootElem); 11 | } 12 | 13 | function usePortal():HTMLDivElement { 14 | const rootElemRef = useRef(null); 15 | 16 | useEffect(() => { 17 | // Look for existing target dom element to append to 18 | const existingParent: HTMLDivElement | null = document.querySelector('#rcw-image-preview'); 19 | // Parent is either a new root or the existing dom element 20 | const parentElem: HTMLDivElement = existingParent || createRootElement('#rcw-image-preview'); 21 | 22 | // If there is no existing DOM element, add a new one. 23 | if (!existingParent) { 24 | addRootElement(parentElem); 25 | } 26 | 27 | // Add the detached element to the parent 28 | if(rootElemRef.current) { 29 | parentElem.appendChild(rootElemRef.current); 30 | } 31 | 32 | return function removeElement() { 33 | if(rootElemRef.current) { 34 | rootElemRef.current.remove(); 35 | } 36 | if (parentElem.childNodes.length === -1) { 37 | parentElem.remove(); 38 | } 39 | }; 40 | }, []); 41 | 42 | function getRootElem():HTMLDivElement { 43 | if (!rootElemRef.current) { 44 | rootElemRef.current = document.createElement('div'); 45 | } 46 | return rootElemRef.current as HTMLDivElement; 47 | } 48 | 49 | return getRootElem(); 50 | } 51 | 52 | export default usePortal; 53 | -------------------------------------------------------------------------------- /src/components/Widget/components/FullScreenPreview/usePreview.ts: -------------------------------------------------------------------------------- 1 | import { useState, useReducer } from 'react'; 2 | 3 | type Layout = { 4 | width?: number; 5 | height?: number; 6 | } 7 | 8 | interface STATE { 9 | layout: Layout; 10 | zoom?: boolean 11 | direction: 'vertical' | 'horizontal' 12 | } 13 | 14 | const initState: STATE = { 15 | layout: { width: 800 }, 16 | zoom: false, 17 | direction: 'vertical' 18 | }; 19 | 20 | const usePreview = (zoomStep) => { 21 | const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); 22 | const [fileSize, setFileSize] = useState({ width: 0, height: 0 }); 23 | 24 | const reducer = (state, action) => { 25 | switch (action.type) { 26 | case 'initLayout': 27 | return { 28 | ...state, 29 | layout: action.payload.layout, 30 | direction: action.payload.direction, 31 | zoom: false 32 | }; 33 | case 'zoomIn': 34 | return { 35 | ...state, 36 | layout: action.layout, 37 | zoom: true 38 | }; 39 | case 'zoomOut': 40 | return { 41 | ...state, 42 | layout: action.layout, 43 | zoom: true 44 | }; 45 | case 'resetZoom': 46 | return { ...state, layout: action.layout, direction: action.direction }; 47 | default: 48 | throw new Error('Unexpected action'); 49 | } 50 | }; 51 | 52 | const [state, dispatch] = useReducer(reducer, { ...initState }); 53 | 54 | const initFileSize = (width: number, height: number):void => { 55 | const { innerWidth, innerHeight } = window; 56 | setWindowSize({ width: innerWidth, height: innerHeight }); 57 | // default size 58 | setFileSize({ width, height }); 59 | 60 | const payload: STATE = { layout: {}, direction: 'horizontal' }; 61 | 62 | /** 63 | * Calculate the display ratio of screen to picture 64 | */ 65 | if(innerWidth / innerHeight <= width / height) { 66 | payload.layout.width = innerWidth * 0.8 67 | payload.direction = 'horizontal' 68 | } else { 69 | payload.layout.height = innerHeight * 0.8 70 | payload.direction = 'vertical' 71 | } 72 | 73 | dispatch({ 74 | type: 'initLayout', 75 | payload 76 | }); 77 | }; 78 | 79 | const getLayout = (step: number): Layout => { 80 | let layout; 81 | if(state.direction === 'vertical') { 82 | layout = { 83 | height: state.layout.height + step 84 | } 85 | } else { 86 | layout = { 87 | width: state.layout.width + step 88 | } 89 | } 90 | return layout 91 | } 92 | 93 | const isMinSize = (): Boolean => { 94 | if(state.direction === 'vertical') { 95 | return state.layout.height > (windowSize.height / 3) 96 | } 97 | return state.layout.width > windowSize.width / 3 98 | } 99 | 100 | const onZoomIn = ():void => { 101 | dispatch({ 102 | type: 'zoomIn', 103 | layout: getLayout(zoomStep) 104 | }); 105 | }; 106 | 107 | 108 | const onZoomOut = ():void => { 109 | if (isMinSize()) { 110 | dispatch({ 111 | type: 'zoomOut', 112 | layout: getLayout(-zoomStep) 113 | }); 114 | } 115 | }; 116 | 117 | const onResizePageZoom = ():void => { 118 | if (state.zoom) { 119 | initFileSize(fileSize.width, fileSize.height) 120 | } 121 | }; 122 | 123 | return { 124 | state, 125 | initFileSize, 126 | onZoomIn, 127 | onZoomOut, 128 | onResizePageZoom 129 | }; 130 | }; 131 | 132 | export default usePreview; 133 | -------------------------------------------------------------------------------- /src/components/Widget/components/Launcher/components/Badge/index.tsx: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | 3 | type Props = { 4 | badge: number 5 | } 6 | 7 | function Badge({ badge }: Props) { 8 | return badge > 0 ? {badge} : null; 9 | } 10 | 11 | export default Badge; 12 | -------------------------------------------------------------------------------- /src/components/Widget/components/Launcher/components/Badge/style.scss: -------------------------------------------------------------------------------- 1 | @import 'variables/colors'; 2 | 3 | .rcw-launcher { 4 | 5 | .rcw-badge{ 6 | position: fixed; 7 | top: -10px; 8 | right: -5px; 9 | background-color: $red; 10 | color: $white; 11 | width: 25px; 12 | height: 25px; 13 | text-align: center; 14 | line-height: 25px; 15 | border-radius: 50%; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Widget/components/Launcher/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector, useDispatch } from 'react-redux'; 2 | import cn from 'classnames'; 3 | 4 | import Badge from './components/Badge'; 5 | import { GlobalState } from '../../../../store/types'; 6 | import { setBadgeCount } from '../../../../store/actions'; 7 | 8 | import './style.scss'; 9 | 10 | const openLauncher = require('../../../../../assets/launcher_button.svg') as string; 11 | const close = require('../../../../../assets/clear-button.svg') as string; 12 | 13 | type Props = { 14 | toggle: () => void; 15 | chatId: string; 16 | openLabel: string; 17 | closeLabel: string; 18 | closeImg: string; 19 | openImg: string; 20 | showBadge?: boolean; 21 | } 22 | 23 | function Launcher({ toggle, chatId, openImg, closeImg, openLabel, closeLabel, showBadge }: Props) { 24 | const dispatch = useDispatch(); 25 | const { showChat, badgeCount } = useSelector((state: GlobalState) => ({ 26 | showChat: state.behavior.showChat, 27 | badgeCount: state.messages.badgeCount 28 | })); 29 | 30 | const toggleChat = () => { 31 | toggle(); 32 | if (!showChat) dispatch(setBadgeCount(0)); 33 | } 34 | 35 | return ( 36 | 43 | ); 44 | } 45 | 46 | export default Launcher; 47 | -------------------------------------------------------------------------------- /src/components/Widget/components/Launcher/style.scss: -------------------------------------------------------------------------------- 1 | @import 'common'; 2 | @import 'variables/colors'; 3 | @import 'animation'; 4 | 5 | .rcw-launcher { 6 | @include animation(0, 0.5s, slide-in); 7 | align-self: flex-end; 8 | background-color: $turqois-1; 9 | border: 0; 10 | border-radius: 50%; 11 | box-shadow: 0px 2px 10px 1px $grey-3; 12 | height: 60px; 13 | margin-top: 10px; 14 | cursor: pointer; 15 | width: 60px; 16 | 17 | &:focus { 18 | outline: none; 19 | } 20 | } 21 | 22 | .rcw-open-launcher { 23 | @include animation(0, 0.5s, rotation-rl); 24 | } 25 | 26 | .rcw-close-launcher { 27 | width: 20px; 28 | @include animation(0, 0.5s, rotation-lr); 29 | } 30 | 31 | @media screen and (max-width: 800px){ 32 | .rcw-launcher { 33 | @include launcher-fs; 34 | } 35 | 36 | .rcw-hide-sm { 37 | display: none; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Widget/components/Launcher/test/index.test.js: -------------------------------------------------------------------------------- 1 | import { configure, mount } from 'enzyme'; 2 | import { Provider } from 'react-redux' 3 | import configureMockStore from 'redux-mock-store' 4 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 5 | 6 | import Launcher from '../index'; 7 | import Badge from '../components/Badge'; 8 | 9 | configure({ adapter: new Adapter() }); 10 | const mockStore = configureMockStore() 11 | 12 | describe('', () => { 13 | const createMessageComponent = ({ toggle, chatOpened, badge = 0 }) => { 14 | const store = mockStore({ 15 | behavior: { showChat: chatOpened }, 16 | messages: { badgeCount: badge } 17 | }); 18 | 19 | return mount( 20 | 21 | 24 | 25 | ); 26 | } 27 | 28 | it('should call toggle prop when clicked', () => { 29 | const toggle = jest.fn(); 30 | const chatOpened = false; 31 | const launcherComponent = createMessageComponent({ toggle, chatOpened }); 32 | launcherComponent.find('.rcw-launcher').simulate('click'); 33 | expect(toggle).toBeCalled(); 34 | }); 35 | 36 | it('should render the open-launcher image when chatOpened = false', () => { 37 | const toggle = jest.fn(); 38 | const chatOpened = false; 39 | const launcherComponent = createMessageComponent({ toggle, chatOpened }); 40 | expect(launcherComponent.find('.rcw-open-launcher')).toHaveLength(1); 41 | }); 42 | 43 | it('should render the close-launcher image when chatOpened = true', () => { 44 | const toggle = jest.fn(); 45 | const chatOpened = true; 46 | const launcherComponent = createMessageComponent({ toggle, chatOpened }); 47 | expect(launcherComponent.find('.rcw-close-launcher')).toHaveLength(1); 48 | }); 49 | 50 | it('should render Badge component when closed and new message is in', () => { 51 | const toggle = jest.fn(); 52 | const chatOpened = false; 53 | const badge = 1; 54 | const launcherComponent = createMessageComponent({ toggle, chatOpened, badge }); 55 | expect(launcherComponent.find(Badge).props().badge).toBe(1); 56 | }) 57 | }); 58 | -------------------------------------------------------------------------------- /src/components/Widget/index.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux'; 2 | 3 | import { toggleChat, addUserMessage } from '../../store/actions'; 4 | import { isWidgetOpened } from '../../store/dispatcher'; 5 | import { AnyFunction } from '../../utils/types'; 6 | 7 | import WidgetLayout from './layout'; 8 | 9 | type Props = { 10 | title: string; 11 | titleAvatar?: string; 12 | subtitle: string; 13 | senderPlaceHolder: string; 14 | profileAvatar?: string; 15 | profileClientAvatar?: string; 16 | showCloseButton: boolean; 17 | fullScreenMode: boolean; 18 | autofocus: boolean; 19 | customLauncher?: AnyFunction; 20 | handleNewUserMessage: AnyFunction; 21 | handleQuickButtonClicked?: AnyFunction; 22 | handleTextInputChange?: (event: any) => void; 23 | chatId: string; 24 | handleToggle?: AnyFunction; 25 | launcherOpenLabel: string; 26 | launcherCloseLabel: string; 27 | launcherOpenImg: string; 28 | launcherCloseImg: string; 29 | sendButtonAlt: string; 30 | showTimeStamp: boolean; 31 | imagePreview?: boolean; 32 | zoomStep?: number; 33 | handleSubmit?: AnyFunction; 34 | showBadge?: boolean; 35 | resizable?: boolean; 36 | emojis?: boolean; 37 | } 38 | 39 | function Widget({ 40 | title, 41 | titleAvatar, 42 | subtitle, 43 | senderPlaceHolder, 44 | profileAvatar, 45 | profileClientAvatar, 46 | showCloseButton, 47 | fullScreenMode, 48 | autofocus, 49 | customLauncher, 50 | handleNewUserMessage, 51 | handleQuickButtonClicked, 52 | handleTextInputChange, 53 | chatId, 54 | handleToggle, 55 | launcherOpenLabel, 56 | launcherCloseLabel, 57 | launcherCloseImg, 58 | launcherOpenImg, 59 | sendButtonAlt, 60 | showTimeStamp, 61 | imagePreview, 62 | zoomStep, 63 | handleSubmit, 64 | showBadge, 65 | resizable, 66 | emojis 67 | }: Props) { 68 | const dispatch = useDispatch(); 69 | 70 | const toggleConversation = () => { 71 | dispatch(toggleChat()); 72 | handleToggle ? handleToggle(isWidgetOpened()) : null; 73 | } 74 | 75 | const handleMessageSubmit = (userInput) => { 76 | if (!userInput.trim()) { 77 | return; 78 | } 79 | 80 | handleSubmit?.(userInput); 81 | dispatch(addUserMessage(userInput)); 82 | handleNewUserMessage(userInput); 83 | } 84 | 85 | const onQuickButtonClicked = (event, value) => { 86 | event.preventDefault(); 87 | handleQuickButtonClicked?.(value) 88 | } 89 | 90 | return ( 91 | 119 | ); 120 | } 121 | 122 | export default Widget; 123 | -------------------------------------------------------------------------------- /src/components/Widget/layout.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import cn from 'classnames'; 4 | 5 | import { GlobalState } from 'src/store/types'; 6 | import { AnyFunction } from 'src/utils/types'; 7 | import { openFullscreenPreview } from '../../store/actions'; 8 | 9 | import Conversation from './components/Conversation'; 10 | import Launcher from './components/Launcher'; 11 | import FullScreenPreview from './components/FullScreenPreview'; 12 | 13 | import './style.scss'; 14 | 15 | type Props = { 16 | title: string; 17 | titleAvatar?: string; 18 | subtitle: string; 19 | onSendMessage: AnyFunction; 20 | onToggleConversation: AnyFunction; 21 | senderPlaceHolder: string; 22 | onQuickButtonClicked: AnyFunction; 23 | profileAvatar?: string; 24 | profileClientAvatar?: string; 25 | showCloseButton: boolean; 26 | fullScreenMode: boolean; 27 | autofocus: boolean; 28 | customLauncher?: AnyFunction; 29 | onTextInputChange?: (event: any) => void; 30 | chatId: string; 31 | launcherOpenLabel: string; 32 | launcherCloseLabel: string; 33 | launcherCloseImg: string; 34 | launcherOpenImg: string; 35 | sendButtonAlt: string; 36 | showTimeStamp: boolean; 37 | imagePreview?: boolean; 38 | zoomStep?: number; 39 | showBadge?: boolean; 40 | resizable?: boolean; 41 | emojis?: boolean 42 | } 43 | 44 | function WidgetLayout({ 45 | title, 46 | titleAvatar, 47 | subtitle, 48 | onSendMessage, 49 | onToggleConversation, 50 | senderPlaceHolder, 51 | onQuickButtonClicked, 52 | profileAvatar, 53 | profileClientAvatar, 54 | showCloseButton, 55 | fullScreenMode, 56 | autofocus, 57 | customLauncher, 58 | onTextInputChange, 59 | chatId, 60 | launcherOpenLabel, 61 | launcherCloseLabel, 62 | launcherCloseImg, 63 | launcherOpenImg, 64 | sendButtonAlt, 65 | showTimeStamp, 66 | imagePreview, 67 | zoomStep, 68 | showBadge, 69 | resizable, 70 | emojis 71 | }: Props) { 72 | const dispatch = useDispatch(); 73 | const { dissableInput, showChat, visible } = useSelector((state: GlobalState) => ({ 74 | showChat: state.behavior.showChat, 75 | dissableInput: state.behavior.disabledInput, 76 | visible: state.preview.visible, 77 | })); 78 | 79 | const messageRef = useRef(null); 80 | 81 | useEffect(() => { 82 | if(showChat) { 83 | messageRef.current = document.getElementById('messages') as HTMLDivElement; 84 | } 85 | return () => { 86 | messageRef.current = null; 87 | } 88 | }, [showChat]) 89 | 90 | const eventHandle = evt => { 91 | if(evt.target && evt.target.className === 'rcw-message-img') { 92 | const { src, alt, naturalWidth, naturalHeight } = (evt.target as HTMLImageElement); 93 | const obj = { 94 | src: src, 95 | alt: alt, 96 | width: naturalWidth, 97 | height: naturalHeight, 98 | }; 99 | dispatch(openFullscreenPreview(obj)) 100 | } 101 | } 102 | 103 | /** 104 | * Previewer needs to prevent body scroll behavior when fullScreenMode is true 105 | */ 106 | useEffect(() => { 107 | const target = messageRef?.current; 108 | if(imagePreview && showChat) { 109 | target?.addEventListener('click', eventHandle, false); 110 | } 111 | 112 | return () => { 113 | target?.removeEventListener('click', eventHandle); 114 | } 115 | }, [imagePreview, showChat]); 116 | 117 | useEffect(() => { 118 | document.body.setAttribute('style', `overflow: ${visible || fullScreenMode ? 'hidden' : 'auto'}`) 119 | }, [fullScreenMode, visible]) 120 | 121 | return ( 122 |
130 | {showChat && 131 | 151 | } 152 | {customLauncher ? 153 | customLauncher(onToggleConversation) : 154 | !fullScreenMode && 155 | 164 | } 165 | { 166 | imagePreview && 167 | } 168 |
169 | ); 170 | } 171 | 172 | export default WidgetLayout; 173 | -------------------------------------------------------------------------------- /src/components/Widget/style.scss: -------------------------------------------------------------------------------- 1 | @import 'animation'; 2 | @import 'common'; 3 | 4 | .rcw-widget-container { 5 | bottom: 0; 6 | display: flex; 7 | flex-direction: column; 8 | margin: 0 20px 20px 0; 9 | position: fixed; 10 | right: 0; 11 | z-index: 9999; 12 | } 13 | 14 | .rcw-full-screen { 15 | @include widget-container-fs; 16 | } 17 | 18 | @media screen and (max-width: 800px) { 19 | .rcw-widget-container { 20 | height: 100%; 21 | @include widget-container-fs; 22 | } 23 | } 24 | 25 | .rcw-previewer .rcw-message-img { 26 | cursor: pointer; 27 | } 28 | 29 | .rcw-close-widget-container { 30 | height: max-content; 31 | width: max-content; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Widget/test/index.test.js: -------------------------------------------------------------------------------- 1 | import { configure, mount } from 'enzyme'; 2 | import { Provider } from 'react-redux' 3 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 4 | 5 | import assetMock from '../../../../mocks/fileMock'; 6 | import { createMockStore } from '../../../utils/store' 7 | import Widget from '../index'; 8 | import WidgetLayout from '../layout'; 9 | 10 | configure({ adapter: new Adapter() }); 11 | 12 | const mockStore = createMockStore() 13 | 14 | describe('', () => { 15 | const profile = assetMock; 16 | const handleUserMessage = jest.fn(); 17 | const newMessageEvent = { 18 | target: { 19 | message: { 20 | value: 'New message' 21 | } 22 | }, 23 | preventDefault() {} 24 | }; 25 | 26 | const widgetComponent = mount( 27 | 28 | 29 | 30 | ) 31 | 32 | it('should render WidgetLayout', () => { 33 | expect(widgetComponent.find(WidgetLayout)).toHaveLength(1); 34 | }); 35 | 36 | it('should prevent events default behavior', () => { 37 | const spyPreventDefault = jest.spyOn(newMessageEvent, 'preventDefault'); 38 | widgetComponent.find(WidgetLayout).prop('onSendMessage')(newMessageEvent) 39 | expect(spyPreventDefault).toHaveBeenCalled() 40 | 41 | }); 42 | 43 | it('should call prop when calling newMessageEvent', () => { 44 | widgetComponent.find(WidgetLayout).prop('onSendMessage')(newMessageEvent); 45 | expect(handleUserMessage).toBeCalled(); 46 | }); 47 | 48 | it('should clear the message input when newMessageEvent', () => { 49 | widgetComponent.find(WidgetLayout).prop('onSendMessage')(newMessageEvent); 50 | expect(newMessageEvent.target.message.value).toBe(''); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MESSAGE_SENDER = { 2 | CLIENT: 'client', 3 | RESPONSE: 'response' 4 | }; 5 | 6 | export const MESSAGES_TYPES = { 7 | TEXT: 'text', 8 | SNIPPET: { 9 | LINK: 'snippet' 10 | }, 11 | CUSTOM_COMPONENT: 'component' 12 | }; 13 | 14 | export const MESSAGE_BOX_SCROLL_DURATION = 400; 15 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'react-redux'; 2 | 3 | import Widget from './components/Widget'; 4 | 5 | import store from './store'; 6 | 7 | import { AnyFunction } from './utils/types'; 8 | 9 | type Props = { 10 | handleNewUserMessage: AnyFunction; 11 | handleQuickButtonClicked?: AnyFunction; 12 | title?: string; 13 | titleAvatar?: string; 14 | subtitle?: string; 15 | senderPlaceHolder?: string; 16 | showCloseButton?: boolean; 17 | fullScreenMode?: boolean; 18 | autofocus?: boolean; 19 | profileAvatar?: string; 20 | profileClientAvatar?: string; 21 | launcher?: AnyFunction; 22 | handleTextInputChange?: (event: any) => void; 23 | chatId?: string; 24 | handleToggle?: AnyFunction; 25 | launcherOpenLabel?: string, 26 | launcherCloseLabel?: string, 27 | launcherCloseImg?: string, 28 | launcherOpenImg?: string, 29 | sendButtonAlt?: string; 30 | showTimeStamp?: boolean; 31 | imagePreview?: boolean; 32 | zoomStep?: number; 33 | emojis?: boolean; 34 | handleSubmit?: AnyFunction; 35 | showBadge?: boolean; 36 | resizable?: boolean; 37 | } & typeof defaultProps; 38 | 39 | function ConnectedWidget({ 40 | title, 41 | titleAvatar, 42 | subtitle, 43 | senderPlaceHolder, 44 | showCloseButton, 45 | fullScreenMode, 46 | autofocus, 47 | profileAvatar, 48 | profileClientAvatar, 49 | launcher, 50 | handleNewUserMessage, 51 | handleQuickButtonClicked, 52 | handleTextInputChange, 53 | chatId, 54 | handleToggle, 55 | launcherOpenLabel, 56 | launcherCloseLabel, 57 | launcherCloseImg, 58 | launcherOpenImg, 59 | sendButtonAlt, 60 | showTimeStamp, 61 | imagePreview, 62 | zoomStep, 63 | handleSubmit, 64 | showBadge, 65 | resizable, 66 | emojis 67 | }: Props) { 68 | return ( 69 | 70 | 99 | 100 | ); 101 | } 102 | 103 | const defaultProps = { 104 | title: 'Welcome', 105 | subtitle: 'This is your chat subtitle', 106 | senderPlaceHolder: 'Type a message...', 107 | showCloseButton: true, 108 | fullScreenMode: false, 109 | autofocus: true, 110 | chatId: 'rcw-chat-container', 111 | launcherOpenLabel: 'Open chat', 112 | launcherCloseLabel: 'Close chat', 113 | launcherOpenImg: '', 114 | launcherCloseImg: '', 115 | sendButtonAlt: 'Send', 116 | showTimeStamp: true, 117 | imagePreview: false, 118 | zoomStep: 80, 119 | showBadge: true, 120 | }; 121 | ConnectedWidget.defaultProps = defaultProps; 122 | 123 | export default ConnectedWidget; 124 | -------------------------------------------------------------------------------- /src/scss/_animation.scss: -------------------------------------------------------------------------------- 1 | @mixin animation ($delay, $duration, $animation) { 2 | -webkit-animation-delay: $delay; 3 | -webkit-animation-duration: $duration; 4 | -webkit-animation-name: $animation; 5 | -webkit-animation-fill-mode: forwards; 6 | 7 | -moz-animation-delay: $delay; 8 | -moz-animation-duration: $duration; 9 | -moz-animation-name: $animation; 10 | -moz-animation-fill-mode: forwards; 11 | 12 | animation-delay: $delay; 13 | animation-duration: $duration; 14 | animation-name: $animation; 15 | animation-fill-mode: forwards; 16 | } 17 | 18 | @mixin keyframes ($animation-name) { 19 | @-webkit-keyframes #{$animation-name} { 20 | @content; 21 | } 22 | 23 | @-moz-keyframes #{$animation-name} { 24 | @content; 25 | } 26 | 27 | @keyframes #{$animation-name} { 28 | @content; 29 | } 30 | } 31 | 32 | @include keyframes(rotation-lr) { 33 | from { 34 | transform: rotate(-90deg); 35 | } 36 | to { 37 | transform: rotate(0); 38 | } 39 | } 40 | 41 | @include keyframes(rotation-rl) { 42 | from { 43 | transform: rotate(90deg); 44 | } 45 | to { 46 | transform: rotate(0); 47 | } 48 | } 49 | 50 | @include keyframes(slide-in) { 51 | from { 52 | opacity: 0; 53 | transform: translateY(10px); 54 | } 55 | to { 56 | opacity: 1; 57 | transform: translateY(0); 58 | } 59 | } 60 | 61 | @include keyframes(slide-out) { 62 | from { 63 | opacity: 1; 64 | transform: translateY(0); 65 | } 66 | to { 67 | opacity: 0; 68 | transform: translateY(10px); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/scss/_common.scss: -------------------------------------------------------------------------------- 1 | @mixin message-bubble($color) { 2 | background-color: $color; 3 | border-radius: 10px; 4 | max-width: 215px; 5 | padding: 15px; 6 | text-align: left; 7 | } 8 | 9 | // Full screen mixins 10 | 11 | @mixin widget-container-fs { 12 | height: 100vh; 13 | margin: 0; 14 | max-width: none; 15 | width: 100%; 16 | } 17 | 18 | @mixin header-fs { 19 | border-radius: 0; 20 | flex-shrink: 0; 21 | position: relative; 22 | } 23 | 24 | @mixin title-fs { 25 | padding: 0 0 15px 0; 26 | } 27 | 28 | @mixin close-button-fs { 29 | background-color: $turqois-1; 30 | border: 0; 31 | display: block; 32 | position: absolute; 33 | right: 10px; 34 | top: 20px; 35 | width: 40px; 36 | } 37 | 38 | @mixin close-fs { 39 | width: 20px; 40 | height: 20px; 41 | } 42 | 43 | @mixin messages-container-fs { 44 | height: 100%; 45 | max-height: none; 46 | } 47 | 48 | @mixin conversation-container-fs { 49 | display: flex; 50 | flex-direction: column; 51 | height: 100%; 52 | } 53 | 54 | @mixin launcher-fs { 55 | bottom: 0; 56 | margin: 20px; 57 | position: fixed; 58 | right: 0; 59 | } 60 | -------------------------------------------------------------------------------- /src/scss/variables/_colors.scss: -------------------------------------------------------------------------------- 1 | $green-1: #35e65d; 2 | $grey-0: #808080; 3 | $grey-1: #cdd8ec; 4 | $grey-2: #f4f7f9; 5 | $grey-3: #b5b5b5; 6 | $turqois-1: #35cce6; 7 | $turqois-2: #a3eaf7; 8 | $white: #fff; 9 | $red: #ff0000; 10 | -------------------------------------------------------------------------------- /src/scss/variables/_sizes.scss: -------------------------------------------------------------------------------- 1 | $fullscreen-break: 800px; 2 | -------------------------------------------------------------------------------- /src/store/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { ElementType } from 'react'; 2 | 3 | import * as actionsTypes from './types'; 4 | import { LinkParams, ImageState } from '../types'; 5 | 6 | export function toggleChat(): actionsTypes.ToggleChat { 7 | return { 8 | type: actionsTypes.TOGGLE_CHAT 9 | }; 10 | } 11 | 12 | export function toggleInputDisabled(): actionsTypes.ToggleInputDisabled { 13 | return { 14 | type: actionsTypes.TOGGLE_INPUT_DISABLED 15 | }; 16 | } 17 | 18 | export function addUserMessage(text: string, id?: string): actionsTypes.AddUserMessage { 19 | return { 20 | type: actionsTypes.ADD_NEW_USER_MESSAGE, 21 | text, 22 | id 23 | }; 24 | } 25 | 26 | export function addResponseMessage(text: string, id?: string): actionsTypes.AddResponseMessage { 27 | return { 28 | type: actionsTypes.ADD_NEW_RESPONSE_MESSAGE, 29 | text, 30 | id 31 | }; 32 | } 33 | 34 | export function toggleMsgLoader(): actionsTypes.ToggleMsgLoader { 35 | return { 36 | type: actionsTypes.TOGGLE_MESSAGE_LOADER 37 | } 38 | } 39 | 40 | export function addLinkSnippet(link: LinkParams, id?: string): actionsTypes.AddLinkSnippet { 41 | return { 42 | type: actionsTypes.ADD_NEW_LINK_SNIPPET, 43 | link, 44 | id 45 | }; 46 | } 47 | 48 | export function renderCustomComponent( 49 | component: ElementType, 50 | props: any, 51 | showAvatar: boolean, 52 | id?: string 53 | ): actionsTypes.RenderCustomComponent { 54 | return { 55 | type: actionsTypes.ADD_COMPONENT_MESSAGE, 56 | component, 57 | props, 58 | showAvatar, 59 | id 60 | }; 61 | } 62 | 63 | export function dropMessages(): actionsTypes.DropMessages { 64 | return { 65 | type: actionsTypes.DROP_MESSAGES 66 | }; 67 | } 68 | 69 | export function hideAvatar(index: number): actionsTypes.HideAvatar { 70 | return { 71 | type: actionsTypes.HIDE_AVATAR, 72 | index 73 | }; 74 | } 75 | 76 | export function setQuickButtons(buttons: Array<{ label: string, value: string | number }>): actionsTypes.SetQuickButtons { 77 | return { 78 | type: actionsTypes.SET_QUICK_BUTTONS, 79 | buttons 80 | } 81 | } 82 | 83 | export function deleteMessages(count: number, id?: string): actionsTypes.DeleteMessages { 84 | return { 85 | type: actionsTypes.DELETE_MESSAGES, 86 | count, 87 | id 88 | } 89 | } 90 | 91 | export function setBadgeCount(count: number): actionsTypes.SetBadgeCount { 92 | return { 93 | type: actionsTypes.SET_BADGE_COUNT, 94 | count 95 | } 96 | } 97 | 98 | export function markAllMessagesRead(): actionsTypes.MarkAllMessagesRead { 99 | return { 100 | type: actionsTypes.MARK_ALL_READ 101 | } 102 | } 103 | 104 | export function openFullscreenPreview(payload: ImageState): actionsTypes.FullscreenPreviewActions { 105 | return { 106 | type: actionsTypes.OPEN_FULLSCREEN_PREVIEW, 107 | payload 108 | }; 109 | } 110 | 111 | export function closeFullscreenPreview(): actionsTypes.FullscreenPreviewActions { 112 | return { 113 | type: actionsTypes.CLOSE_FULLSCREEN_PREVIEW 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/store/actions/types.ts: -------------------------------------------------------------------------------- 1 | import { ElementType } from 'react'; 2 | 3 | import { LinkParams, FullscreenPreviewState } from '../types'; 4 | 5 | export const TOGGLE_CHAT = 'BEHAVIOR/TOGGLE_CHAT'; 6 | export const TOGGLE_INPUT_DISABLED = 'BEHAVIOR/TOGGLE_INPUT_DISABLED'; 7 | export const TOGGLE_MESSAGE_LOADER = 'BEHAVIOR/TOGGLE_MSG_LOADER'; 8 | export const SET_BADGE_COUNT = 'BEHAVIOR/SET_BADGE_COUNT'; 9 | export const ADD_NEW_USER_MESSAGE = 'MESSAGES/ADD_NEW_USER_MESSAGE'; 10 | export const ADD_NEW_RESPONSE_MESSAGE = 'MESSAGES/ADD_NEW_RESPONSE_MESSAGE'; 11 | export const ADD_NEW_LINK_SNIPPET = 'MESSAGES/ADD_NEW_LINK_SNIPPET'; 12 | export const ADD_COMPONENT_MESSAGE = 'MESSAGES/ADD_COMPONENT_MESSAGE'; 13 | export const DROP_MESSAGES = 'MESSAGES/DROP_MESSAGES'; 14 | export const HIDE_AVATAR = 'MESSAGES/HIDE_AVATAR'; 15 | export const DELETE_MESSAGES = 'MESSAGES/DELETE_MESSAGES'; 16 | export const MARK_ALL_READ = 'MESSAGES/MARK_ALL_READ'; 17 | export const SET_QUICK_BUTTONS = 'SET_QUICK_BUTTONS'; 18 | export const OPEN_FULLSCREEN_PREVIEW = 'FULLSCREEN/OPEN_PREVIEW'; 19 | export const CLOSE_FULLSCREEN_PREVIEW = 'FULLSCREEN/CLOSE_PREVIEW'; 20 | 21 | export interface ToggleChat { 22 | type: typeof TOGGLE_CHAT; 23 | } 24 | 25 | export interface ToggleInputDisabled { 26 | type: typeof TOGGLE_INPUT_DISABLED; 27 | } 28 | 29 | export interface AddUserMessage { 30 | type: typeof ADD_NEW_USER_MESSAGE; 31 | text: string; 32 | id?: string; 33 | } 34 | 35 | export interface AddResponseMessage { 36 | type: typeof ADD_NEW_RESPONSE_MESSAGE; 37 | text: string; 38 | id?: string; 39 | } 40 | 41 | export interface ToggleMsgLoader { 42 | type: typeof TOGGLE_MESSAGE_LOADER; 43 | } 44 | 45 | export interface AddLinkSnippet { 46 | type: typeof ADD_NEW_LINK_SNIPPET; 47 | link: LinkParams; 48 | id?: string; 49 | } 50 | 51 | export interface RenderCustomComponent { 52 | type: typeof ADD_COMPONENT_MESSAGE; 53 | component: ElementType; 54 | props: any; 55 | showAvatar: boolean; 56 | id?: string; 57 | } 58 | 59 | export interface DropMessages { 60 | type: typeof DROP_MESSAGES; 61 | } 62 | 63 | export interface HideAvatar { 64 | type: typeof HIDE_AVATAR; 65 | index: number; 66 | } 67 | 68 | export interface DeleteMessages { 69 | type: typeof DELETE_MESSAGES; 70 | count: number; 71 | id?: string; 72 | } 73 | 74 | export interface SetQuickButtons { 75 | type: typeof SET_QUICK_BUTTONS; 76 | buttons: Array<{ label: string, value: string | number }>; 77 | } 78 | 79 | export interface SetBadgeCount { 80 | type: typeof SET_BADGE_COUNT; 81 | count: number; 82 | } 83 | 84 | export interface MarkAllMessagesRead { 85 | type: typeof MARK_ALL_READ; 86 | } 87 | 88 | export type BehaviorActions = ToggleChat | ToggleInputDisabled | ToggleMsgLoader; 89 | 90 | export type MessagesActions = AddUserMessage | AddResponseMessage | AddLinkSnippet | RenderCustomComponent 91 | | DropMessages | HideAvatar | DeleteMessages | MarkAllMessagesRead | SetBadgeCount; 92 | 93 | export type QuickButtonsActions = SetQuickButtons; 94 | 95 | export interface openFullscreenPreview { 96 | type: typeof OPEN_FULLSCREEN_PREVIEW; 97 | payload: FullscreenPreviewState 98 | } 99 | 100 | export interface closeFullscreenPreview { 101 | type: typeof CLOSE_FULLSCREEN_PREVIEW; 102 | } 103 | 104 | export type FullscreenPreviewActions = openFullscreenPreview | closeFullscreenPreview; -------------------------------------------------------------------------------- /src/store/dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { ElementType } from 'react'; 2 | 3 | import store from '.'; 4 | import * as actions from './actions'; 5 | import { LinkParams, ImageState } from './types'; 6 | 7 | export function addUserMessage(text: string, id?: string) { 8 | store.dispatch(actions.addUserMessage(text, id)); 9 | } 10 | 11 | export function addResponseMessage(text: string, id?: string) { 12 | store.dispatch(actions.addResponseMessage(text, id)); 13 | } 14 | 15 | export function addLinkSnippet(link: LinkParams, id?: string) { 16 | store.dispatch(actions.addLinkSnippet(link, id)); 17 | } 18 | 19 | export function toggleMsgLoader() { 20 | store.dispatch(actions.toggleMsgLoader()); 21 | } 22 | 23 | export function renderCustomComponent(component: ElementType, props: any, showAvatar = false, id?: string) { 24 | store.dispatch(actions.renderCustomComponent(component, props, showAvatar, id)); 25 | } 26 | 27 | export function toggleWidget() { 28 | store.dispatch(actions.toggleChat()); 29 | } 30 | 31 | export function toggleInputDisabled() { 32 | store.dispatch(actions.toggleInputDisabled()); 33 | } 34 | 35 | export function dropMessages() { 36 | store.dispatch(actions.dropMessages()); 37 | } 38 | 39 | export function isWidgetOpened(): boolean { 40 | return store.getState().behavior.showChat; 41 | } 42 | 43 | export function setQuickButtons(buttons: Array<{ label: string, value: string | number }>) { 44 | store.dispatch(actions.setQuickButtons(buttons)); 45 | } 46 | 47 | export function deleteMessages(count: number, id?: string) { 48 | store.dispatch(actions.deleteMessages(count, id)); 49 | } 50 | 51 | export function markAllAsRead() { 52 | store.dispatch(actions.markAllMessagesRead()); 53 | } 54 | 55 | export function setBadgeCount(count: number) { 56 | store.dispatch(actions.setBadgeCount(count)); 57 | } 58 | 59 | export function openFullscreenPreview(payload: ImageState) { 60 | store.dispatch(actions.openFullscreenPreview(payload)); 61 | } 62 | 63 | export function closeFullscreenPreview() { 64 | store.dispatch(actions.closeFullscreenPreview()); 65 | } 66 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, compose } from 'redux'; 2 | 3 | import behavior from './reducers/behaviorReducer'; 4 | import messages from './reducers/messagesReducer'; 5 | import quickButtons from './reducers/quickButtonsReducer'; 6 | import preview from './reducers/fullscreenPreviewReducer'; 7 | 8 | declare global { 9 | interface Window { 10 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose; 11 | } 12 | } 13 | 14 | const composeEnhancers = (process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose; 15 | const reducer = combineReducers({ behavior, messages, quickButtons, preview }); 16 | 17 | export default createStore(reducer, composeEnhancers()); 18 | -------------------------------------------------------------------------------- /src/store/reducers/behaviorReducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer } from '../../utils/createReducer'; 2 | import { BehaviorState } from '../types'; 3 | 4 | import { 5 | BehaviorActions, 6 | TOGGLE_CHAT, 7 | TOGGLE_INPUT_DISABLED, 8 | TOGGLE_MESSAGE_LOADER 9 | } from '../actions/types'; 10 | 11 | const initialState = { 12 | showChat: false, 13 | disabledInput: false, 14 | messageLoader: false 15 | }; 16 | 17 | const behaviorReducer = { 18 | [TOGGLE_CHAT]: (state: BehaviorState) => ({ ...state, showChat: !state.showChat}), 19 | 20 | [TOGGLE_INPUT_DISABLED]: (state: BehaviorState) => ({ ...state, disabledInput: !state.disabledInput }), 21 | 22 | [TOGGLE_MESSAGE_LOADER]: (state: BehaviorState) => ({ ...state, messageLoader: !state.messageLoader }) 23 | }; 24 | 25 | export default (state: BehaviorState = initialState, action: BehaviorActions) => createReducer(behaviorReducer, state, action); 26 | -------------------------------------------------------------------------------- /src/store/reducers/fullscreenPreviewReducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer } from '../../utils/createReducer'; 2 | import { FullscreenPreviewState, ImageState } from '../types'; 3 | 4 | import { 5 | OPEN_FULLSCREEN_PREVIEW, 6 | CLOSE_FULLSCREEN_PREVIEW, 7 | FullscreenPreviewActions 8 | } from '../actions/types'; 9 | 10 | const initialState = { 11 | src: '', 12 | alt: '', 13 | width: 0, 14 | height: 0, 15 | visible: false 16 | }; 17 | 18 | const fullscreenPreviewReducer = { 19 | [OPEN_FULLSCREEN_PREVIEW]: (state: FullscreenPreviewState, { payload }) => { 20 | const { src, width, height } = payload 21 | return { ...state, src, width, height, visible: true } 22 | }, 23 | 24 | [CLOSE_FULLSCREEN_PREVIEW]: (state: FullscreenPreviewState) => ({ ...initialState }), 25 | }; 26 | 27 | export default (state: FullscreenPreviewState = initialState, action: FullscreenPreviewActions) => createReducer(fullscreenPreviewReducer, state, action); 28 | -------------------------------------------------------------------------------- /src/store/reducers/messagesReducer.ts: -------------------------------------------------------------------------------- 1 | import { MessagesState } from '../types'; 2 | 3 | import { createReducer } from '../../utils/createReducer'; 4 | import { createNewMessage, createLinkSnippet, createComponentMessage } from '../../utils/messages'; 5 | import { MESSAGE_SENDER } from '../../constants'; 6 | import { 7 | MessagesActions, 8 | ADD_NEW_USER_MESSAGE, 9 | ADD_NEW_RESPONSE_MESSAGE, 10 | ADD_NEW_LINK_SNIPPET, 11 | ADD_COMPONENT_MESSAGE, 12 | DROP_MESSAGES, 13 | HIDE_AVATAR, 14 | DELETE_MESSAGES, 15 | MARK_ALL_READ, 16 | SET_BADGE_COUNT 17 | } from '../actions/types'; 18 | 19 | const initialState = { 20 | messages: [], 21 | badgeCount: 0 22 | }; 23 | 24 | const messagesReducer = { 25 | [ADD_NEW_USER_MESSAGE]: (state: MessagesState, { text, showClientAvatar, id }) => 26 | ({ ...state, messages: [...state.messages, createNewMessage(text, MESSAGE_SENDER.CLIENT, id)]}), 27 | 28 | [ADD_NEW_RESPONSE_MESSAGE]: (state: MessagesState, { text, id }) => 29 | ({ ...state, messages: [...state.messages, createNewMessage(text, MESSAGE_SENDER.RESPONSE, id)], badgeCount: state.badgeCount + 1 }), 30 | 31 | [ADD_NEW_LINK_SNIPPET]: (state: MessagesState, { link, id }) => 32 | ({ ...state, messages: [...state.messages, createLinkSnippet(link, id)] }), 33 | 34 | [ADD_COMPONENT_MESSAGE]: (state: MessagesState, { component, props, showAvatar, id }) => 35 | ({ ...state, messages: [...state.messages, createComponentMessage(component, props, showAvatar, id)] }), 36 | 37 | [DROP_MESSAGES]: (state: MessagesState) => ({ ...state, messages: [] }), 38 | 39 | [HIDE_AVATAR]: (state: MessagesState, { index }) => state.messages[index].showAvatar = false, 40 | 41 | [DELETE_MESSAGES]: (state: MessagesState, { count, id }) => 42 | ({ 43 | ...state, 44 | messages: id 45 | ? state.messages.filter((_, index) => { 46 | const targetMsg = state.messages.findIndex(tMsg => tMsg.customId === id) 47 | return index < targetMsg - count + 1 || index > targetMsg 48 | }) 49 | : state.messages.slice(0, state.messages.length - count) 50 | }), 51 | 52 | [SET_BADGE_COUNT]: (state: MessagesState, { count }) => ({ ...state, badgeCount: count }), 53 | 54 | [MARK_ALL_READ]: (state: MessagesState) => 55 | ({ ...state, messages: state.messages.map(message => ({ ...message, unread: false })), badgeCount: 0 }) 56 | } 57 | 58 | export default (state = initialState, action: MessagesActions) => createReducer(messagesReducer, state, action); 59 | -------------------------------------------------------------------------------- /src/store/reducers/quickButtonsReducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer } from '../../utils/createReducer'; 2 | import { createQuickButton } from '../../utils/messages'; 3 | import { SET_QUICK_BUTTONS, QuickButtonsActions } from '../actions/types'; 4 | import { QuickButtonsState, QuickButtonTypes } from '../types' 5 | 6 | const initialState = { 7 | quickButtons: [] 8 | }; 9 | 10 | const quickButtonsReducer = { 11 | [SET_QUICK_BUTTONS]: (_: QuickButtonsState, { buttons }) => 12 | ({ quickButtons: [...buttons.map((button: QuickButtonTypes) => createQuickButton(button))] }) 13 | } 14 | 15 | export default (state = initialState, action: QuickButtonsActions) => createReducer(quickButtonsReducer, state, action); 16 | -------------------------------------------------------------------------------- /src/store/types.ts: -------------------------------------------------------------------------------- 1 | import { ElementType } from 'react'; 2 | 3 | type BaseMessage = { 4 | type: string; 5 | component: ElementType; 6 | sender: string; 7 | showAvatar: boolean; 8 | timestamp: Date; 9 | unread: boolean; 10 | customId?: string; 11 | props?: any; 12 | } 13 | 14 | export interface MessageTypes extends BaseMessage { 15 | text: string; 16 | }; 17 | 18 | export type QuickButtonTypes = { 19 | label: string; 20 | value: string | number; 21 | component: ElementType; 22 | }; 23 | 24 | export interface Link extends BaseMessage { 25 | title: string; 26 | link: string; 27 | target: string; 28 | }; 29 | 30 | export interface LinkParams { 31 | link: string; 32 | title: string; 33 | target?: string; 34 | } 35 | 36 | export interface CustomCompMessage extends BaseMessage { 37 | props: any; 38 | } 39 | 40 | export interface BehaviorState { 41 | showChat: boolean; 42 | disabledInput: boolean; 43 | messageLoader: boolean; 44 | }; 45 | 46 | export interface MessagesState { 47 | messages: (MessageTypes | Link | CustomCompMessage)[]; 48 | badgeCount: number; 49 | } 50 | 51 | export interface QuickButtonsState { 52 | quickButtons: QuickButtonTypes[]; 53 | } 54 | 55 | export interface ImageState { 56 | src: string; 57 | alt?: string; 58 | width: number; 59 | height: number; 60 | } 61 | 62 | export interface FullscreenPreviewState extends ImageState { 63 | visible?: boolean; 64 | }; 65 | 66 | export interface GlobalState { 67 | messages: MessagesState; 68 | behavior: BehaviorState; 69 | quickButtons: QuickButtonsState; 70 | preview: FullscreenPreviewState; 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/contentEditable.ts: -------------------------------------------------------------------------------- 1 | export const getCaretIndex = (el) => { 2 | let position = 0; 3 | const selection = window.getSelection()!; 4 | if (selection.rangeCount !== 0) { 5 | const range = window.getSelection()!.getRangeAt(0); 6 | const preCaretRange = range.cloneRange(); 7 | preCaretRange.selectNodeContents(el); 8 | preCaretRange.setEnd(range.endContainer, range.endOffset); 9 | position = preCaretRange.toString().length; 10 | } 11 | return position; 12 | } 13 | 14 | export const getSelection = (el) => { 15 | const range = window.getSelection()!.getRangeAt(0); 16 | const preSelectionRange = range.cloneRange(); 17 | preSelectionRange.selectNodeContents(el); 18 | preSelectionRange.setEnd(range.startContainer, range.startOffset); 19 | 20 | const start = preSelectionRange.toString().length; 21 | return { 22 | start: start, 23 | end: start + range.toString().length 24 | } 25 | } 26 | 27 | export const isFirefox = () => navigator.userAgent.search("Firefox") > 0; 28 | 29 | export const updateCaret = (el, caret, offset) => { 30 | const range = document.createRange(); 31 | const selection = window.getSelection()!; 32 | range.setStart(el.childNodes[0], caret+offset); 33 | range.collapse(true); 34 | selection.removeAllRanges(); 35 | selection.addRange(range); 36 | el.focus(); 37 | } 38 | 39 | export const insertNodeAtCaret = (el) => { 40 | const position = getCaretIndex(el) 41 | let characterToEnter = '\n\n'; 42 | let prevChar, char = ''; 43 | if (position > 0) { 44 | prevChar = el.innerHTML.charAt(position - 1); 45 | char = el.innerHTML.charAt(position); 46 | const newLines = el.innerHTML.match(/\n/g); 47 | if( 48 | prevChar === char || 49 | (prevChar === '\n' && char === '') || 50 | (isFirefox() && newLines?.length > 0) 51 | ) { 52 | characterToEnter = '\n'; 53 | } 54 | } 55 | const selection = window.getSelection()!; 56 | const node = document.createTextNode(characterToEnter); 57 | const range = selection.getRangeAt(0); 58 | range.collapse(false); 59 | range.insertNode(node); 60 | const cloneRange = range.cloneRange(); 61 | cloneRange.selectNodeContents(node); 62 | cloneRange.collapse(false); 63 | selection.removeAllRanges(); 64 | selection.addRange(cloneRange); 65 | el.innerHTML = el.innerHTML.replace(/
/g, ''); 66 | updateCaret(el, position, 1); 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/createReducer.ts: -------------------------------------------------------------------------------- 1 | export const createReducer = ( 2 | reducer: { [key: string]: Function }, 3 | state: S, 4 | action: { [key: string]: any } 5 | ) => (reducer[action.type] ? reducer[action.type](state, action) : state); 6 | -------------------------------------------------------------------------------- /src/utils/messages.ts: -------------------------------------------------------------------------------- 1 | import { ElementType } from 'react'; 2 | 3 | import { MessageTypes as MessageI, Link, CustomCompMessage, LinkParams } from '../store/types'; 4 | 5 | import Message from '../components/Widget/components/Conversation/components/Messages/components/Message'; 6 | import Snippet from '../components/Widget/components/Conversation/components/Messages/components/Snippet'; 7 | import QuickButton from '../components/Widget/components/Conversation/components/QuickButtons/components/QuickButton'; 8 | 9 | import { MESSAGES_TYPES, MESSAGE_SENDER, MESSAGE_BOX_SCROLL_DURATION } from '../constants'; 10 | 11 | export function createNewMessage( 12 | text: string, 13 | sender: string, 14 | id?: string, 15 | ): MessageI { 16 | return { 17 | type: MESSAGES_TYPES.TEXT, 18 | component: Message, 19 | text, 20 | sender, 21 | timestamp: new Date(), 22 | showAvatar: true, 23 | customId: id, 24 | unread: sender === MESSAGE_SENDER.RESPONSE 25 | }; 26 | } 27 | 28 | export function createLinkSnippet(link: LinkParams, id?: string) : Link { 29 | return { 30 | type: MESSAGES_TYPES.SNIPPET.LINK, 31 | component: Snippet, 32 | title: link.title, 33 | link: link.link, 34 | target: link.target || '_blank', 35 | sender: MESSAGE_SENDER.RESPONSE, 36 | timestamp: new Date(), 37 | showAvatar: true, 38 | customId: id, 39 | unread: true 40 | }; 41 | } 42 | 43 | export function createComponentMessage(component: ElementType, props: any, showAvatar: boolean, id?: string): CustomCompMessage { 44 | return { 45 | type: MESSAGES_TYPES.CUSTOM_COMPONENT, 46 | component, 47 | props, 48 | sender: MESSAGE_SENDER.RESPONSE, 49 | timestamp: new Date(), 50 | showAvatar, 51 | customId: id, 52 | unread: true 53 | }; 54 | } 55 | 56 | export function createQuickButton(button: { label: string, value: string | number }) { 57 | return { 58 | component: QuickButton, 59 | label: button.label, 60 | value: button.value 61 | }; 62 | } 63 | 64 | // TODO: Clean functions and window use for SSR 65 | 66 | function sinEaseOut(timestamp: any, begining: any, change: any, duration: any) { 67 | return change * ((timestamp = timestamp / duration - 1) * timestamp * timestamp + 1) + begining; 68 | } 69 | 70 | /** 71 | * 72 | * @param {*} target scroll target 73 | * @param {*} scrollStart 74 | * @param {*} scroll scroll distance 75 | */ 76 | function scrollWithSlowMotion(target: any, scrollStart: any, scroll: number) { 77 | const raf = window?.requestAnimationFrame; 78 | let start = 0; 79 | const step = (timestamp) => { 80 | if (!start) { 81 | start = timestamp; 82 | } 83 | let stepScroll = sinEaseOut(timestamp - start, 0, scroll, MESSAGE_BOX_SCROLL_DURATION); 84 | let total = scrollStart + stepScroll; 85 | target.scrollTop = total; 86 | if (total < scrollStart + scroll) { 87 | raf(step); 88 | } 89 | } 90 | raf(step); 91 | } 92 | 93 | export function scrollToBottom(messagesDiv: HTMLDivElement | null) { 94 | if (!messagesDiv) return; 95 | const screenHeight = messagesDiv.clientHeight; 96 | const scrollTop = messagesDiv.scrollTop; 97 | const scrollOffset = messagesDiv.scrollHeight - (scrollTop + screenHeight); 98 | if (scrollOffset) scrollWithSlowMotion(messagesDiv, scrollTop, scrollOffset); 99 | } 100 | -------------------------------------------------------------------------------- /src/utils/store.ts: -------------------------------------------------------------------------------- 1 | import configureMockStore from 'redux-mock-store' 2 | 3 | export const createMockStore = (state = {}) => 4 | configureMockStore()({ 5 | behavior: { showChat: false, disabledInput: false }, 6 | messages: { messages: [], badgeCount: 0 }, 7 | preview: { visible: false }, 8 | ...state 9 | }) 10 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type Nullable = T | null; 2 | 3 | export type AnyFunction = (...args: any[]) => any; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": ".", 5 | "outDir": "./lib/", 6 | "sourceMap": true, 7 | "noImplicitAny": false, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "experimentalDecorators": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "target": "es6", 15 | "jsx": "react-jsx", 16 | "allowJs": true, 17 | "lib": [ 18 | "dom", 19 | "dom.iterable", 20 | "esnext" 21 | ], 22 | "skipLibCheck": true, 23 | "resolveJsonModule": true 24 | }, 25 | "extends": "./tsconfig.paths.json", 26 | "include": ["src"], 27 | "exclude": ["node_modules", "lib"] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "paths": { 5 | "@assets": ["./assets"], 6 | "@actions": ["./src/store/actions"], 7 | "@types": ["./src/store/types"], 8 | "@scss/*": ["scss/*"], 9 | "@constants/*": ["constants/*"], 10 | "@utils/*": ["utils/*"], 11 | "@components/*": ["app/components/*"], 12 | "@screens/*": ["app/screens/*"], 13 | "@config/*": ["config/*"], 14 | "@schema/*": ["schema/*"], 15 | "@tests-mocks": ["./mocks"], 16 | "@messagesComponents": ["./src/components/Widget/components/Conversation/components/Messages/components"], 17 | "@quickButtonsComponents": ["./src/components/Widget/components/Conversation/components/QuickButtons/components"] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const webpack = require('webpack'); 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const autoprefixer = require('autoprefixer'); 7 | 8 | module.exports = { 9 | entry: { 10 | main: path.resolve(__dirname, 'dev/main.tsx'), 11 | vendor: ['react', 'react-dom'] 12 | }, 13 | target: 'web', 14 | mode: 'development', 15 | devServer: { 16 | contentBase: path.resolve(__dirname, 'dist'), 17 | compress: false, 18 | host: '0.0.0.0', 19 | port: 3000, 20 | hot: true 21 | }, 22 | resolve: { 23 | extensions: ['.tsx', '.ts', '.js'] 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.ts(x?)$/, 29 | exclude: /node_modules/, 30 | use: ['babel-loader', 'ts-loader'] 31 | }, 32 | { 33 | enforce: "pre", 34 | test: /\.js$/, 35 | loader: "source-map-loader" 36 | }, 37 | { 38 | test: /\.js$/, 39 | loader: 'babel-loader', 40 | exclude: /node_modules/ 41 | }, 42 | { 43 | test: /\.scss$/, 44 | exclude: /node_modules/, 45 | use: [ 46 | 'style-loader', 47 | 'css-loader', 48 | { 49 | loader: 'postcss-loader', 50 | options: { 51 | postcssOptions: { 52 | plugins: ['postcss-preset-env'] 53 | } 54 | } 55 | }, 56 | { 57 | loader: 'sass-loader', 58 | options: { 59 | implementation: require('node-sass'), 60 | sassOptions: { 61 | includePaths: [path.resolve(__dirname, 'src/scss/')] 62 | } 63 | } 64 | } 65 | ] 66 | }, 67 | { 68 | test: /\.(jpg|png|gif|svg)$/, 69 | type: 'asset/inline' 70 | } 71 | ] 72 | }, 73 | devtool: 'inline-source-map', 74 | plugins: [ 75 | new webpack.HotModuleReplacementPlugin(), 76 | new HtmlWebpackPlugin({ 77 | template: './dev/index.html' 78 | }), 79 | new webpack.ProvidePlugin({ 80 | 'React': 'react' 81 | }) 82 | ], 83 | performance: { 84 | hints: false 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const webpack = require('webpack'); 4 | const path = require('path'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 7 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 8 | 9 | module.exports = { 10 | entry: './index.js', 11 | output: { 12 | path: path.join(__dirname, '/lib'), 13 | filename: 'index.js', 14 | library: 'react-chat-widget', 15 | libraryTarget: 'umd', 16 | clean: true 17 | }, 18 | resolve: { 19 | extensions: ['.tsx', '.ts', '.js'], 20 | alias: { 21 | react: path.resolve(__dirname, './node_modules/react'), 22 | 'react-dom': path.resolve(__dirname, './node_modules/react-dom'), 23 | 'react/jsx-runtime': require.resolve('./node_modules/react/jsx-runtime') 24 | } 25 | }, 26 | target: 'web', 27 | mode: 'production', 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.ts(x?)$/, 32 | exclude: [/node_modules/, /dev/], 33 | use: ['babel-loader', 'ts-loader'] 34 | }, 35 | { 36 | enforce: 'pre', 37 | test: /\.js$/, 38 | loader: 'source-map-loader' 39 | }, 40 | { 41 | test: /\.js$/, 42 | exclude: /node_modules/, 43 | loader: 'babel-loader' 44 | }, 45 | { 46 | test: /\.scss$/, 47 | use: [ 48 | MiniCssExtractPlugin.loader, 49 | 'css-loader', 50 | { 51 | loader: 'postcss-loader', 52 | options: { 53 | postcssOptions: { 54 | plugins: ['postcss-preset-env'] 55 | } 56 | } 57 | }, 58 | { 59 | loader: 'sass-loader', 60 | options: { 61 | implementation: require('node-sass'), 62 | sassOptions: { 63 | includePaths: [path.resolve(__dirname, 'src/scss/')] 64 | } 65 | } 66 | } 67 | ] 68 | }, 69 | { 70 | test: /\.(jpg|png|gif|svg)$/, 71 | type: 'asset/inline' 72 | } 73 | ] 74 | }, 75 | plugins: [ 76 | /** 77 | * Known issue for the CSS Extract Plugin in Ubuntu 16.04: You'll need to install 78 | * the following package: sudo apt-get install libpng16-dev 79 | */ 80 | new MiniCssExtractPlugin({ 81 | filename: 'styles.css', 82 | chunkFilename: '[id].css' 83 | }), 84 | new webpack.ProvidePlugin({ 85 | 'react': 'React' 86 | }) 87 | ], 88 | externals: { 89 | react: { 90 | root: 'React', 91 | commonjs2: 'react', 92 | commonjs: 'react', 93 | amd: 'react' 94 | }, 95 | 'react-dom': { 96 | root: 'ReactDOM', 97 | commonjs2: 'react-dom', 98 | commonjs: 'react-dom', 99 | amd: 'react-dom' 100 | } 101 | }, 102 | optimization: { 103 | minimizer: [ 104 | new UglifyJsPlugin({ 105 | cache: true, 106 | parallel: true 107 | }), 108 | new OptimizeCSSAssetsPlugin({}) 109 | ] 110 | } 111 | }; 112 | --------------------------------------------------------------------------------