├── .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 | 
27 |
28 | ##### Private Chat
29 | 
30 |
31 | ##### Login
32 | 
33 |
34 | ##### Register
35 | 
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 | You need to enable JavaScript to run this app.
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 |
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 |
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 |
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 |
68 |
69 |
76 | {currentUser.name}
77 | {arrowIcon()}
78 |
79 |
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 |
--------------------------------------------------------------------------------