├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── README.md ├── client ├── .env ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.png │ ├── index.html │ └── manifest.json └── src │ ├── App.js │ ├── Chat │ ├── Chat.jsx │ ├── ChatBox.jsx │ ├── Conversations.jsx │ └── Users.jsx │ ├── Home │ ├── Home.jsx │ ├── Login.jsx │ └── Register.jsx │ ├── Layout │ ├── Header.jsx │ └── logo.png │ ├── Services │ ├── authenticationService.js │ ├── chatService.js │ └── userService.js │ ├── Utilities │ ├── auth-header.js │ ├── common.js │ ├── handle-response.js │ ├── history.js │ └── private-route.js │ ├── index.css │ ├── index.js │ └── serviceWorker.js ├── config ├── keys.js └── passport.js ├── models ├── Conversation.js ├── GlobalMessage.js ├── Message.js └── User.js ├── package-lock.json ├── package.json ├── routes └── api │ ├── messages.js │ └── users.js ├── server.js ├── utilities └── verify-token.js └── validation ├── login.js └── register.js /.eslintignore: -------------------------------------------------------------------------------- 1 | ./client -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "globals": { 9 | "Atomics": "readonly", 10 | "SharedArrayBuffer": "readonly" 11 | }, 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "jsx": true 15 | }, 16 | "ecmaVersion": 2018, 17 | "sourceType": "module" 18 | }, 19 | "plugins": ["react", "react-hooks"], 20 | "rules": { 21 | "react-hooks/rules-of-hooks": "error", 22 | "no-console": "off" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .prettierrc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MERN: Full-stack Chat Application 2 | 3 | #### Introduction 4 | 5 | The MERN stack which consists of **Mongo DB**, **Express.js**, **Node.js**, and **React.js** is a popular stack for building full-stack web-based applications because of its simplicity and ease of use. In recent years, with the explosive popularity and the growing maturity of the JavaScript ecosystem, the MERN stack has been the goto stack for a large number of web applications. This stack is also highly popular among newcomers to the JS field because of how easy it is to get started with this stack. 6 |

7 | This repo consists of a **Chat Application** built with the MERN stack. I built this sometime back when I was trying to learn the stack and I have left it here for anyone new to the stack so that they can use this repo as a guide. 8 |

9 | This is a full-stack chat application that can be up and running with just a few steps. 10 | Its frontend is built with [Material UI](https://material-ui.com/) running on top of React. 11 | The backend is built with Express.js and Node.js. 12 | Real-time message broadcasting is developed using [Socket.IO](https://socket.io/). 13 | 14 | ### Features 15 | 16 | This application provides users with the following features 17 |
18 | * Authentication using **JWT Tokens** 19 | * A **Global Chat** which can be used by anyone using the application to broadcast messages to everyone else. 20 | * A **Private Chat** functionality where users can chat with other users privately. 21 | * Real-time updates to the user list, conversation list, and conversation messages 22 | 23 | #### Screenshots 24 | 25 | ##### Global Chat 26 | ![Global Chat](https://i.imgur.com/VkdwAme.png) 27 |

28 | ##### Private Chat 29 | ![Private Chat](https://i.imgur.com/jdCBYu4.png) 30 |

31 | ##### Login 32 | ![Login](https://i.imgur.com/6iobucn.png) 33 |

34 | ##### Register 35 | ![Register](https://i.imgur.com/AMkpl9C.png) 36 | 37 | ### How to use 38 | 39 | You can have this application up and running with just a few steps because it has both the frontend and the backend in a single repository. Follow the steps below to do so. 40 | 41 | 1. Clone this repo 42 | 2. Once you have the repo, you need to install its dependencies. So using a terminal, move into the root directory of the project and execute `npm install` to install the dependencies of the Node.js server and then run `npm run client-install` to install the dependencies of the frontend. The second command is a custom command that I wrote to simplify the installation process. 43 | 3. This application uses MongoDB as its Database. So make sure you have it installed. You can find detailed guides on how to do so [here](https://docs.mongodb.com/manual/administration/install-community/). Once installed, make sure that your local MongoDB server is not protected by any kind of authentication. If there is authentication involved, make sure you edit the `mongoURI` in the `config/keys.js` file. 44 | 4. Finally, all you have to do is simply run `npm run dev`. If this command fails, try installing the package [concurrently](https://www.npmjs.com/package/concurrently) globally by running `npm install -g concurrently` and then running the `dev` command. 45 | 5. The frontend of the application will be automatically opened in your web browser and you can test it away. 46 | 47 | 48 | ### Things to note 49 | 50 | * The frontend is created using [create-react-app](https://github.com/facebook/create-react-app) 51 | * Database connections in the backend are handled using the [Mongoose ORM](https://mongoosejs.com/) 52 | * Code quality is ensured using (ESLint)[https://eslint.org/] 53 | 54 | ### Disclaimer 55 | 56 | This repository contains beginner level code and might contain some things I wish to change or remove. I have not maintained this for quite some time, but now I am trying to slowly fix these issues. You are welcome to open issues if you find any and I will accept PR's as well. 57 |

58 | 59 | Cheers 💻 🍺 🔥 🙌 60 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL = http://localhost:5000 -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.11.0", 7 | "@material-ui/icons": "^4.9.1", 8 | "@material-ui/styles": "^4.10.0", 9 | "classnames": "^2.2.6", 10 | "formik": "^2.2.0", 11 | "history": "^4.10.1", 12 | "notistack": "^0.8.9", 13 | "react": "^16.13.1", 14 | "react-dom": "^16.13.1", 15 | "react-router-dom": "^5.2.0", 16 | "react-scripts": "^3.4.3", 17 | "rxjs": "^6.6.3", 18 | "socket.io-client": "^2.3.1", 19 | "yup": "^0.27.0" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": "react-app" 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davehowson/chat-app/7e1cc2bf2b716c75f13a21a8182670377493a1f9/client/public/favicon.png -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | Chat App 23 | 24 | 25 | 26 |
27 | 37 | 38 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Route } from 'react-router-dom'; 3 | import { createMuiTheme } from '@material-ui/core/styles'; 4 | import CssBaseline from '@material-ui/core/CssBaseline'; 5 | import { ThemeProvider } from '@material-ui/styles'; 6 | import { SnackbarProvider } from 'notistack'; 7 | 8 | import history from './Utilities/history'; 9 | import PrivateRoute from './Utilities/private-route'; 10 | import Home from './Home/Home'; 11 | import Chat from './Chat/Chat'; 12 | 13 | const theme = createMuiTheme({ 14 | palette: { 15 | primary: { 16 | light: '#58a5f0', 17 | main: '#0277bd', 18 | dark: '#004c8c', 19 | }, 20 | secondary: { 21 | light: '#ffd95a', 22 | main: '#f9a825', 23 | dark: '#c17900', 24 | contrastText: '#212121', 25 | }, 26 | background: { 27 | default: '#f0f0f0', 28 | }, 29 | }, 30 | typography: { 31 | useNextVariants: true, 32 | }, 33 | }); 34 | 35 | function App() { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | } 48 | 49 | export default App; 50 | -------------------------------------------------------------------------------- /client/src/Chat/Chat.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import Paper from '@material-ui/core/Paper'; 5 | import List from '@material-ui/core/List'; 6 | import Tabs from '@material-ui/core/Tabs'; 7 | import Tab from '@material-ui/core/Tab'; 8 | 9 | import Header from '../Layout/Header'; 10 | import ChatBox from './ChatBox'; 11 | import Conversations from './Conversations'; 12 | import Users from './Users'; 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | paper: { 16 | minHeight: 'calc(100vh - 64px)', 17 | borderRadius: 0, 18 | }, 19 | sidebar: { 20 | zIndex: 8, 21 | }, 22 | subheader: { 23 | display: 'flex', 24 | alignItems: 'center', 25 | cursor: 'pointer', 26 | }, 27 | globe: { 28 | backgroundColor: theme.palette.primary.dark, 29 | }, 30 | subheaderText: { 31 | color: theme.palette.primary.dark, 32 | }, 33 | })); 34 | 35 | const Chat = () => { 36 | const [scope, setScope] = useState('Global Chat'); 37 | const [tab, setTab] = useState(0); 38 | const [user, setUser] = useState(null); 39 | const classes = useStyles(); 40 | 41 | const handleChange = (e, newVal) => { 42 | setTab(newVal); 43 | }; 44 | 45 | return ( 46 | 47 |
48 | 49 | 50 | 51 | 52 | 59 | 60 | 61 | 62 | 63 | {tab === 0 && ( 64 | 68 | )} 69 | {tab === 1 && ( 70 | 71 | )} 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default Chat; 83 | -------------------------------------------------------------------------------- /client/src/Chat/ChatBox.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import { makeStyles } from "@material-ui/core/styles"; 3 | import Grid from "@material-ui/core/Grid"; 4 | import Typography from "@material-ui/core/Typography"; 5 | import TextField from "@material-ui/core/TextField"; 6 | import IconButton from "@material-ui/core/IconButton"; 7 | import SendIcon from "@material-ui/icons/Send"; 8 | import List from "@material-ui/core/List"; 9 | import ListItem from "@material-ui/core/ListItem"; 10 | import ListItemText from "@material-ui/core/ListItemText"; 11 | import ListItemAvatar from "@material-ui/core/ListItemAvatar"; 12 | import Avatar from "@material-ui/core/Avatar"; 13 | import Paper from "@material-ui/core/Paper"; 14 | import socketIOClient from "socket.io-client"; 15 | import classnames from "classnames"; 16 | import commonUtilites from "../Utilities/common"; 17 | import { 18 | useGetGlobalMessages, 19 | useSendGlobalMessage, 20 | useGetConversationMessages, 21 | useSendConversationMessage, 22 | } from "../Services/chatService"; 23 | import { authenticationService } from "../Services/authenticationService"; 24 | 25 | const useStyles = makeStyles((theme) => ({ 26 | root: { 27 | height: "100%", 28 | }, 29 | headerRow: { 30 | maxHeight: 60, 31 | zIndex: 5, 32 | }, 33 | paper: { 34 | display: "flex", 35 | alignItems: "center", 36 | justifyContent: "center", 37 | height: "100%", 38 | color: theme.palette.primary.dark, 39 | }, 40 | messageContainer: { 41 | height: "100%", 42 | display: "flex", 43 | alignContent: "flex-end", 44 | }, 45 | messagesRow: { 46 | maxHeight: "calc(100vh - 184px)", 47 | overflowY: "auto", 48 | }, 49 | newMessageRow: { 50 | width: "100%", 51 | padding: theme.spacing(0, 2, 1), 52 | }, 53 | messageBubble: { 54 | padding: 10, 55 | border: "1px solid white", 56 | backgroundColor: "white", 57 | borderRadius: "0 10px 10px 10px", 58 | boxShadow: "-3px 4px 4px 0px rgba(0,0,0,0.08)", 59 | marginTop: 8, 60 | maxWidth: "40em", 61 | }, 62 | messageBubbleRight: { 63 | borderRadius: "10px 0 10px 10px", 64 | }, 65 | inputRow: { 66 | display: "flex", 67 | alignItems: "flex-end", 68 | }, 69 | form: { 70 | width: "100%", 71 | }, 72 | avatar: { 73 | margin: theme.spacing(1, 1.5), 74 | }, 75 | listItem: { 76 | display: "flex", 77 | width: "100%", 78 | }, 79 | listItemRight: { 80 | flexDirection: "row-reverse", 81 | }, 82 | })); 83 | 84 | const ChatBox = (props) => { 85 | const [currentUserId] = useState( 86 | authenticationService.currentUserValue.userId 87 | ); 88 | const [newMessage, setNewMessage] = useState(""); 89 | const [messages, setMessages] = useState([]); 90 | const [lastMessage, setLastMessage] = useState(null); 91 | 92 | const getGlobalMessages = useGetGlobalMessages(); 93 | const sendGlobalMessage = useSendGlobalMessage(); 94 | const getConversationMessages = useGetConversationMessages(); 95 | const sendConversationMessage = useSendConversationMessage(); 96 | 97 | let chatBottom = useRef(null); 98 | const classes = useStyles(); 99 | 100 | useEffect(() => { 101 | reloadMessages(); 102 | scrollToBottom(); 103 | }, [lastMessage, props.scope, props.conversationId]); 104 | 105 | useEffect(() => { 106 | const socket = socketIOClient(process.env.REACT_APP_API_URL); 107 | socket.on("messages", (data) => setLastMessage(data)); 108 | }, []); 109 | 110 | const reloadMessages = () => { 111 | if (props.scope === "Global Chat") { 112 | getGlobalMessages().then((res) => { 113 | setMessages(res); 114 | }); 115 | } else if (props.scope !== null && props.conversationId !== null) { 116 | getConversationMessages(props.user._id).then((res) => setMessages(res)); 117 | } else { 118 | setMessages([]); 119 | } 120 | }; 121 | 122 | const scrollToBottom = () => { 123 | chatBottom.current.scrollIntoView({ behavior: "smooth" }); 124 | }; 125 | 126 | useEffect(scrollToBottom, [messages]); 127 | 128 | const handleSubmit = (e) => { 129 | e.preventDefault(); 130 | if (props.scope === "Global Chat") { 131 | sendGlobalMessage(newMessage).then(() => { 132 | setNewMessage(""); 133 | }); 134 | } else { 135 | sendConversationMessage(props.user._id, newMessage).then((res) => { 136 | setNewMessage(""); 137 | }); 138 | } 139 | }; 140 | 141 | return ( 142 | 143 | 144 | 145 | 146 | {props.scope} 147 | 148 | 149 | 150 | 151 | 152 | 153 | {messages && ( 154 | 155 | {messages.map((m) => ( 156 | 164 | 165 | 166 | {commonUtilites.getInitialsFromName(m.fromObj[0].name)} 167 | 168 | 169 | {m.body}} 178 | /> 179 | 180 | ))} 181 | 182 | )} 183 |
184 | 185 | 186 |
187 | 192 | 193 | setNewMessage(e.target.value)} 201 | /> 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 |
210 |
211 | 212 | 213 | 214 | ); 215 | }; 216 | 217 | export default ChatBox; 218 | -------------------------------------------------------------------------------- /client/src/Chat/Conversations.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import List from "@material-ui/core/List"; 3 | import ListItem from "@material-ui/core/ListItem"; 4 | import ListItemText from "@material-ui/core/ListItemText"; 5 | import ListItemAvatar from "@material-ui/core/ListItemAvatar"; 6 | import Avatar from "@material-ui/core/Avatar"; 7 | import LanguageIcon from "@material-ui/icons/Language"; 8 | import Divider from "@material-ui/core/Divider"; 9 | import { makeStyles } from "@material-ui/core/styles"; 10 | import socketIOClient from "socket.io-client"; 11 | 12 | import { useGetConversations } from "../Services/chatService"; 13 | import { authenticationService } from "../Services/authenticationService"; 14 | import commonUtilites from "../Utilities/common"; 15 | 16 | const useStyles = makeStyles((theme) => ({ 17 | subheader: { 18 | display: "flex", 19 | alignItems: "center", 20 | cursor: "pointer", 21 | }, 22 | globe: { 23 | backgroundColor: theme.palette.primary.dark, 24 | }, 25 | subheaderText: { 26 | color: theme.palette.primary.dark, 27 | }, 28 | list: { 29 | maxHeight: "calc(100vh - 112px)", 30 | overflowY: "auto", 31 | }, 32 | })); 33 | 34 | const Conversations = (props) => { 35 | const classes = useStyles(); 36 | const [conversations, setConversations] = useState([]); 37 | const [newConversation, setNewConversation] = useState(null); 38 | const getConversations = useGetConversations(); 39 | 40 | // Returns the recipient name that does not 41 | // belong to the current user. 42 | const handleRecipient = (recipients) => { 43 | for (let i = 0; i < recipients.length; i++) { 44 | if ( 45 | recipients[i].username !== 46 | authenticationService.currentUserValue.username 47 | ) { 48 | return recipients[i]; 49 | } 50 | } 51 | return null; 52 | }; 53 | 54 | useEffect(() => { 55 | getConversations().then((res) => setConversations(res)); 56 | }, [newConversation]); 57 | 58 | useEffect(() => { 59 | let socket = socketIOClient(process.env.REACT_APP_API_URL); 60 | socket.on("messages", (data) => setNewConversation(data)); 61 | 62 | return () => { 63 | socket.removeListener("messages"); 64 | }; 65 | }, []); 66 | 67 | return ( 68 | 69 | { 72 | props.setScope("Global Chat"); 73 | }} 74 | > 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | {conversations && ( 85 | 86 | {conversations.map((c) => ( 87 | { 92 | props.setUser(handleRecipient(c.recipientObj)); 93 | props.setScope(handleRecipient(c.recipientObj).name); 94 | }} 95 | > 96 | 97 | 98 | {commonUtilites.getInitialsFromName( 99 | handleRecipient(c.recipientObj).name 100 | )} 101 | 102 | 103 | {c.lastMessage}} 106 | /> 107 | 108 | ))} 109 | 110 | )} 111 | 112 | ); 113 | }; 114 | 115 | export default Conversations; 116 | -------------------------------------------------------------------------------- /client/src/Chat/Users.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import List from "@material-ui/core/List"; 3 | import ListItem from "@material-ui/core/ListItem"; 4 | import ListItemText from "@material-ui/core/ListItemText"; 5 | import ListItemAvatar from "@material-ui/core/ListItemAvatar"; 6 | import Avatar from "@material-ui/core/Avatar"; 7 | import { makeStyles } from "@material-ui/core/styles"; 8 | import socketIOClient from "socket.io-client"; 9 | 10 | import { useGetUsers } from "../Services/userService"; 11 | import commonUtilites from "../Utilities/common"; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | subheader: { 15 | display: "flex", 16 | alignItems: "center", 17 | cursor: "pointer", 18 | }, 19 | globe: { 20 | backgroundColor: theme.palette.primary.dark, 21 | }, 22 | subheaderText: { 23 | color: theme.palette.primary.dark, 24 | }, 25 | list: { 26 | maxHeight: "calc(100vh - 112px)", 27 | overflowY: "auto", 28 | }, 29 | avatar: { 30 | margin: theme.spacing(0, 3, 0, 1), 31 | }, 32 | })); 33 | 34 | const Users = (props) => { 35 | const classes = useStyles(); 36 | const [users, setUsers] = useState([]); 37 | const [newUser, setNewUser] = useState(null); 38 | const getUsers = useGetUsers(); 39 | 40 | useEffect(() => { 41 | getUsers().then((res) => setUsers(res)); 42 | }, [newUser]); 43 | 44 | useEffect(() => { 45 | const socket = socketIOClient(process.env.REACT_APP_API_URL); 46 | socket.on("users", (data) => { 47 | setNewUser(data); 48 | }); 49 | }, []); 50 | 51 | return ( 52 | 53 | {users && ( 54 | 55 | {users.map((u) => ( 56 | { 60 | props.setUser(u); 61 | props.setScope(u.name); 62 | }} 63 | button 64 | > 65 | 66 | {commonUtilites.getInitialsFromName(u.name)} 67 | 68 | 69 | 70 | ))} 71 | 72 | )} 73 | 74 | ); 75 | }; 76 | 77 | export default Users; 78 | -------------------------------------------------------------------------------- /client/src/Home/Home.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Container from '@material-ui/core/Container'; 3 | 4 | import history from '../Utilities/history'; 5 | import Login from './Login'; 6 | import Register from './Register'; 7 | import { authenticationService } from '../Services/authenticationService'; 8 | 9 | const Home = () => { 10 | const [page, setPage] = useState('login'); 11 | 12 | useEffect(() => { 13 | if (authenticationService.currentUserValue) { 14 | history.push('/chat'); 15 | } 16 | }, []); 17 | 18 | const handleClick = location => { 19 | setPage(location); 20 | }; 21 | 22 | let Content; 23 | 24 | if (page === 'login') { 25 | Content = ; 26 | } else { 27 | Content = ; 28 | } 29 | 30 | return ( 31 | 32 | {Content} 33 | 34 | ); 35 | }; 36 | 37 | export default Home; 38 | -------------------------------------------------------------------------------- /client/src/Home/Login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import { Formik } from 'formik'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import Link from '@material-ui/core/Link'; 6 | import TextField from '@material-ui/core/TextField'; 7 | import Typography from '@material-ui/core/Typography'; 8 | import { makeStyles } from '@material-ui/core/styles'; 9 | import * as Yup from 'yup'; 10 | 11 | import history from '../Utilities/history'; 12 | import { useLogin } from '../Services/authenticationService'; 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | paper: { 16 | marginTop: theme.spacing(8), 17 | display: 'flex', 18 | flexDirection: 'column', 19 | alignItems: 'center', 20 | }, 21 | form: { 22 | width: '100%', 23 | marginTop: theme.spacing(1), 24 | }, 25 | submit: { 26 | margin: theme.spacing(3, 0, 2), 27 | }, 28 | })); 29 | 30 | const Login = props => { 31 | const login = useLogin(); 32 | const classes = useStyles(); 33 | 34 | return ( 35 |
36 | 37 | 38 | 39 | Sign in 40 | 41 | { 59 | setStatus(); 60 | login(username, password).then( 61 | () => { 62 | const { from } = history.location.state || { 63 | from: { pathname: '/chat' }, 64 | }; 65 | history.push(from); 66 | }, 67 | error => { 68 | setSubmitting(false); 69 | setStatus(error); 70 | } 71 | ); 72 | }} 73 | > 74 | {({ 75 | handleSubmit, 76 | handleChange, 77 | values, 78 | touched, 79 | errors, 80 | }) => ( 81 |
85 | 104 | 124 | 133 | 134 | )} 135 |
136 |
137 | 138 | 139 | props.handleClick('register')} 141 | href="#" 142 | > 143 | Don't have an account? 144 | 145 | 146 | 147 |
148 |
149 | ); 150 | }; 151 | 152 | export default Login; 153 | -------------------------------------------------------------------------------- /client/src/Home/Register.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles } from '@material-ui/styles'; 3 | import Button from '@material-ui/core/Button'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import Link from '@material-ui/core/Link'; 7 | import TextField from '@material-ui/core/TextField'; 8 | import { Formik } from 'formik'; 9 | import * as Yup from 'yup'; 10 | 11 | import history from '../Utilities/history'; 12 | import { useRegister } from '../Services/authenticationService'; 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | paper: { 16 | marginTop: theme.spacing(8), 17 | display: 'flex', 18 | flexDirection: 'column', 19 | alignItems: 'center', 20 | }, 21 | form: { 22 | width: '100%', 23 | marginTop: theme.spacing(1), 24 | }, 25 | submit: { 26 | margin: theme.spacing(3, 0, 2), 27 | }, 28 | })); 29 | 30 | const Register = props => { 31 | const register = useRegister(); 32 | 33 | const classes = useStyles(); 34 | 35 | return ( 36 |
37 | 38 | 39 | 40 | Register 41 | 42 | { 72 | setStatus(); 73 | register(name, username, password, password2).then( 74 | user => { 75 | const { from } = history.location.state || { 76 | from: { pathname: '/chat' }, 77 | }; 78 | history.push(from); 79 | }, 80 | error => { 81 | setSubmitting(false); 82 | setStatus(error); 83 | } 84 | ); 85 | }} 86 | validateOnChange={false} 87 | validateOnBlur={false} 88 | > 89 | {({ 90 | handleSubmit, 91 | handleChange, 92 | values, 93 | touched, 94 | isValid, 95 | errors, 96 | }) => ( 97 |
101 | 115 | 116 | 135 | 136 | 156 | 157 | 179 | 180 | 189 | 190 | )} 191 |
192 |
193 | 194 | 195 | props.handleClick('login')} 197 | href="#" 198 | > 199 | Already have an account? 200 | 201 | 202 | 203 |
204 |
205 | ); 206 | }; 207 | 208 | export default Register; 209 | -------------------------------------------------------------------------------- /client/src/Layout/Header.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import AppBar from '@material-ui/core/AppBar'; 4 | import Toolbar from '@material-ui/core/Toolbar'; 5 | import Button from '@material-ui/core/Button'; 6 | import Menu from '@material-ui/core/Menu'; 7 | import MenuItem from '@material-ui/core/MenuItem'; 8 | import Link from '@material-ui/core/Link'; 9 | import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; 10 | import ArrowDropUpIcon from '@material-ui/icons/ArrowDropUp'; 11 | 12 | import { authenticationService } from '../Services/authenticationService'; 13 | import history from '../Utilities/history'; 14 | import logo from './logo.png'; 15 | 16 | const useStyles = makeStyles(theme => ({ 17 | root: { 18 | flexGrow: 1, 19 | }, 20 | title: { 21 | flexGrow: 1, 22 | display: 'flex', 23 | }, 24 | userDropdown: { 25 | marginLeft: theme.spacing(2), 26 | padding: theme.spacing(1), 27 | [theme.breakpoints.down('xs')]: { 28 | marginLeft: 'auto', 29 | }, 30 | }, 31 | })); 32 | 33 | const Header = () => { 34 | const [currentUser] = useState(authenticationService.currentUserValue); 35 | const [anchorEl, setAnchorEl] = useState(null); 36 | const [dropdownOpen, setDropdownOpen] = useState(false); 37 | 38 | const handleDropClose = () => { 39 | setDropdownOpen(false); 40 | setAnchorEl(null); 41 | }; 42 | 43 | const handleDropOpen = event => { 44 | setDropdownOpen(true); 45 | setAnchorEl(event.currentTarget); 46 | }; 47 | 48 | const handleLogout = () => { 49 | authenticationService.logout(); 50 | history.push('/'); 51 | }; 52 | 53 | const arrowIcon = () => { 54 | if (dropdownOpen) { 55 | return ; 56 | } 57 | return ; 58 | }; 59 | 60 | const classes = useStyles(); 61 | 62 | return ( 63 |
64 | 65 | 66 | 67 | Logo 68 | 69 | 79 | 94 | Logout 95 | 96 | 97 | 98 |
99 | ); 100 | }; 101 | 102 | export default Header; 103 | -------------------------------------------------------------------------------- /client/src/Layout/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davehowson/chat-app/7e1cc2bf2b716c75f13a21a8182670377493a1f9/client/src/Layout/logo.png -------------------------------------------------------------------------------- /client/src/Services/authenticationService.js: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs'; 2 | import { useSnackbar } from 'notistack'; 3 | 4 | import useHandleResponse from '../Utilities/handle-response'; 5 | 6 | const currentUserSubject = new BehaviorSubject( 7 | JSON.parse(localStorage.getItem('currentUser')) 8 | ); 9 | 10 | export const authenticationService = { 11 | logout, 12 | currentUser: currentUserSubject.asObservable(), 13 | get currentUserValue() { 14 | return currentUserSubject.value; 15 | }, 16 | }; 17 | 18 | export function useLogin() { 19 | const { enqueueSnackbar } = useSnackbar(); 20 | const handleResponse = useHandleResponse(); 21 | 22 | const login = (username, password) => { 23 | const requestOptions = { 24 | method: 'POST', 25 | headers: { 'Content-Type': 'application/json' }, 26 | body: JSON.stringify({ username, password }), 27 | }; 28 | 29 | return fetch( 30 | `${process.env.REACT_APP_API_URL}/api/users/login`, 31 | requestOptions 32 | ) 33 | .then(handleResponse) 34 | .then(user => { 35 | localStorage.setItem('currentUser', JSON.stringify(user)); 36 | currentUserSubject.next(user); 37 | return user; 38 | }) 39 | .catch(function() { 40 | enqueueSnackbar('Failed to Login', { 41 | variant: 'error', 42 | }); 43 | }); 44 | }; 45 | 46 | return login; 47 | } 48 | 49 | export function useRegister() { 50 | const { enqueueSnackbar } = useSnackbar(); 51 | const handleResponse = useHandleResponse(); 52 | 53 | const register = (name, username, password, password2) => { 54 | const requestOptions = { 55 | method: 'POST', 56 | headers: { 'Content-Type': 'application/json' }, 57 | body: JSON.stringify({ name, username, password, password2 }), 58 | }; 59 | 60 | return fetch( 61 | `${process.env.REACT_APP_API_URL}/api/users/register`, 62 | requestOptions 63 | ) 64 | .then(handleResponse) 65 | .then(user => { 66 | localStorage.setItem('currentUser', JSON.stringify(user)); 67 | currentUserSubject.next(user); 68 | 69 | return user; 70 | }) 71 | .catch(function(response) { 72 | if (response) { 73 | enqueueSnackbar(response, { 74 | variant: 'error', 75 | }); 76 | } else { 77 | enqueueSnackbar('Failed to Register', { 78 | variant: 'error', 79 | }); 80 | } 81 | }); 82 | }; 83 | 84 | return register; 85 | } 86 | 87 | function logout() { 88 | localStorage.removeItem('currentUser'); 89 | currentUserSubject.next(null); 90 | } 91 | -------------------------------------------------------------------------------- /client/src/Services/chatService.js: -------------------------------------------------------------------------------- 1 | import useHandleResponse from '../Utilities/handle-response'; 2 | import authHeader from '../Utilities/auth-header'; 3 | import { useSnackbar } from 'notistack'; 4 | 5 | // Receive global messages 6 | export function useGetGlobalMessages() { 7 | const { enqueueSnackbar } = useSnackbar(); 8 | const handleResponse = useHandleResponse(); 9 | const requestOptions = { 10 | method: 'GET', 11 | headers: authHeader(), 12 | }; 13 | 14 | const getGlobalMessages = () => { 15 | return fetch( 16 | `${process.env.REACT_APP_API_URL}/api/messages/global`, 17 | requestOptions 18 | ) 19 | .then(handleResponse) 20 | .catch(() => 21 | enqueueSnackbar('Could not load Global Chat', { 22 | variant: 'error', 23 | }) 24 | ); 25 | }; 26 | 27 | return getGlobalMessages; 28 | } 29 | 30 | // Send a global message 31 | export function useSendGlobalMessage() { 32 | const { enqueueSnackbar } = useSnackbar(); 33 | const handleResponse = useHandleResponse(); 34 | 35 | const sendGlobalMessage = body => { 36 | const requestOptions = { 37 | method: 'POST', 38 | headers: authHeader(), 39 | body: JSON.stringify({ body: body, global: true }), 40 | }; 41 | 42 | return fetch( 43 | `${process.env.REACT_APP_API_URL}/api/messages/global`, 44 | requestOptions 45 | ) 46 | .then(handleResponse) 47 | .catch(err => { 48 | console.log(err); 49 | enqueueSnackbar('Could send message', { 50 | variant: 'error', 51 | }); 52 | }); 53 | }; 54 | 55 | return sendGlobalMessage; 56 | } 57 | 58 | // Get list of users conversations 59 | export function useGetConversations() { 60 | const { enqueueSnackbar } = useSnackbar(); 61 | const handleResponse = useHandleResponse(); 62 | const requestOptions = { 63 | method: 'GET', 64 | headers: authHeader(), 65 | }; 66 | 67 | const getConversations = () => { 68 | return fetch( 69 | `${process.env.REACT_APP_API_URL}/api/messages/conversations`, 70 | requestOptions 71 | ) 72 | .then(handleResponse) 73 | .catch(() => 74 | enqueueSnackbar('Could not load chats', { 75 | variant: 'error', 76 | }) 77 | ); 78 | }; 79 | 80 | return getConversations; 81 | } 82 | 83 | // get conversation messages based on 84 | // to and from id's 85 | export function useGetConversationMessages() { 86 | const { enqueueSnackbar } = useSnackbar(); 87 | const handleResponse = useHandleResponse(); 88 | const requestOptions = { 89 | method: 'GET', 90 | headers: authHeader(), 91 | }; 92 | 93 | const getConversationMessages = id => { 94 | return fetch( 95 | `${ 96 | process.env.REACT_APP_API_URL 97 | }/api/messages/conversations/query?userId=${id}`, 98 | requestOptions 99 | ) 100 | .then(handleResponse) 101 | .catch(() => 102 | enqueueSnackbar('Could not load chats', { 103 | variant: 'error', 104 | }) 105 | ); 106 | }; 107 | 108 | return getConversationMessages; 109 | } 110 | 111 | export function useSendConversationMessage() { 112 | const { enqueueSnackbar } = useSnackbar(); 113 | const handleResponse = useHandleResponse(); 114 | 115 | const sendConversationMessage = (id, body) => { 116 | const requestOptions = { 117 | method: 'POST', 118 | headers: authHeader(), 119 | body: JSON.stringify({ to: id, body: body }), 120 | }; 121 | 122 | return fetch( 123 | `${process.env.REACT_APP_API_URL}/api/messages/`, 124 | requestOptions 125 | ) 126 | .then(handleResponse) 127 | .catch(err => { 128 | console.log(err); 129 | enqueueSnackbar('Could send message', { 130 | variant: 'error', 131 | }); 132 | }); 133 | }; 134 | 135 | return sendConversationMessage; 136 | } 137 | -------------------------------------------------------------------------------- /client/src/Services/userService.js: -------------------------------------------------------------------------------- 1 | import useHandleResponse from '../Utilities/handle-response'; 2 | import authHeader from '../Utilities/auth-header'; 3 | import { useSnackbar } from 'notistack'; 4 | 5 | export function useGetUsers() { 6 | const { enqueueSnackbar } = useSnackbar(); 7 | const handleResponse = useHandleResponse(); 8 | const requestOptions = { 9 | method: 'GET', 10 | headers: authHeader(), 11 | }; 12 | 13 | const getUsers = () => { 14 | return fetch( 15 | `${process.env.REACT_APP_API_URL}/api/users`, 16 | requestOptions 17 | ) 18 | .then(handleResponse) 19 | .catch(() => 20 | enqueueSnackbar('Could not load Users', { 21 | variant: 'error', 22 | }) 23 | ); 24 | }; 25 | 26 | return getUsers; 27 | } 28 | -------------------------------------------------------------------------------- /client/src/Utilities/auth-header.js: -------------------------------------------------------------------------------- 1 | import { authenticationService } from '../Services/authenticationService'; 2 | 3 | function authHeader() { 4 | const currentUser = authenticationService.currentUserValue; 5 | if (currentUser && currentUser.token) { 6 | return { 7 | Authorization: `${currentUser.token}`, 8 | 'Content-Type': 'application/json', 9 | }; 10 | } else { 11 | return {}; 12 | } 13 | } 14 | 15 | export default authHeader; 16 | -------------------------------------------------------------------------------- /client/src/Utilities/common.js: -------------------------------------------------------------------------------- 1 | export default { 2 | getInitialsFromName: (name) => { 3 | const letters = String(name) 4 | .split(" ") 5 | .map((i) => i.charAt(0)); 6 | return letters.join(""); 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /client/src/Utilities/handle-response.js: -------------------------------------------------------------------------------- 1 | import { authenticationService } from '../Services/authenticationService'; 2 | import { useSnackbar } from 'notistack'; 3 | 4 | const useHandleResponse = () => { 5 | const { enqueueSnackbar } = useSnackbar(); 6 | 7 | const handleResponse = response => { 8 | return response.text().then(text => { 9 | const data = text && JSON.parse(text); 10 | if (!response.ok) { 11 | if ([401, 403].indexOf(response.status) !== -1) { 12 | authenticationService.logout(); 13 | enqueueSnackbar('User Unauthorized', { 14 | variant: 'error', 15 | }); 16 | } 17 | 18 | const error = (data && data.message) || response.statusText; 19 | return Promise.reject(error); 20 | } 21 | 22 | return data; 23 | }); 24 | }; 25 | 26 | return handleResponse; 27 | }; 28 | 29 | export default useHandleResponse; 30 | -------------------------------------------------------------------------------- /client/src/Utilities/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | 3 | const history = createBrowserHistory(); 4 | 5 | export default history; 6 | -------------------------------------------------------------------------------- /client/src/Utilities/private-route.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Redirect } from 'react-router-dom'; 3 | 4 | import { authenticationService } from '../Services/authenticationService'; 5 | 6 | const PrivateRoute = ({ component: Component, ...rest }) => ( 7 | { 10 | const currentUser = authenticationService.currentUserValue; 11 | if (!currentUser) { 12 | // not logged in so redirect to login page with the return url 13 | return ( 14 | 17 | ); 18 | } 19 | 20 | // authorised so return component 21 | return ; 22 | }} 23 | /> 24 | ); 25 | 26 | export default PrivateRoute; 27 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500&display=swap'); 2 | 3 | body { 4 | margin: 0; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /client/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /config/keys.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mongoURI: "mongodb://localhost:27017/chat-app", 3 | secretOrKey: "secret", 4 | }; 5 | -------------------------------------------------------------------------------- /config/passport.js: -------------------------------------------------------------------------------- 1 | const JwtStrategy = require('passport-jwt').Strategy; 2 | const ExtractJwt = require('passport-jwt').ExtractJwt; 3 | const mongoose = require('mongoose'); 4 | const User = mongoose.model('users'); 5 | const keys = require('../config/keys'); 6 | const opts = {}; 7 | opts.jwtFromRequest = ExtractJwt.fromAuthHeaderAsBearerToken(); 8 | opts.secretOrKey = keys.secretOrKey; 9 | module.exports = passport => { 10 | passport.use( 11 | new JwtStrategy(opts, (jwt_payload, done) => { 12 | User.findById(jwt_payload.id) 13 | .then(user => { 14 | if (user) { 15 | return done(null, user); 16 | } 17 | return done(null, false); 18 | }) 19 | .catch(err => console.log(err)); 20 | }) 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /models/Conversation.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | // Create Schema for Users 5 | const ConversationSchema = new Schema({ 6 | recipients: [{ type: Schema.Types.ObjectId, ref: 'users' }], 7 | lastMessage: { 8 | type: String, 9 | }, 10 | date: { 11 | type: String, 12 | default: Date.now, 13 | }, 14 | }); 15 | 16 | module.exports = Conversation = mongoose.model( 17 | 'conversations', 18 | ConversationSchema 19 | ); 20 | -------------------------------------------------------------------------------- /models/GlobalMessage.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | // Create Schema for Users 5 | const GlobalMessageSchema = new Schema({ 6 | from: { 7 | type: Schema.Types.ObjectId, 8 | ref: 'users', 9 | }, 10 | body: { 11 | type: String, 12 | required: true, 13 | }, 14 | date: { 15 | type: String, 16 | default: Date.now, 17 | }, 18 | }); 19 | 20 | module.exports = GlobalMessage = mongoose.model( 21 | 'global_messages', 22 | GlobalMessageSchema 23 | ); 24 | -------------------------------------------------------------------------------- /models/Message.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | // Create Schema for Users 5 | const MessageSchema = new Schema({ 6 | conversation: { 7 | type: Schema.Types.ObjectId, 8 | ref: 'conversations', 9 | }, 10 | to: { 11 | type: Schema.Types.ObjectId, 12 | ref: 'users', 13 | }, 14 | from: { 15 | type: Schema.Types.ObjectId, 16 | ref: 'users', 17 | }, 18 | body: { 19 | type: String, 20 | required: true, 21 | }, 22 | date: { 23 | type: String, 24 | default: Date.now, 25 | }, 26 | }); 27 | 28 | module.exports = Message = mongoose.model('messages', MessageSchema); 29 | -------------------------------------------------------------------------------- /models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | // Create Schema for Users 5 | const UserSchema = new Schema({ 6 | name: { 7 | type: String, 8 | required: true, 9 | }, 10 | username: { 11 | type: String, 12 | required: true, 13 | }, 14 | password: { 15 | type: String, 16 | required: true, 17 | }, 18 | date: { 19 | type: String, 20 | default: Date.now, 21 | }, 22 | }); 23 | 24 | module.exports = User = mongoose.model('users', UserSchema); 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-app", 3 | "version": "1.0.0", 4 | "description": "MERN Stack Based Chat Application", 5 | "main": "server.js", 6 | "scripts": { 7 | "client-install": "npm install --prefix client", 8 | "start": "node server.js", 9 | "server": "nodemon server.js", 10 | "client": "npm start --prefix client", 11 | "dev": "concurrently \"npm run server\" \"npm run client\"" 12 | }, 13 | "author": "Dave Howson", 14 | "license": "MIT", 15 | "dependencies": { 16 | "bcryptjs": "^2.4.3", 17 | "body-parser": "^1.19.0", 18 | "concurrently": "^5.3.0", 19 | "cors": "^2.8.5", 20 | "express": "^4.17.1", 21 | "http": "0.0.0", 22 | "is-empty": "^1.2.0", 23 | "jsonwebtoken": "^8.5.1", 24 | "mongoose": "^5.10.9", 25 | "passport": "^0.4.1", 26 | "passport-jwt": "^4.0.0", 27 | "socket.io": "^2.4.0", 28 | "validator": "^11.1.0" 29 | }, 30 | "devDependencies": { 31 | "eslint": "^6.6.0", 32 | "eslint-plugin-react": "^7.21.4", 33 | "eslint-plugin-react-hooks": "^1.7.0", 34 | "nodemon": "^2.0.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /routes/api/messages.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const jwt = require('jsonwebtoken'); 3 | const mongoose = require('mongoose'); 4 | const router = express.Router(); 5 | 6 | const keys = require('../../config/keys'); 7 | const verify = require('../../utilities/verify-token'); 8 | const Message = require('../../models/Message'); 9 | const Conversation = require('../../models/Conversation'); 10 | const GlobalMessage = require('../../models/GlobalMessage'); 11 | 12 | let jwtUser = null; 13 | 14 | // Token verfication middleware 15 | router.use(function(req, res, next) { 16 | try { 17 | jwtUser = jwt.verify(verify(req), keys.secretOrKey); 18 | next(); 19 | } catch (err) { 20 | console.log(err); 21 | res.setHeader('Content-Type', 'application/json'); 22 | res.end(JSON.stringify({ message: 'Unauthorized' })); 23 | res.sendStatus(401); 24 | } 25 | }); 26 | 27 | // Get global messages 28 | router.get('/global', (req, res) => { 29 | GlobalMessage.aggregate([ 30 | { 31 | $lookup: { 32 | from: 'users', 33 | localField: 'from', 34 | foreignField: '_id', 35 | as: 'fromObj', 36 | }, 37 | }, 38 | ]) 39 | .project({ 40 | 'fromObj.password': 0, 41 | 'fromObj.__v': 0, 42 | 'fromObj.date': 0, 43 | }) 44 | .exec((err, messages) => { 45 | if (err) { 46 | console.log(err); 47 | res.setHeader('Content-Type', 'application/json'); 48 | res.end(JSON.stringify({ message: 'Failure' })); 49 | res.sendStatus(500); 50 | } else { 51 | res.send(messages); 52 | } 53 | }); 54 | }); 55 | 56 | // Post global message 57 | router.post('/global', (req, res) => { 58 | let message = new GlobalMessage({ 59 | from: jwtUser.id, 60 | body: req.body.body, 61 | }); 62 | 63 | req.io.sockets.emit('messages', req.body.body); 64 | 65 | message.save(err => { 66 | if (err) { 67 | console.log(err); 68 | res.setHeader('Content-Type', 'application/json'); 69 | res.end(JSON.stringify({ message: 'Failure' })); 70 | res.sendStatus(500); 71 | } else { 72 | res.setHeader('Content-Type', 'application/json'); 73 | res.end(JSON.stringify({ message: 'Success' })); 74 | } 75 | }); 76 | }); 77 | 78 | // Get conversations list 79 | router.get('/conversations', (req, res) => { 80 | let from = mongoose.Types.ObjectId(jwtUser.id); 81 | Conversation.aggregate([ 82 | { 83 | $lookup: { 84 | from: 'users', 85 | localField: 'recipients', 86 | foreignField: '_id', 87 | as: 'recipientObj', 88 | }, 89 | }, 90 | ]) 91 | .match({ recipients: { $all: [{ $elemMatch: { $eq: from } }] } }) 92 | .project({ 93 | 'recipientObj.password': 0, 94 | 'recipientObj.__v': 0, 95 | 'recipientObj.date': 0, 96 | }) 97 | .exec((err, conversations) => { 98 | if (err) { 99 | console.log(err); 100 | res.setHeader('Content-Type', 'application/json'); 101 | res.end(JSON.stringify({ message: 'Failure' })); 102 | res.sendStatus(500); 103 | } else { 104 | res.send(conversations); 105 | } 106 | }); 107 | }); 108 | 109 | // Get messages from conversation 110 | // based on to & from 111 | router.get('/conversations/query', (req, res) => { 112 | let user1 = mongoose.Types.ObjectId(jwtUser.id); 113 | let user2 = mongoose.Types.ObjectId(req.query.userId); 114 | Message.aggregate([ 115 | { 116 | $lookup: { 117 | from: 'users', 118 | localField: 'to', 119 | foreignField: '_id', 120 | as: 'toObj', 121 | }, 122 | }, 123 | { 124 | $lookup: { 125 | from: 'users', 126 | localField: 'from', 127 | foreignField: '_id', 128 | as: 'fromObj', 129 | }, 130 | }, 131 | ]) 132 | .match({ 133 | $or: [ 134 | { $and: [{ to: user1 }, { from: user2 }] }, 135 | { $and: [{ to: user2 }, { from: user1 }] }, 136 | ], 137 | }) 138 | .project({ 139 | 'toObj.password': 0, 140 | 'toObj.__v': 0, 141 | 'toObj.date': 0, 142 | 'fromObj.password': 0, 143 | 'fromObj.__v': 0, 144 | 'fromObj.date': 0, 145 | }) 146 | .exec((err, messages) => { 147 | if (err) { 148 | console.log(err); 149 | res.setHeader('Content-Type', 'application/json'); 150 | res.end(JSON.stringify({ message: 'Failure' })); 151 | res.sendStatus(500); 152 | } else { 153 | res.send(messages); 154 | } 155 | }); 156 | }); 157 | 158 | // Post private message 159 | router.post('/', (req, res) => { 160 | let from = mongoose.Types.ObjectId(jwtUser.id); 161 | let to = mongoose.Types.ObjectId(req.body.to); 162 | 163 | Conversation.findOneAndUpdate( 164 | { 165 | recipients: { 166 | $all: [ 167 | { $elemMatch: { $eq: from } }, 168 | { $elemMatch: { $eq: to } }, 169 | ], 170 | }, 171 | }, 172 | { 173 | recipients: [jwtUser.id, req.body.to], 174 | lastMessage: req.body.body, 175 | date: Date.now(), 176 | }, 177 | { upsert: true, new: true, setDefaultsOnInsert: true }, 178 | function(err, conversation) { 179 | if (err) { 180 | console.log(err); 181 | res.setHeader('Content-Type', 'application/json'); 182 | res.end(JSON.stringify({ message: 'Failure' })); 183 | res.sendStatus(500); 184 | } else { 185 | let message = new Message({ 186 | conversation: conversation._id, 187 | to: req.body.to, 188 | from: jwtUser.id, 189 | body: req.body.body, 190 | }); 191 | 192 | req.io.sockets.emit('messages', req.body.body); 193 | 194 | message.save(err => { 195 | if (err) { 196 | console.log(err); 197 | res.setHeader('Content-Type', 'application/json'); 198 | res.end(JSON.stringify({ message: 'Failure' })); 199 | res.sendStatus(500); 200 | } else { 201 | res.setHeader('Content-Type', 'application/json'); 202 | res.end( 203 | JSON.stringify({ 204 | message: 'Success', 205 | conversationId: conversation._id, 206 | }) 207 | ); 208 | } 209 | }); 210 | } 211 | } 212 | ); 213 | }); 214 | 215 | module.exports = router; 216 | -------------------------------------------------------------------------------- /routes/api/users.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const bcrypt = require("bcryptjs"); 4 | const jwt = require("jsonwebtoken"); 5 | const mongoose = require("mongoose"); 6 | 7 | const keys = require("../../config/keys"); 8 | const verify = require("../../utilities/verify-token"); 9 | const validateRegisterInput = require("../../validation/register"); 10 | const validateLoginInput = require("../../validation/login"); 11 | const User = require("../../models/User"); 12 | 13 | router.get("/", (req, res) => { 14 | try { 15 | let jwtUser = jwt.verify(verify(req), keys.secretOrKey); 16 | let id = mongoose.Types.ObjectId(jwtUser.id); 17 | 18 | User.aggregate() 19 | .match({ _id: { $not: { $eq: id } } }) 20 | .project({ 21 | password: 0, 22 | __v: 0, 23 | date: 0, 24 | }) 25 | .exec((err, users) => { 26 | if (err) { 27 | console.log(err); 28 | res.setHeader("Content-Type", "application/json"); 29 | res.end(JSON.stringify({ message: "Failure" })); 30 | res.sendStatus(500); 31 | } else { 32 | res.send(users); 33 | } 34 | }); 35 | } catch (err) { 36 | console.log(err); 37 | res.setHeader("Content-Type", "application/json"); 38 | res.end(JSON.stringify({ message: "Unauthorized" })); 39 | res.sendStatus(401); 40 | } 41 | }); 42 | 43 | router.post("/register", (req, res) => { 44 | // Form validation 45 | const { errors, isValid } = validateRegisterInput(req.body); 46 | // Check validation 47 | if (!isValid) { 48 | return res.status(400).json(errors); 49 | } 50 | User.findOne({ username: req.body.username }).then((user) => { 51 | if (user) { 52 | return res.status(400).json({ message: "Username already exists" }); 53 | } else { 54 | const newUser = new User({ 55 | name: req.body.name, 56 | username: req.body.username, 57 | password: req.body.password, 58 | }); 59 | // Hash password before saving in database 60 | bcrypt.genSalt(10, (err, salt) => { 61 | bcrypt.hash(newUser.password, salt, (err, hash) => { 62 | if (err) throw err; 63 | newUser.password = hash; 64 | newUser 65 | .save() 66 | .then((user) => { 67 | const payload = { 68 | id: user.id, 69 | name: user.name, 70 | }; 71 | // Sign token 72 | jwt.sign( 73 | payload, 74 | keys.secretOrKey, 75 | { 76 | expiresIn: 31556926, // 1 year in seconds 77 | }, 78 | (err, token) => { 79 | if (err) { 80 | console.log(err); 81 | } else { 82 | req.io.sockets.emit("users", user.username); 83 | res.json({ 84 | success: true, 85 | token: "Bearer " + token, 86 | name: user.name, 87 | }); 88 | } 89 | } 90 | ); 91 | }) 92 | .catch((err) => console.log(err)); 93 | }); 94 | }); 95 | } 96 | }); 97 | }); 98 | 99 | router.post("/login", (req, res) => { 100 | // Form validation 101 | const { errors, isValid } = validateLoginInput(req.body); 102 | // Check validation 103 | if (!isValid) { 104 | return res.status(400).json(errors); 105 | } 106 | const username = req.body.username; 107 | const password = req.body.password; 108 | // Find user by username 109 | User.findOne({ username }).then((user) => { 110 | // Check if user exists 111 | if (!user) { 112 | return res.status(404).json({ usernamenotfound: "Username not found" }); 113 | } 114 | // Check password 115 | bcrypt.compare(password, user.password).then((isMatch) => { 116 | if (isMatch) { 117 | // User matched 118 | // Create JWT Payload 119 | const payload = { 120 | id: user.id, 121 | name: user.name, 122 | }; 123 | // Sign token 124 | jwt.sign( 125 | payload, 126 | keys.secretOrKey, 127 | { 128 | expiresIn: 31556926, // 1 year in seconds 129 | }, 130 | (err, token) => { 131 | res.json({ 132 | success: true, 133 | token: "Bearer " + token, 134 | name: user.name, 135 | username: user.username, 136 | userId: user._id, 137 | }); 138 | } 139 | ); 140 | } else { 141 | return res 142 | .status(400) 143 | .json({ passwordincorrect: "Password incorrect" }); 144 | } 145 | }); 146 | }); 147 | }); 148 | 149 | module.exports = router; 150 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const mongoose = require("mongoose"); 3 | const bodyParser = require("body-parser"); 4 | const passport = require("passport"); 5 | const cors = require("cors"); 6 | 7 | const users = require("./routes/api/users"); 8 | const messages = require("./routes/api/messages"); 9 | 10 | const app = express(); 11 | 12 | // Port that the webserver listens to 13 | const port = process.env.PORT || 5000; 14 | 15 | const server = app.listen(port, () => 16 | console.log(`Server running on port ${port}`) 17 | ); 18 | 19 | const io = require("socket.io").listen(server); 20 | 21 | // Body Parser middleware to parse request bodies 22 | app.use( 23 | bodyParser.urlencoded({ 24 | extended: false, 25 | }) 26 | ); 27 | app.use(bodyParser.json()); 28 | 29 | // CORS middleware 30 | app.use(cors()); 31 | 32 | // Database configuration 33 | const db = require("./config/keys").mongoURI; 34 | 35 | mongoose 36 | .connect(db, { 37 | useNewUrlParser: true, 38 | useFindAndModify: false, 39 | useUnifiedTopology: true, 40 | }) 41 | .then(() => console.log("MongoDB Successfully Connected")) 42 | .catch((err) => console.log(err)); 43 | 44 | // Passport middleware 45 | app.use(passport.initialize()); 46 | // Passport config 47 | require("./config/passport")(passport); 48 | 49 | // Assign socket object to every request 50 | app.use(function (req, res, next) { 51 | req.io = io; 52 | next(); 53 | }); 54 | 55 | // Routes 56 | app.use("/api/users", users); 57 | app.use("/api/messages", messages); 58 | -------------------------------------------------------------------------------- /utilities/verify-token.js: -------------------------------------------------------------------------------- 1 | const verify = req => { 2 | if ( 3 | req.headers.authorization && 4 | req.headers.authorization.split(' ')[0] === 'Bearer' 5 | ) 6 | return req.headers.authorization.split(' ')[1]; 7 | return null; 8 | }; 9 | 10 | module.exports = verify; 11 | -------------------------------------------------------------------------------- /validation/login.js: -------------------------------------------------------------------------------- 1 | const Validator = require('validator'); 2 | const isEmpty = require('is-empty'); 3 | module.exports = function validateLoginInput(data) { 4 | let errors = {}; 5 | 6 | // Converts empty fields to String in order to validate them 7 | data.username = !isEmpty(data.username) ? data.username : ''; 8 | data.password = !isEmpty(data.password) ? data.password : ''; 9 | 10 | if (Validator.isEmpty(data.username)) { 11 | errors.username = 'Username field is required'; 12 | } 13 | 14 | if (Validator.isEmpty(data.password)) { 15 | errors.password = 'Password field is required'; 16 | } 17 | return { 18 | errors, 19 | isValid: isEmpty(errors), 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /validation/register.js: -------------------------------------------------------------------------------- 1 | const Validator = require('validator'); 2 | const isEmpty = require('is-empty'); 3 | 4 | module.exports = function validateRegisterInput(data) { 5 | let errors = {}; 6 | 7 | // Converts empty fields to String in order to validate them 8 | data.name = !isEmpty(data.name) ? data.name : ''; 9 | data.username = !isEmpty(data.username) ? data.username : ''; 10 | data.password = !isEmpty(data.password) ? data.password : ''; 11 | data.password2 = !isEmpty(data.password2) ? data.password2 : ''; 12 | 13 | if (Validator.isEmpty(data.name)) { 14 | errors.name = 'Name field is required'; 15 | } 16 | 17 | if (Validator.isEmpty(data.username)) { 18 | errors.username = 'Username field is required'; 19 | } 20 | 21 | if (Validator.isEmpty(data.password)) { 22 | errors.password = 'Password field is required'; 23 | } 24 | if (Validator.isEmpty(data.password2)) { 25 | errors.password2 = 'Confirm password field is required'; 26 | } 27 | if (!Validator.isLength(data.password, { min: 6, max: 30 })) { 28 | errors.password = 'Password must be at least 6 characters'; 29 | } 30 | if (!Validator.equals(data.password, data.password2)) { 31 | errors.password2 = 'Passwords must match'; 32 | } 33 | 34 | return { 35 | errors, 36 | isValid: isEmpty(errors), 37 | }; 38 | }; 39 | --------------------------------------------------------------------------------