├── .babelrc ├── .commitlintrc.json ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── .husky └── commit-msg ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── client ├── api │ └── services │ │ └── setting.api.js ├── app.jsx ├── components │ ├── auth │ │ ├── login.jsx │ │ └── register.jsx │ ├── chat │ │ ├── foreground │ │ │ ├── header.jsx │ │ │ ├── inbox.jsx │ │ │ ├── minibox.jsx │ │ │ └── openContact.jsx │ │ └── room │ │ │ ├── emojiBoard.jsx │ │ │ ├── header.jsx │ │ │ ├── monitor.jsx │ │ │ └── send.jsx │ └── modals │ │ ├── attachMenu.jsx │ │ ├── avatarUpload.jsx │ │ ├── changePass.jsx │ │ ├── confirmAddParticipant.jsx │ │ ├── confirmDeleteChat.jsx │ │ ├── confirmDeleteChatAndInbox.jsx │ │ ├── confirmDeleteContact.jsx │ │ ├── confirmExitGroup.jsx │ │ ├── confirmNewGroup.jsx │ │ ├── deleteAcc.jsx │ │ ├── editGroup.jsx │ │ ├── groupContextMenu.jsx │ │ ├── imageCropper.jsx │ │ ├── inboxMenu.jsx │ │ ├── newContact.jsx │ │ ├── photoFull.jsx │ │ ├── qr.jsx │ │ ├── recordVoice.jsx │ │ ├── roomHeaderMenu.jsx │ │ ├── sendFile.jsx │ │ ├── signout.jsx │ │ └── webcam.jsx ├── config.js ├── containers │ └── chat │ │ ├── foreground.jsx │ │ └── room.jsx ├── helpers │ ├── base64Encode.js │ ├── bytesToSize.js │ ├── notification.js │ ├── socket.js │ └── touchAndHold.js ├── index.html ├── index.jsx ├── json │ └── emoji.json ├── pages │ ├── addParticipant.jsx │ ├── contact.jsx │ ├── friendProfile.jsx │ ├── groupParticipant.jsx │ ├── groupProfile.jsx │ ├── newGroup.jsx │ ├── profile.jsx │ └── setting.jsx ├── public │ └── assets │ │ ├── images │ │ ├── default-avatar.png │ │ ├── default-group-avatar.png │ │ ├── error.ico │ │ └── favicon.ico │ │ └── sound │ │ └── default-ringtone.mp3 ├── redux │ ├── features │ │ ├── chore.js │ │ ├── modal.js │ │ ├── page.js │ │ ├── room.js │ │ └── user.js │ └── store.js ├── routes │ ├── auth.jsx │ ├── chat.jsx │ ├── inactive.jsx │ └── verify.jsx └── style.css ├── docs └── img │ ├── dark-desktop.png │ └── light-desktop.png ├── package-lock.json ├── package.json ├── postcss.config.js ├── server ├── config.js ├── controllers │ ├── avatar.js │ ├── chat.js │ ├── contact.js │ ├── group.js │ ├── inbox.js │ ├── profile.js │ ├── setting.js │ └── user.js ├── db │ ├── connect.js │ └── models │ │ ├── chat.js │ │ ├── contact.js │ │ ├── file.js │ │ ├── group.js │ │ ├── inbox.js │ │ ├── profile.js │ │ ├── setting.js │ │ └── user.js ├── helpers │ ├── decrypt.js │ ├── encrypt.js │ ├── mailer.js │ ├── models │ │ ├── chats.js │ │ └── inbox.js │ ├── response.js │ ├── templates │ │ └── otp.html │ └── uniqueId.js ├── index.js ├── middleware │ ├── auth.js │ └── cloudinary.js ├── routes │ ├── avatar.js │ ├── chat.js │ ├── contact.js │ ├── group.js │ ├── inbox.js │ ├── index.js │ ├── profile.js │ ├── setting.js │ └── user.js ├── server.js └── socket │ ├── events │ ├── chat.js │ ├── group.js │ ├── room.js │ └── user.js │ └── index.js ├── tailwind.config.js ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "esmodules": true 8 | } 9 | } 10 | ], 11 | "@babel/preset-react" 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-transform-runtime", 15 | [ 16 | "wildcard", 17 | { 18 | "exts": ["js", "jsx"], 19 | "useCamelCase": true 20 | } 21 | ] 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV = development 2 | 3 | # cloud mongodb 4 | MONGO_URI = mongodb+srv://:@.deu00vc.mongodb.net/?retryWrites=true&w=majority 5 | 6 | # https://cloudinary.com/ 7 | CLOUDINARY_API_KEY = 8 | CLOUDINARY_API_SECRET = 9 | CLOUDINARY_CLOUD_NAME = 10 | 11 | # nodemailer 12 | EMAIL_USER = 13 | EMAIL_PASS = 14 | 15 | # fake SMTP/email server 16 | TEST_EMAIL_USER = 17 | TEST_EMAIL_PASS = 18 | TEST_EMAIL_HOST = 19 | TEST_EMAIL_PORT = 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /client/public 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": ["plugin:react/recommended", "airbnb", "prettier"], 8 | "overrides": [], 9 | "parserOptions": { 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "plugins": ["react", "prettier"], 14 | "rules": { 15 | "prettier/prettier": "error", 16 | "linebreak-style": ["error", "unix"], 17 | "no-underscore-dangle": "off", 18 | "no-console": "off", 19 | "import/no-unresolved": "off", 20 | "import/extensions": "off", 21 | "import/prefer-default-export": "off", 22 | "react/self-closing-comp": "off", 23 | "react/prop-types": "off" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: febriadj 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /client/public 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.lint.unknownAtRules": "ignore", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "editor.tabSize": 2, 6 | "eslint.enable": true, 7 | "files.eol": "\n" 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LeChat 2 | 3 | LeChat is a web-based instant messaging app that allows you to quickly send and receive text messages, emojis, photos, or files with other LeChat users. LeChat uses its own socket server and works independently, professionally built using **MongoDB, Express, React, Node, and Socket IO**. Suitable for those of you who are interested in learning the workflow of messaging apps. 4 | 5 | ## Table of Contents 6 | 7 | - [Getting Started](#getting-started) 8 | - [Preview](#preview) 9 | - [Requirements](#requirements) 10 | - [Features](#features) 11 | - [Environment Variables](#environment-variables) 12 | - [Connect to MongoDB](#connect-to-mongodb) 13 | - [Cloudinary](#cloudinary) 14 | - [Nodemailer](#nodemailer) 15 | - [Fake SMTP Server](#fake-smtp-server) 16 | - [License](#license) 17 | 18 | ## Getting Started 19 | 20 | **Step 1:** Fork and clone this repository. 21 | 22 | ```bash 23 | git clone https://github.com/{username}/lechat.git 24 | ``` 25 | 26 | **Step 2:** Rename `.env.example` file to `.env` and complete the required [environment variables](#environment-variables). 27 | 28 | **Step 3:** Install dependencies. 29 | 30 | ```bash 31 | npm install 32 | ``` 33 | 34 | **Step 4:** Run the app in development mode. 35 | 36 | ```bash 37 | npm run dev 38 | ``` 39 | 40 | ## Preview 41 | 42 | ![cover](/docs/img/light-desktop.png) 43 | ![cover](/docs/img/dark-desktop.png) 44 | 45 | ## Requirements 46 | 47 | - **Node.js:** _latest_ 48 | - **NPM**: _latest_ 49 | - **MongoDB**: _^6.0.4_ 50 | - [Cloudinary account](https://cloudinary.com): _third-party for media cloud_ 51 | 52 | ## Features 53 | 54 | - User authentication 55 | - Sharing text messages, emojis, photos, or files 56 | - Online/offline, last seen time, blue tick, and typing indicators 57 | - Photo capture 58 | - Browser notification 59 | - Peer-to-peer and group chat 60 | - User profile 61 | - Contact 62 | - Account settings 63 | - Dark mode 64 | - Change account password 65 | - Delete account 66 | - ... 67 | 68 | ## Environment Variables 69 | 70 | Environment variables provide information about the environment in which the process is running. We use Node environment variables to handle sensitive data like API keys, or configuration details that might change between runs. 71 | 72 | ``` 73 | NODE_ENV = development 74 | ``` 75 | 76 | ### Connect to MongoDB 77 | 78 | By default, LeChat will use your local MongoDB server and the `lechat` database will be created automatically when the app is run in development mode. In production mode, you should use a cloud database like [MongoDB Atlas](https://www.mongodb.com/atlas/database). 79 | 80 | ``` 81 | MONGO_URI = mongodb+srv://{username}:{password}@node.deu00vc.mongodb.net/{dbname}?retryWrites=true&w=majority 82 | ``` 83 | 84 | ### Cloudinary 85 | 86 | We rely on Cloudinary service to store all media uploaded by users, follow the instructions below to getting started with Cloudinary: 87 | 88 | - Create [Cloudinary Account](https://cloudinary.com/) for free and you will get **Product Environment Credentials** like Cloud Name, API Key, and API Secret. 89 | - Open the **Media Library** then create `avatars` and `chat` folders. 90 | 91 | ``` 92 | CLOUDINARY_API_KEY = 93 | CLOUDINARY_API_SECRET = 94 | CLOUDINARY_CLOUD_NAME = 95 | ``` 96 | 97 | ### Nodemailer 98 | 99 | We use [Nodemailer](https://nodemailer.com/about/) to send OTP code via email, use your email address and App Password to run Nodemailer, follow the instructions below to generate your App Password: 100 | 101 | - Go to your [Google Account](https://myaccount.google.com/) > **Security** > **2-Step Verification** 102 | - At the bottom, choose **Select app** and choose Other (custom name) > give this App Password a name, e.g. "Nodemailer" > **Generate** 103 | - Follow the instructions to enter the App Password. The App Password is the 16-character code in the yellow bar on your device 104 | - **Done** 105 | 106 | ``` 107 | EMAIL_USER = your@gmail.com 108 | EMAIL_PASS = 109 | ``` 110 | 111 | ### Fake SMTP Server 112 | 113 | There are many fake SMTP server services to test your email but I recommend using [Mailtrap](https://mailtrap.io), this variable will only be executed in development mode. 114 | 115 | ``` 116 | TEST_EMAIL_USER = 117 | TEST_EMAIL_PASS = 118 | TEST_EMAIL_HOST = smtp.mailtrap.io 119 | TEST_EMAIL_PORT = 2525 120 | ``` 121 | 122 | ## License 123 | 124 | Distributed under the [GPL-3.0 License](/LICENSE). 125 | -------------------------------------------------------------------------------- /client/api/services/setting.api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export const getSetting = async (queries) => { 4 | try { 5 | const { data } = await axios.get('/settings', queries); 6 | 7 | document.body.classList[data.payload.dark ? 'add' : 'remove']('dark'); 8 | return data.payload; 9 | } catch (error0) { 10 | console.error(error0.message); 11 | 12 | return null; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /client/app.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { BrowserRouter, Routes, Route } from 'react-router-dom'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import axios from 'axios'; 5 | import * as bi from 'react-icons/bi'; 6 | import './style.css'; 7 | import * as route from './routes'; 8 | import { setMaster, setSetting } from './redux/features/user'; 9 | import socket from './helpers/socket'; 10 | import config from './config'; 11 | import { getSetting } from './api/services/setting.api'; 12 | 13 | function App() { 14 | const dispatch = useDispatch(); 15 | const { master } = useSelector((state) => state.user); 16 | 17 | const [inactive, setInactive] = useState(false); 18 | const [loaded, setLoaded] = useState(false); 19 | 20 | // get access token from localStorage 21 | const token = localStorage.getItem('token'); 22 | 23 | const handleGetMaster = async (signal) => { 24 | try { 25 | if (token) { 26 | // set default authorization 27 | axios.defaults.headers.Authorization = `Bearer ${token}`; 28 | // get account setting 29 | const setting = await getSetting({ signal }); 30 | 31 | if (setting) { 32 | dispatch(setSetting(setting)); 33 | 34 | const { data } = await axios.get('/users', { signal }); 35 | // set master 36 | dispatch(setMaster(data.payload)); 37 | socket.emit('user/connect', data.payload._id); 38 | } 39 | 40 | setLoaded(true); 41 | } else { 42 | setTimeout(() => setLoaded(true), 1000); 43 | } 44 | } catch (error0) { 45 | console.error(error0.message); 46 | } 47 | }; 48 | 49 | useEffect(() => { 50 | const abortCtrl = new AbortController(); 51 | // set default base url 52 | axios.defaults.baseURL = config.isDev 53 | ? 'http://localhost:8080/api' 54 | : '/api'; 55 | handleGetMaster(abortCtrl.signal); 56 | 57 | socket.on('user/inactivate', () => { 58 | setInactive(true); 59 | dispatch(setMaster(null)); 60 | }); 61 | 62 | return () => { 63 | abortCtrl.abort(); 64 | socket.off('user/inactivate'); 65 | }; 66 | }, []); 67 | 68 | useEffect(() => { 69 | document.onvisibilitychange = (e) => { 70 | if (master) { 71 | const active = e.target.visibilityState === 'visible'; 72 | socket.emit(active ? 'user/connect' : 'user/disconnect', master._id); 73 | } 74 | }; 75 | }, [!!master]); 76 | 77 | return ( 78 | 79 | {loaded ? ( 80 | 81 | {inactive && } />} 82 | {!inactive && master ? ( 83 | : } 87 | /> 88 | ) : ( 89 | } /> 90 | )} 91 | 92 | ) : ( 93 |
94 |
95 | 96 | 97 | 98 |

Loading

99 |
100 |
101 | )} 102 |
103 | ); 104 | } 105 | 106 | export default App; 107 | -------------------------------------------------------------------------------- /client/components/auth/login.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | import axios from 'axios'; 4 | import * as bi from 'react-icons/bi'; 5 | import config from '../../config'; 6 | 7 | function Login({ setRespond }) { 8 | const cache = JSON.parse(localStorage.getItem('cache')); 9 | 10 | const [process, setProcess] = useState(false); 11 | const [form, setForm] = useState({ 12 | me: false, 13 | username: cache?.me || '', 14 | password: '', 15 | }); 16 | 17 | const handleChange = (e) => { 18 | // if it's a checkbox, get target.checked 19 | setForm((prev) => ({ 20 | ...prev, 21 | [e.target.name]: 22 | e.target.type === 'checkbox' ? e.target.checked : e.target.value, 23 | })); 24 | }; 25 | 26 | const handleSubmit = async (e) => { 27 | try { 28 | e.preventDefault(); 29 | setProcess(true); 30 | const { data } = await axios.post('/users/login', form); 31 | 32 | // store jwt token on localStorage 33 | localStorage.setItem('token', data.payload); 34 | localStorage.setItem( 35 | 'cache', 36 | JSON.stringify({ 37 | me: form.me ? form.username : null, 38 | }) 39 | ); 40 | 41 | // reset form 42 | setForm((prev) => ({ ...prev, username: '', password: '' })); 43 | setRespond({ success: true, message: data.message }); 44 | 45 | // reload this page after 1s 46 | setTimeout(() => { 47 | setProcess(false); 48 | window.location.reload(); 49 | }, 1000); 50 | } catch (error0) { 51 | setProcess(false); 52 | setRespond({ 53 | success: false, 54 | message: error0.response.data.message, 55 | }); 56 | } 57 | }; 58 | 59 | return ( 60 |
61 | 62 | {`Sign in - ${config.brandName}`} 63 | 64 | {[ 65 | { 66 | target: 'username', 67 | type: 'text', 68 | placeholder: 'Username or Email', 69 | icon: , 70 | minLength: 3, 71 | }, 72 | { 73 | target: 'password', 74 | type: 'password', 75 | placeholder: 'Password', 76 | icon: , 77 | minLength: 6, 78 | }, 79 | ].map((elem) => ( 80 | 102 | ))} 103 | 104 | 114 | 115 | {/* submit btn */} 116 | 129 | 130 | ); 131 | } 132 | 133 | export default Login; 134 | -------------------------------------------------------------------------------- /client/components/auth/register.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | import axios from 'axios'; 4 | import * as bi from 'react-icons/bi'; 5 | import config from '../../config'; 6 | 7 | function Register({ setRespond }) { 8 | const [process, setProcess] = useState(false); 9 | const [form, setForm] = useState({ 10 | username: '', 11 | email: '', 12 | password: '', 13 | }); 14 | 15 | const handleChange = (e) => { 16 | setForm((prev) => ({ 17 | ...prev, 18 | [e.target.name]: e.target.value, 19 | })); 20 | }; 21 | 22 | const handleSubmit = async (e) => { 23 | try { 24 | e.preventDefault(); 25 | setProcess(true); 26 | 27 | const { data } = await axios.post('/users/register', form); 28 | 29 | // set success response 30 | setRespond({ success: true, message: data.message }); 31 | // reset form 32 | setForm({ 33 | username: '', 34 | email: '', 35 | password: '', 36 | }); 37 | 38 | setTimeout(() => { 39 | // set localStorage 40 | localStorage.setItem('token', data.payload); 41 | localStorage.setItem( 42 | 'cache', 43 | JSON.stringify({ remember: form.username }) 44 | ); 45 | 46 | setProcess(false); 47 | 48 | window.location.reload(); 49 | }, 1000); 50 | } catch (error0) { 51 | setProcess(false); 52 | 53 | setRespond({ 54 | success: false, 55 | message: error0.response.data.message, 56 | }); 57 | } 58 | }; 59 | 60 | return ( 61 |
62 | 63 | {`Sign up - ${config.brandName}`} 64 | 65 | {[ 66 | { 67 | target: 'username', 68 | type: 'text', 69 | placeholder: 'Username', 70 | icon: , 71 | pattern: '[a-z0-9_-]{3,24}', 72 | }, 73 | { 74 | target: 'email', 75 | type: 'email', 76 | placeholder: 'Email address', 77 | icon: , 78 | pattern: null, 79 | }, 80 | { 81 | target: 'password', 82 | type: 'password', 83 | placeholder: 'Password', 84 | icon: , 85 | pattern: null, 86 | }, 87 | ].map((elem) => ( 88 | 110 | ))} 111 | {/* notice of terms */} 112 | 113 |

114 | {`People who use our service may have uploaded your contact information to ${config.brandName}. `} 115 | 116 | Learn More 117 | 118 |

119 |

120 | {'By signing up, you agree to our '} 121 | 122 | Terms 123 | 124 | {', '} 125 | 126 | Privacy Policy 127 | 128 | {' and '} 129 | 130 | Cookies Policy 131 | 132 | {'. '} 133 |

134 |
135 | 148 | 149 | ); 150 | } 151 | 152 | export default Register; 153 | -------------------------------------------------------------------------------- /client/components/chat/foreground/header.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { setModal } from '../../../redux/features/modal'; 6 | import { setPage } from '../../../redux/features/page'; 7 | import { setRefreshInbox } from '../../../redux/features/chore'; 8 | 9 | import config from '../../../config'; 10 | 11 | function Header({ setSearch }) { 12 | const dispatch = useDispatch(); 13 | const inputTimeout = useRef(null); 14 | 15 | return ( 16 |
17 |
18 | {/* brand name */} 19 |

{config.brandName}

20 |
21 | {[ 22 | { 23 | target: 'refresh-inbox', 24 | icon: , 25 | action() { 26 | dispatch(setRefreshInbox(uuidv4())); 27 | }, 28 | }, 29 | { 30 | target: 'contact', 31 | icon: , 32 | action() { 33 | dispatch(setPage({ target: 'contact' })); 34 | }, 35 | }, 36 | { 37 | target: 'minibox', 38 | icon: , 39 | action(e) { 40 | e.stopPropagation(); 41 | dispatch(setModal({ target: 'minibox' })); 42 | }, 43 | }, 44 | ].map((elem) => ( 45 | 53 | ))} 54 |
55 |
56 | {/* search bar */} 57 |
58 | 79 |
80 |
81 | ); 82 | } 83 | 84 | export default Header; 85 | -------------------------------------------------------------------------------- /client/components/chat/foreground/minibox.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import { setPage } from '../../../redux/features/page'; 5 | import { setModal } from '../../../redux/features/modal'; 6 | 7 | function Minibox() { 8 | const dispatch = useDispatch(); 9 | const { user, modal } = useSelector((state) => state); 10 | 11 | return ( 12 |
e.stopPropagation()} 20 | > 21 |
22 | {[ 23 | { 24 | target: 'profile', 25 | data: user.master._id, 26 | html: 'Profile', 27 | icon: , 28 | }, 29 | { target: 'setting', html: 'Settings', icon: }, 30 | { target: 'signout', html: 'Sign out', icon: }, 31 | ].map((elem) => ( 32 | 53 | ))} 54 |
55 |
56 | ); 57 | } 58 | 59 | export default Minibox; 60 | -------------------------------------------------------------------------------- /client/components/chat/foreground/openContact.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as ri from 'react-icons/ri'; 4 | import { setPage } from '../../../redux/features/page'; 5 | 6 | function OpenContact() { 7 | const dispatch = useDispatch(); 8 | const page = useSelector((state) => state.page); 9 | 10 | const somePageIsOpened = Object.entries(page) 11 | .filter( 12 | (e) => 13 | ![ 14 | 'friendProfile', 15 | 'groupProfile', 16 | 'groupParticipant', 17 | 'addParticipant', 18 | ].includes(e[0]) 19 | ) 20 | .some((elem) => !!elem[1]); 21 | 22 | return ( 23 | 39 | ); 40 | } 41 | 42 | export default OpenContact; 43 | -------------------------------------------------------------------------------- /client/components/chat/room/emojiBoard.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import * as bi from 'react-icons/bi'; 3 | import emojis from '../../../json/emoji.json'; 4 | 5 | function EmojiBoard({ setForm }) { 6 | const [category, setCategory] = useState('Smileys & Emotion'); 7 | 8 | useEffect(() => { 9 | const monitor = document.querySelector('#monitor'); 10 | monitor.scrollTop += 192; 11 | }, []); 12 | 13 | return ( 14 |
15 |
22 | {emojis 23 | .filter((elem) => elem.category === category) 24 | .map((elem) => ( 25 | 44 | ))} 45 |
46 |
47 |
48 | {[ 49 | { category: 'Smileys & Emotion', icon: }, 50 | { category: 'People & Body', icon: }, 51 | { category: 'Animals & Nature', icon: }, 52 | { category: 'Food & Drink', icon: }, 53 | { category: 'Travel & Places', icon: }, 54 | { category: 'Activities', icon: }, 55 | { category: 'Objects', icon: }, 56 | { category: 'Symbols', icon: }, 57 | { category: 'Flags', icon: }, 58 | ].map((elem) => ( 59 | 60 | 72 | 73 | ))} 74 |
75 |
76 |
77 | ); 78 | } 79 | 80 | export default EmojiBoard; 81 | -------------------------------------------------------------------------------- /client/components/chat/room/send.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import socket from '../../../helpers/socket'; 5 | import EmojiBoard from './emojiBoard'; 6 | import { setModal } from '../../../redux/features/modal'; 7 | 8 | import AttachMenu from '../../modals/attachMenu'; 9 | 10 | function Send({ setChats, setNewMessage, control }) { 11 | const dispatch = useDispatch(); 12 | const { 13 | user: { master, setting }, 14 | room: { chat: chatRoom }, 15 | } = useSelector((state) => state); 16 | 17 | const isGroup = chatRoom.data.roomType === 'group'; 18 | 19 | const [emojiBoard, setEmojiBoard] = useState(false); 20 | const [form, setForm] = useState({ 21 | text: '', 22 | file: null, 23 | }); 24 | 25 | const handleChange = (e) => { 26 | setForm((prev) => ({ 27 | ...prev, 28 | [e.target.name]: e.target.value, 29 | })); 30 | 31 | const { roomId, roomType } = chatRoom.data; 32 | // set typing status 33 | socket.emit('chat/typing', { 34 | roomType, 35 | roomId, 36 | userId: master._id, 37 | }); 38 | }; 39 | 40 | const handleSubmit = () => { 41 | if (form.text.length > 0 || form.file) { 42 | const { group = null, profile = null } = chatRoom.data; 43 | 44 | if ( 45 | (isGroup && group.participantsId.includes(master._id)) || 46 | (!isGroup && profile.active) 47 | ) { 48 | socket.emit('chat/insert', { 49 | ...form, 50 | ownersId: chatRoom.data.ownersId, 51 | roomType: chatRoom.data.roomType, 52 | userId: master._id, 53 | roomId: chatRoom.data.roomId, 54 | }); 55 | } else return; 56 | 57 | // close emoji board after 150ms 58 | setTimeout(() => setEmojiBoard(false), 150); 59 | // reset form 60 | setForm({ text: '', file: null }); 61 | } 62 | }; 63 | 64 | useEffect(() => { 65 | socket.on('chat/insert', (payload) => { 66 | if (chatRoom.isOpen) { 67 | // push new chat to state.chats 68 | setChats((prev) => { 69 | if (prev) { 70 | if (prev.length >= control.limit) { 71 | prev.shift(); 72 | } 73 | return [...prev, payload]; 74 | } 75 | return [payload]; 76 | }); 77 | } 78 | 79 | setTimeout(() => { 80 | const monitor = document.querySelector('#monitor'); 81 | 82 | if (payload.userId === master._id) { 83 | monitor.scrollTo({ 84 | top: monitor.scrollHeight, 85 | behavior: 'smooth', 86 | }); 87 | 88 | return; 89 | } 90 | 91 | if ( 92 | monitor.scrollHeight - monitor.clientHeight >= 93 | monitor.scrollTop + monitor.clientHeight / 2 94 | ) { 95 | setNewMessage((prev) => prev + 1); 96 | } else { 97 | monitor.scrollTo({ 98 | top: monitor.scrollHeight, 99 | behavior: 'smooth', 100 | }); 101 | } 102 | }, 150); 103 | }); 104 | 105 | return () => { 106 | socket.off('chat/insert'); 107 | }; 108 | }, []); 109 | 110 | return ( 111 |
112 | 113 |
114 | 115 | 129 | 147 | 148 | { 157 | if (setting.enterToSend && e.key === 'Enter') { 158 | handleSubmit(); 159 | } 160 | }} 161 | /> 162 | 175 |
176 | {emojiBoard && } 177 |
178 | ); 179 | } 180 | 181 | export default Send; 182 | -------------------------------------------------------------------------------- /client/components/modals/attachMenu.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import { setModal } from '../../redux/features/modal'; 5 | import base64Encode from '../../helpers/base64Encode'; 6 | 7 | function AttachMenu() { 8 | const dispatch = useDispatch(); 9 | const modal = useSelector((state) => state.modal); 10 | 11 | return ( 12 |
e.stopPropagation()} 18 | > 19 |
20 | {[ 21 | { 22 | target: 'photo', 23 | icon: , 24 | accept: 'image/png, image/jpg, image/jpeg, image/webp', 25 | }, 26 | { target: 'file', icon: , accept: '' }, 27 | ].map((elem) => ( 28 | 59 | ))} 60 |
61 |
62 | ); 63 | } 64 | 65 | export default AttachMenu; 66 | -------------------------------------------------------------------------------- /client/components/modals/avatarUpload.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import { setModal } from '../../redux/features/modal'; 5 | import base64Encode from '../../helpers/base64Encode'; 6 | import config from '../../config'; 7 | 8 | function AvatarUpload() { 9 | const dispatch = useDispatch(); 10 | const modal = useSelector((state) => state.modal); 11 | 12 | const [respond, setRespond] = useState({ success: true, message: null }); 13 | 14 | const handleGallery = async (e) => { 15 | try { 16 | const file = e.target.files[0]; 17 | 18 | if (file.size >= config.avatarUploadLimit) { 19 | const errData = { 20 | message: 'File too large. (max. 2 MB)', 21 | }; 22 | throw errData; 23 | } 24 | 25 | const base64 = await base64Encode(file); 26 | setRespond({ success: true, message: null }); 27 | 28 | dispatch( 29 | setModal({ 30 | target: 'imageCropper', 31 | data: { 32 | targetId: modal.avatarUpload.targetId, 33 | isGroup: modal.avatarUpload.isGroup, 34 | src: base64, 35 | back: 'avatarUpload', 36 | }, 37 | }) 38 | ); 39 | } catch ({ message }) { 40 | setRespond({ 41 | success: false, 42 | message, 43 | }); 44 | } 45 | }; 46 | 47 | return ( 48 |
55 |
{ 61 | e.stopPropagation(); 62 | }} 63 | > 64 | {/* header */} 65 |
66 |

67 | {modal.avatarUpload.isGroup ? 'Group Photo' : 'Profile Photo'} 68 |

69 | {respond.message && ( 70 |

75 | {respond.message} 76 |

77 | )} 78 |
79 |
80 | 97 | 114 |
115 |
116 |
117 | ); 118 | } 119 | 120 | export default AvatarUpload; 121 | -------------------------------------------------------------------------------- /client/components/modals/changePass.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import axios from 'axios'; 5 | import { setModal } from '../../redux/features/modal'; 6 | 7 | function ChangePass() { 8 | const dispatch = useDispatch(); 9 | const modal = useSelector((state) => state.modal); 10 | 11 | const [respond, setRespond] = useState({ success: true, message: null }); 12 | const [form, setForm] = useState({ 13 | oldPass: '', 14 | newPass: '', 15 | confirmNewPass: '', 16 | }); 17 | 18 | const handleChange = (e) => { 19 | setForm((prev) => ({ 20 | ...prev, 21 | [e.target.name]: e.target.value, 22 | })); 23 | }; 24 | 25 | const handleSubmit = async (e) => { 26 | try { 27 | e.preventDefault(); 28 | 29 | const { data } = await axios.patch('/users/change-pass', form); 30 | 31 | // set success response 32 | setRespond({ success: true, message: data.message }); 33 | setForm({ 34 | oldPass: '', 35 | newPass: '', 36 | confirmNewPass: '', 37 | }); 38 | 39 | // close modal after 1s 40 | setTimeout(() => { 41 | dispatch(setModal({ target: 'changePass' })); 42 | }, 1000); 43 | } catch (error0) { 44 | // set error response 45 | setRespond({ 46 | success: false, 47 | message: error0.response.data.message, 48 | }); 49 | } 50 | }; 51 | 52 | return ( 53 |
{ 61 | setRespond({ success: true, message: null }); 62 | setForm({ 63 | oldPass: '', 64 | newPass: '', 65 | confirmNewPass: '', 66 | }); 67 | }} 68 | > 69 |
{ 75 | e.stopPropagation(); 76 | }} 77 | > 78 |

Password

79 |
80 | {respond.message && ( 81 |

86 | {respond.message} 87 |

88 | )} 89 | 90 | {[ 91 | { target: 'oldPass', placeholder: 'Old password' }, 92 | { target: 'newPass', placeholder: 'New password' }, 93 | { target: 'confirmNewPass', placeholder: 'Confirm new password' }, 94 | ].map((elem) => ( 95 | 120 | ))} 121 | 122 | 123 | 139 | 145 | 146 |
147 |
148 |
149 | ); 150 | } 151 | 152 | export default ChangePass; 153 | -------------------------------------------------------------------------------- /client/components/modals/confirmAddParticipant.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { setModal } from '../../redux/features/modal'; 4 | import socket from '../../helpers/socket'; 5 | import { setSelectedParticipants } from '../../redux/features/chore'; 6 | import { setPage } from '../../redux/features/page'; 7 | 8 | function ConfirmAddParticipant() { 9 | const dispatch = useDispatch(); 10 | const { 11 | chore: { selectedParticipants }, 12 | modal, 13 | user: { master }, 14 | } = useSelector((state) => state); 15 | 16 | const handleSubmit = () => { 17 | const { groupId, roomId } = modal.confirmAddParticipant; 18 | 19 | socket.emit('group/add-participants', { 20 | userId: master._id, 21 | friendsId: selectedParticipants.map((elem) => elem.friendId), 22 | roomId, 23 | groupId, 24 | }); 25 | 26 | dispatch(setSelectedParticipants([])); 27 | 28 | setTimeout(() => { 29 | dispatch(setPage({ target: 'addParticipant', data: false })); 30 | 31 | setTimeout(() => { 32 | dispatch( 33 | setModal({ 34 | target: 'confirmAddParticipants', 35 | data: false, 36 | }) 37 | ); 38 | }, 500); 39 | }, 500); 40 | }; 41 | 42 | return ( 43 |
55 |
{ 61 | e.stopPropagation(); 62 | }} 63 | > 64 |

Add Participants

65 |

Are you sure to add these contacts as a group participants?

66 |
67 | 76 | 83 |
84 |
85 |
86 | ); 87 | } 88 | 89 | export default ConfirmAddParticipant; 90 | -------------------------------------------------------------------------------- /client/components/modals/confirmDeleteChat.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { setModal } from '../../redux/features/modal'; 4 | import socket from '../../helpers/socket'; 5 | 6 | function ConfirmDeleteChat() { 7 | const dispatch = useDispatch(); 8 | 9 | const { 10 | chore: { selectedChats }, 11 | modal: { confirmDeleteChat: confirmBox }, 12 | room: { chat: chatRoom }, 13 | user: { master }, 14 | } = useSelector((state) => state); 15 | 16 | const [deleteForEveryone, setDeleteForEveryone] = useState(false); 17 | 18 | const handleDeleteChats = () => { 19 | socket.emit('chat/delete', { 20 | roomId: chatRoom.data.roomId, 21 | userId: master._id, 22 | chatsId: selectedChats, 23 | deleteForEveryone, 24 | }); 25 | }; 26 | 27 | return ( 28 |
{ 36 | setTimeout(() => { 37 | setDeleteForEveryone(false); 38 | }, 150); 39 | }} 40 | > 41 |
{ 47 | e.stopPropagation(); 48 | }} 49 | > 50 |

Delete Message

51 |

Are you sure you want to delete this message?

52 | 65 | 66 | {[ 67 | { 68 | label: 'Cancel', 69 | style: 'hover:bg-gray-100 dark:hover:bg-spill-700', 70 | action: () => { 71 | dispatch(setModal({ target: 'confirmDeleteChat' })); 72 | setTimeout(() => { 73 | setDeleteForEveryone(false); 74 | }, 150); 75 | }, 76 | }, 77 | { 78 | label: 'Delete', 79 | style: 'font-bold text-white bg-rose-600 hover:bg-rose-700', 80 | action: () => handleDeleteChats(), 81 | }, 82 | ].map((elem) => ( 83 | 91 | ))} 92 | 93 |
94 |
95 | ); 96 | } 97 | 98 | export default ConfirmDeleteChat; 99 | -------------------------------------------------------------------------------- /client/components/modals/confirmDeleteChatAndInbox.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import axios from 'axios'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | import { setModal } from '../../redux/features/modal'; 6 | import { setRefreshInbox } from '../../redux/features/chore'; 7 | import { setChatRoom } from '../../redux/features/room'; 8 | 9 | function ConfirmDeleteChatAndInbox() { 10 | const dispatch = useDispatch(); 11 | const confirmDeleteChatAndInbox = useSelector( 12 | (state) => state.modal.confirmDeleteChatAndInbox 13 | ); 14 | const chatRoom = useSelector((state) => state.room.chat); 15 | 16 | const handleDeleteChatAndInbox = async () => { 17 | try { 18 | await axios.delete(`/chats/${confirmDeleteChatAndInbox.roomId}`); 19 | 20 | dispatch(setRefreshInbox(uuidv4())); 21 | 22 | if ( 23 | confirmDeleteChatAndInbox.inboxId === chatRoom.isOpen && 24 | chatRoom.data._id 25 | ) { 26 | dispatch( 27 | setChatRoom({ 28 | isOpen: false, 29 | refreshId: null, 30 | data: null, 31 | }) 32 | ); 33 | } 34 | 35 | setTimeout(() => { 36 | // close confirm-delete-inbox modal 37 | dispatch( 38 | setModal({ 39 | target: 'confirmDeleteChatAndInbox', 40 | data: false, 41 | }) 42 | ); 43 | }, 300); 44 | } catch (error0) { 45 | console.error(error0.message); 46 | } 47 | }; 48 | 49 | return ( 50 |
61 |
{ 67 | e.stopPropagation(); 68 | }} 69 | > 70 |

Delete Chat

71 |

Are you sure you want to delete this chat?

72 | 73 | 84 | 91 | 92 |
93 |
94 | ); 95 | } 96 | 97 | export default ConfirmDeleteChatAndInbox; 98 | -------------------------------------------------------------------------------- /client/components/modals/confirmDeleteContact.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import axios from 'axios'; 5 | import { setModal } from '../../redux/features/modal'; 6 | import { 7 | setRefreshContact, 8 | setRefreshFriendProfile, 9 | } from '../../redux/features/chore'; 10 | 11 | function ConfirmDeleteContact() { 12 | const dispatch = useDispatch(); 13 | // friend _id 14 | const confirmDeleteContact = useSelector( 15 | (state) => state.modal.confirmDeleteContact 16 | ); 17 | 18 | const handleDeleteContact = async () => { 19 | try { 20 | await axios.delete(`/contacts/${confirmDeleteContact}`); 21 | 22 | // close confirmDeleteContact modal 23 | dispatch( 24 | setModal({ 25 | target: 'confirmDeleteContact', 26 | data: false, 27 | }) 28 | ); 29 | 30 | setTimeout(() => { 31 | // refresh contact and friend's profile page after 150ms 32 | dispatch(setRefreshContact(uuidv4())); 33 | dispatch(setRefreshFriendProfile(uuidv4())); 34 | }, 150); 35 | } catch (error0) { 36 | console.error(error0.message); 37 | } 38 | }; 39 | 40 | return ( 41 |
48 |
{ 54 | e.stopPropagation(); 55 | }} 56 | > 57 |

Delete Contact

58 |

Are you sure you want to delete this contact?

59 | 60 | 71 | 78 | 79 |
80 |
81 | ); 82 | } 83 | 84 | export default ConfirmDeleteContact; 85 | -------------------------------------------------------------------------------- /client/components/modals/confirmExitGroup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { setModal } from '../../redux/features/modal'; 4 | import socket from '../../helpers/socket'; 5 | import { setChatRoom } from '../../redux/features/room'; 6 | 7 | function ConfirmExitGroup() { 8 | const dispatch = useDispatch(); 9 | 10 | const master = useSelector((state) => state.user.master); 11 | const confirmExitGroup = useSelector((state) => state.modal.confirmExitGroup); 12 | 13 | const handleExitGroup = () => { 14 | socket.emit( 15 | 'group/exit', 16 | { 17 | groupId: confirmExitGroup.groupId, 18 | userId: master._id, 19 | }, 20 | () => { 21 | // close confirmExitGroup modal 22 | dispatch(setModal({ target: 'confirmExitGroup', data: false })); 23 | 24 | setTimeout(() => { 25 | // close room after 150ms 26 | dispatch( 27 | setChatRoom({ 28 | isOpen: false, 29 | refreshId: null, 30 | data: null, 31 | }) 32 | ); 33 | }, 150); 34 | } 35 | ); 36 | }; 37 | 38 | return ( 39 |
46 |
{ 52 | e.stopPropagation(); 53 | }} 54 | > 55 |

Exit Group

56 |

57 | {'Are you sure you want to exit '} 58 | {`"${confirmExitGroup.name}"`} 59 | {' group?'} 60 |

61 | 62 | {[ 63 | { 64 | label: 'Cancel', 65 | style: 'hover:bg-gray-100 dark:hover:bg-spill-700', 66 | action: () => 67 | dispatch(setModal({ target: 'confirmExitGroup', data: false })), 68 | }, 69 | { 70 | label: 'Exit group', 71 | style: 'font-bold text-white bg-rose-600 hover:bg-rose-700', 72 | action: () => handleExitGroup(), 73 | }, 74 | ].map((elem) => ( 75 | 83 | ))} 84 | 85 |
86 |
87 | ); 88 | } 89 | 90 | export default ConfirmExitGroup; 91 | -------------------------------------------------------------------------------- /client/components/modals/deleteAcc.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import axios from 'axios'; 5 | import { setModal } from '../../redux/features/modal'; 6 | 7 | function DeleteAccount() { 8 | const dispatch = useDispatch(); 9 | const modal = useSelector((state) => state.modal); 10 | 11 | const [respond, setRespond] = useState({ success: true, message: null }); 12 | const [password, setPassword] = useState(''); 13 | 14 | const handleDelete = async () => { 15 | try { 16 | const { data } = await axios.delete('/users', { data: { password } }); 17 | // set success response 18 | setRespond({ success: true, message: data.message }); 19 | 20 | // delete token and close modal after 0.5s 21 | setTimeout(() => { 22 | // delete access token 23 | localStorage.removeItem('token'); 24 | dispatch(setModal({ target: 'deleteAcc' })); 25 | 26 | // reload & display auth page after 1s 27 | setTimeout(() => { 28 | window.location.reload(); 29 | }, 500); 30 | }, 1000); 31 | } catch (error0) { 32 | // set error response 33 | setRespond({ 34 | success: false, 35 | message: error0.response.data.message, 36 | }); 37 | } 38 | }; 39 | 40 | return ( 41 |
{ 49 | setRespond({ success: true, message: null }); 50 | setPassword(''); 51 | }} 52 | > 53 |
{ 59 | e.stopPropagation(); 60 | }} 61 | > 62 |

Delete Account

63 |
64 |

65 | This is extremely important. 66 |

67 |
68 |

69 | Once your account is deleted, all of your data will be permanently 70 | gone, including your profile, contacts, and chats. 71 |

72 |

Enter your password to confirm.

73 | 92 | {respond.message && ( 93 |

98 | {respond.message} 99 |

100 | )} 101 | 102 | 114 | 121 | 122 |
123 |
124 | ); 125 | } 126 | 127 | export default DeleteAccount; 128 | -------------------------------------------------------------------------------- /client/components/modals/editGroup.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import { setModal } from '../../redux/features/modal'; 5 | import socket from '../../helpers/socket'; 6 | 7 | function EditGroup() { 8 | const dispatch = useDispatch(); 9 | 10 | const master = useSelector((state) => state.user.master); 11 | const modal = useSelector((state) => state.modal); 12 | 13 | const [respond, setRespond] = useState({ success: true, message: null }); 14 | const [form, setForm] = useState({ name: '', desc: '' }); 15 | 16 | const handleCloseModal = () => { 17 | // close modal 18 | dispatch(setModal({ target: 'editGroup', data: false })); 19 | 20 | // reset form and response dialog 21 | setForm({ name: '', desc: '' }); 22 | setRespond({ success: true, message: null }); 23 | }; 24 | 25 | const handleChange = (e) => { 26 | setForm((prev) => ({ 27 | ...prev, 28 | [e.target.name]: e.target.value, 29 | })); 30 | }; 31 | 32 | const handleSubmit = (e) => { 33 | e.preventDefault(); 34 | 35 | if ( 36 | form.name === modal.editGroup.name && 37 | form.desc === modal.editGroup.desc 38 | ) { 39 | handleCloseModal(); 40 | } 41 | 42 | socket.emit( 43 | 'group/edit', 44 | { 45 | userId: master._id, 46 | groupId: modal.editGroup._id, 47 | form, 48 | }, 49 | (cb) => { 50 | if (cb.success) { 51 | setForm({ name: '', desc: '' }); 52 | setRespond(cb); 53 | 54 | setTimeout(() => { 55 | // close editGroup modal after 500ms (0.5s) 56 | handleCloseModal(); 57 | }, 500); 58 | } else { 59 | setRespond(cb); 60 | } 61 | } 62 | ); 63 | }; 64 | 65 | useEffect(() => { 66 | const group = modal.editGroup; 67 | 68 | setForm((prev) => ({ 69 | ...prev, 70 | name: group ? group.name : '', 71 | desc: group ? group.desc : '', 72 | })); 73 | }, [modal.editGroup]); 74 | 75 | return ( 76 |
handleCloseModal()} 85 | > 86 |
{ 92 | e.stopPropagation(); 93 | }} 94 | > 95 | {/* header */} 96 |
97 |

Edit Group

98 | {respond.message && ( 99 |

104 | {respond.message} 105 |

106 | )} 107 |
108 | {modal.editGroup && ( 109 |
110 | 111 | {[ 112 | { 113 | target: 'name', 114 | placeholder: 'Group name', 115 | required: true, 116 | minLength: 1, 117 | maxLength: 32, 118 | }, 119 | { 120 | target: 'desc', 121 | placeholder: 'Group description (optional)', 122 | required: false, 123 | minLength: 0, 124 | maxLength: 300, 125 | }, 126 | ].map((elem) => ( 127 | 153 | ))} 154 | 155 | 156 | 165 | 171 | 172 |
173 | )} 174 |
175 |
176 | ); 177 | } 178 | 179 | export default EditGroup; 180 | -------------------------------------------------------------------------------- /client/components/modals/groupContextMenu.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { setPage } from '../../redux/features/page'; 4 | import { setModal } from '../../redux/features/modal'; 5 | import socket from '../../helpers/socket'; 6 | 7 | function GroupContextMenu() { 8 | const dispatch = useDispatch(); 9 | 10 | const menu = useSelector((state) => state.modal.groupContextMenu); 11 | const master = useSelector((state) => state.user.master); 12 | 13 | return ( 14 |
e.stopPropagation()} 19 | style={{ 20 | transform: `translate(${menu.x}px, ${menu.y}px)`, 21 | }} 22 | > 23 |
24 | {[ 25 | { 26 | _key: 'B-01', 27 | html: `View ${menu.user.fullname.split(' ')[0]}`, 28 | func() { 29 | const data = menu.user.userId; 30 | dispatch(setPage({ target: 'friendProfile', data })); 31 | }, 32 | }, 33 | { 34 | _key: 'B-02', 35 | html: 36 | menu.group.adminId === menu.user.userId 37 | ? 'Dismiss as admin' 38 | : 'Set as admin', 39 | func() { 40 | socket.emit('group/add-admin', { 41 | participantId: menu.user.userId, 42 | userId: master._id, 43 | groupId: menu.group._id, 44 | }); 45 | }, 46 | }, 47 | { 48 | _key: 'B-03', 49 | html: `Remove ${menu.user.fullname.split(' ')[0]}`, 50 | func() { 51 | socket.emit('group/remove-participant', { 52 | participantId: menu.user.userId, 53 | userId: master._id, 54 | groupId: menu.group._id, 55 | }); 56 | }, 57 | }, 58 | ].map((elem) => ( 59 | 71 | ))} 72 |
73 |
74 | ); 75 | } 76 | 77 | export default GroupContextMenu; 78 | -------------------------------------------------------------------------------- /client/components/modals/imageCropper.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import axios from 'axios'; 4 | import * as bi from 'react-icons/bi'; 5 | import Cropper from 'react-easy-crop'; 6 | 7 | import { setModal } from '../../redux/features/modal'; 8 | import { 9 | setRefreshAvatar, 10 | setRefreshGroupAvatar, 11 | } from '../../redux/features/chore'; 12 | 13 | function ImageCropper() { 14 | const dispatch = useDispatch(); 15 | const modal = useSelector((state) => state.modal); 16 | 17 | const [zoom, setZoom] = useState(1); 18 | const [crop, setCrop] = useState({ x: 0, y: 0 }); 19 | const [croppedArea, setCroppedArea] = useState(null); 20 | const [uploading, setUploading] = useState(false); 21 | 22 | const handleUpload = async () => { 23 | try { 24 | setUploading(true); 25 | 26 | const { src: avatar, isGroup, targetId } = modal.imageCropper; 27 | 28 | const { data } = await axios.post('/avatars', { 29 | avatar, 30 | targetId, 31 | isGroup, 32 | crop: croppedArea, 33 | zoom, 34 | }); 35 | 36 | // close imageCropper modal 37 | dispatch(setModal({ target: 'imageCropper' })); 38 | // uploaded 39 | setUploading(false); 40 | 41 | if (isGroup) { 42 | dispatch(setRefreshGroupAvatar(data.payload)); 43 | } else { 44 | dispatch(setRefreshAvatar(data.payload)); 45 | } 46 | } catch (error0) { 47 | setUploading(false); 48 | } 49 | }; 50 | 51 | return ( 52 |
{ 56 | e.stopPropagation(); 57 | dispatch(setModal({ target: modal.imageCropper.back })); 58 | }} 59 | > 60 |
{ 64 | e.stopPropagation(); 65 | }} 66 | > 67 |
68 |

69 | Crop your new profile photo 70 |

71 | 82 |
83 |
84 | setCroppedArea(inPixels)} 92 | /> 93 |
94 |
95 | 96 | 97 | 98 | 99 | { 109 | setZoom(e.target.value); 110 | }} 111 | /> 112 | 113 | 114 | 115 | 116 | 123 |
124 |
125 |
126 | ); 127 | } 128 | 129 | export default ImageCropper; 130 | -------------------------------------------------------------------------------- /client/components/modals/inboxMenu.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import { setModal } from '../../redux/features/modal'; 5 | 6 | function InboxMenu() { 7 | const dispatch = useDispatch(); 8 | const menu = useSelector((state) => state.modal.inboxMenu); 9 | 10 | const isGroup = menu.inbox.roomType === 'group'; 11 | 12 | return ( 13 |
e.stopPropagation()} 18 | style={{ 19 | transform: `translate(${menu.x}px, ${menu.y}px)`, 20 | }} 21 | > 22 |
23 | 57 |
58 |
59 | ); 60 | } 61 | 62 | export default InboxMenu; 63 | -------------------------------------------------------------------------------- /client/components/modals/newContact.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | import axios from 'axios'; 5 | import * as bi from 'react-icons/bi'; 6 | import { setModal } from '../../redux/features/modal'; 7 | import { 8 | setRefreshContact, 9 | setRefreshFriendProfile, 10 | } from '../../redux/features/chore'; 11 | 12 | function NewContact() { 13 | const dispatch = useDispatch(); 14 | const modal = useSelector((state) => state.modal); 15 | 16 | const [respond, setRespond] = useState({ success: true, message: null }); 17 | const [form, setForm] = useState({ username: '', fullname: '' }); 18 | 19 | const handleChange = async (e) => { 20 | setForm((prev) => ({ 21 | ...prev, 22 | [e.target.name]: e.target.value, 23 | })); 24 | }; 25 | 26 | const handleSubmit = async (e) => { 27 | try { 28 | e.preventDefault(); 29 | const { data } = await axios.post('/contacts', form); 30 | 31 | setRespond({ success: true, message: data.message }); 32 | setForm({ username: '', fullname: '' }); 33 | 34 | // refresh contact and friend's profile page 35 | dispatch(setRefreshContact(uuidv4())); 36 | dispatch(setRefreshFriendProfile(uuidv4())); 37 | 38 | setTimeout(() => { 39 | // reset response dialog 40 | setRespond({ success: true, message: '' }); 41 | // and close new-contact modal after 1s 42 | dispatch( 43 | setModal({ 44 | target: 'newcontact', 45 | data: false, 46 | }) 47 | ); 48 | }, 1000); 49 | } catch (error0) { 50 | const { message } = error0.response.data; 51 | setRespond({ 52 | success: false, 53 | message, 54 | }); 55 | } 56 | }; 57 | 58 | useEffect(() => { 59 | if (modal.newcontact) { 60 | setForm((prev) => ({ 61 | ...prev, 62 | username: modal.newcontact?.username ?? '', 63 | })); 64 | } 65 | }, [!!modal.newcontact]); 66 | 67 | return ( 68 |
{ 76 | setRespond((prev) => ({ ...prev, message: null })); 77 | setForm({ username: '', fullname: '' }); 78 | }} 79 | > 80 |
{ 86 | e.stopPropagation(); 87 | }} 88 | > 89 | {/* header */} 90 |
91 |

New Contact

92 | {respond.message && ( 93 |

98 | {respond.message} 99 |

100 | )} 101 |
102 | {/* content */} 103 |
104 |
105 | 106 | {[ 107 | { 108 | target: 'username', 109 | placeholder: 'Username', 110 | required: true, 111 | minLength: 3, 112 | maxLength: 24, 113 | }, 114 | { 115 | target: 'fullname', 116 | placeholder: 'Contact name (optional)', 117 | required: false, 118 | minLength: 6, 119 | maxLength: 32, 120 | }, 121 | ].map((elem) => ( 122 | 148 | ))} 149 | 150 | 151 | 163 | 169 | 170 |
171 |
172 |
173 |
174 | ); 175 | } 176 | 177 | export default NewContact; 178 | -------------------------------------------------------------------------------- /client/components/modals/photoFull.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | 5 | function PhotoFull() { 6 | const photo = useSelector((state) => state.modal.photoFull); 7 | 8 | return ( 9 |
16 |
17 | 25 |
26 |
27 | e.stopPropagation()} 33 | /> 34 |
35 |
36 |
37 | ); 38 | } 39 | 40 | export default PhotoFull; 41 | -------------------------------------------------------------------------------- /client/components/modals/qr.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import QRCode from 'qrcode'; 5 | import { setModal } from '../../redux/features/modal'; 6 | import config from '../../config'; 7 | 8 | function QR() { 9 | const dispatch = useDispatch(); 10 | const { 11 | user: { master }, 12 | modal: { qr }, 13 | } = useSelector((state) => state); 14 | 15 | const generateQRCode = async () => { 16 | if (qr) { 17 | // create a new canvas element 18 | const canvas = document.createElement('canvas'); 19 | const parent = document.querySelector('#qr #canvas-wrap'); 20 | 21 | parent.append(canvas); 22 | 23 | await QRCode.toCanvas(canvas, master.qrCode, { width: 200 }); 24 | } else { 25 | const parent = document.querySelector('#qr #canvas-wrap'); 26 | 27 | if (parent) { 28 | // remove canvas element 29 | const canvas = parent.querySelector('canvas'); 30 | canvas.remove(); 31 | } 32 | } 33 | }; 34 | 35 | useEffect(() => { 36 | generateQRCode(); 37 | }, [!!qr]); 38 | 39 | return ( 40 |
48 |
{ 54 | e.stopPropagation(); 55 | }} 56 | > 57 | {qr && ( 58 | <> 59 |
60 |
61 | 66 | 67 |

{qr.fullname}

68 |

{qr.bio}

69 |
70 |
71 | 83 |
84 |
85 |
86 |
87 |

{`Your QR code is private, if you share it with someone, they can scan it with their ${config.brandName} camera to add you as a contact.`}

88 | 89 | )} 90 |
91 |
92 | ); 93 | } 94 | 95 | export default QR; 96 | -------------------------------------------------------------------------------- /client/components/modals/recordVoice.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as ri from 'react-icons/ri'; 4 | import { setModal } from '../../redux/features/modal'; 5 | 6 | function RecordVoice() { 7 | const dispatch = useDispatch(); 8 | const modal = useSelector((state) => state.modal); 9 | const [recorder, setRecorder] = useState(null); 10 | const [chunks, setChunks] = useState([]); 11 | 12 | const handleStartRecord = async () => { 13 | const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 14 | setRecorder(new MediaRecorder(stream)); 15 | recorder[recorder.state === 'inactive' ? 'start' : 'stop'](); 16 | 17 | recorder.ondataavailable = (e) => { 18 | setChunks(e.data); 19 | }; 20 | 21 | recorder.onstop = () => { 22 | const audio = document 23 | .querySelector('#record-voice') 24 | .querySelector('audio'); 25 | const blob = new Blob(chunks, { type: 'audio/ogg;' }); 26 | const url = URL.createObjectURL(blob); 27 | audio.src = url; 28 | 29 | console.log(blob); 30 | }; 31 | }; 32 | 33 | return ( 34 |
42 |
{ 48 | e.stopPropagation(); 49 | }} 50 | > 51 |

Record Voice

52 | 53 |
54 | 63 |
64 |
65 |
66 | ); 67 | } 68 | 69 | export default RecordVoice; 70 | -------------------------------------------------------------------------------- /client/components/modals/roomHeaderMenu.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import { setModal } from '../../redux/features/modal'; 5 | import { setPage } from '../../redux/features/page'; 6 | import { setChatRoom } from '../../redux/features/room'; 7 | import { setSelectedChats } from '../../redux/features/chore'; 8 | 9 | function RoomHeaderMenu() { 10 | const dispatch = useDispatch(); 11 | 12 | const modal = useSelector((state) => state.modal); 13 | const { 14 | _id: inboxId, 15 | roomId, 16 | profile, 17 | group, 18 | roomType, 19 | } = useSelector((state) => state.room.chat.data); 20 | 21 | const isGroup = roomType === 'group'; 22 | 23 | return ( 24 |
e.stopPropagation()} 30 | > 31 |
32 | {[ 33 | { 34 | _key: 'k-01', 35 | html: isGroup ? 'Group info' : 'Contact info', 36 | icon: , 37 | action() { 38 | const query = {}; 39 | 40 | if (!isGroup && !profile.active) { 41 | return; 42 | } 43 | 44 | query.target = isGroup ? 'groupProfile' : 'friendProfile'; 45 | query.data = isGroup ? group._id : profile.userId; 46 | 47 | dispatch(setPage(query)); 48 | }, 49 | style: '', 50 | }, 51 | { 52 | _key: 'k-02', 53 | html: 'Close chat', 54 | icon: , 55 | action() { 56 | dispatch( 57 | setChatRoom({ 58 | isOpen: false, 59 | refreshId: null, 60 | data: null, 61 | }) 62 | ); 63 | }, 64 | style: '', 65 | }, 66 | { 67 | _key: 'k-03', 68 | html: 'Select messages', 69 | icon: , 70 | action() { 71 | dispatch(setSelectedChats([])); 72 | }, 73 | style: '', 74 | }, 75 | { 76 | _key: 'k-04', 77 | html: 'Delete chat', 78 | icon: , 79 | action() { 80 | dispatch( 81 | setModal({ 82 | target: 'confirmDeleteChatAndInbox', 83 | data: { inboxId, roomId }, 84 | }) 85 | ); 86 | }, 87 | style: isGroup 88 | ? 'hidden' 89 | : 'block text-rose-600 dark:text-rose-400', 90 | }, 91 | { 92 | _key: 'k-05', 93 | html: 'Exit group', 94 | icon: , 95 | action() { 96 | dispatch( 97 | setModal({ 98 | target: 'confirmExitGroup', 99 | data: { groupId: group._id, name: group.name }, 100 | }) 101 | ); 102 | }, 103 | style: isGroup 104 | ? 'block text-rose-600 dark:text-rose-400' 105 | : 'hidden', 106 | }, 107 | ].map((elem) => ( 108 | 120 | ))} 121 |
122 |
123 | ); 124 | } 125 | 126 | export default RoomHeaderMenu; 127 | -------------------------------------------------------------------------------- /client/components/modals/sendFile.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as bi from 'react-icons/bi'; 4 | import { setModal } from '../../redux/features/modal'; 5 | import socket from '../../helpers/socket'; 6 | import bytesToSize from '../../helpers/bytesToSize'; 7 | 8 | function SendFile() { 9 | const dispatch = useDispatch(); 10 | const { 11 | modal: { sendFile }, 12 | room: { chat: chatRoom }, 13 | user: { master }, 14 | } = useSelector((state) => state); 15 | 16 | const [caption, setCaption] = useState(''); 17 | 18 | const handleSubmit = (e) => { 19 | e.preventDefault(); 20 | 21 | socket.emit('chat/insert', { 22 | roomId: chatRoom.data.roomId, 23 | userId: master._id, 24 | ownersId: chatRoom.data.ownersId, 25 | roomType: chatRoom.data.roomType, 26 | text: caption, 27 | file: { 28 | originalname: sendFile.originalname, 29 | url: sendFile.url, 30 | }, 31 | }); 32 | 33 | setCaption(''); 34 | 35 | setTimeout(() => { 36 | dispatch(setModal({ target: 'sendFile' })); 37 | }, 500); 38 | }; 39 | 40 | return ( 41 |
setCaption('')} 49 | > 50 |
{ 56 | e.stopPropagation(); 57 | }} 58 | > 59 | {/* header */} 60 |
61 |

{`Send ${ 62 | sendFile.type === 'image' ? 'Photo' : 'File' 63 | }`}

64 | 78 |
79 | {sendFile && sendFile.type === 'image' && ( 80 |
81 | 82 |
83 | )} 84 | {sendFile && sendFile.type === 'all' && ( 85 |
86 | 87 | 88 | 89 | 90 |

{sendFile.originalname}

91 |

92 | {bytesToSize(sendFile.size)} 93 |

94 |
95 |
96 | )} 97 |
103 | { 111 | setCaption(e.target.value); 112 | }} 113 | /> 114 | 122 |
123 |
124 |
125 | ); 126 | } 127 | 128 | export default SendFile; 129 | -------------------------------------------------------------------------------- /client/components/modals/signout.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { setModal } from '../../redux/features/modal'; 4 | 5 | function Logout() { 6 | const dispatch = useDispatch(); 7 | const modal = useSelector((state) => state.modal); 8 | 9 | return ( 10 |
17 |
{ 23 | e.stopPropagation(); 24 | }} 25 | > 26 |

Sign out

27 |

Are you sure you want to sign out?

28 | 29 | 38 | 55 | 56 |
57 |
58 | ); 59 | } 60 | 61 | export default Logout; 62 | -------------------------------------------------------------------------------- /client/components/modals/webcam.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import * as md from 'react-icons/md'; 4 | import { setModal } from '../../redux/features/modal'; 5 | 6 | function WebCam() { 7 | const dispatch = useDispatch(); 8 | const modal = useSelector((state) => state.modal); 9 | 10 | const [videoStreamTrack, setVideoStreamTrack] = useState(null); 11 | 12 | const handleStart = async () => { 13 | try { 14 | const wrap = document.querySelector('#webcam #video-wrap'); 15 | 16 | if (modal.webcam) { 17 | const stream = await navigator.mediaDevices.getUserMedia({ 18 | audio: false, 19 | video: true, 20 | }); 21 | const track = stream.getVideoTracks()[0]; 22 | 23 | setVideoStreamTrack(track); 24 | const video = document.createElement('video'); 25 | wrap.append(video); 26 | 27 | video.srcObject = stream; 28 | video.style.transform = 'rotateY(180deg)'; 29 | video.play(); 30 | } 31 | 32 | if (!modal.webcam && videoStreamTrack) { 33 | // remove html video element 34 | wrap.querySelector('video').remove(); 35 | // stop track 36 | videoStreamTrack.stop(); 37 | } 38 | } catch (error0) { 39 | console.error(error0.message); 40 | } 41 | }; 42 | 43 | const handleSubmit = () => { 44 | const video = document.querySelector('#webcam').querySelector('video'); 45 | const canvas = document.createElement('canvas'); 46 | 47 | canvas.width = video.videoWidth; 48 | canvas.height = video.videoHeight; 49 | 50 | const ctx = canvas.getContext('2d'); 51 | // flip horizontally 52 | ctx.scale(-1, 1); 53 | ctx.translate(-canvas.width, 0); 54 | ctx.drawImage(video, 0, 0, canvas.width, canvas.height); 55 | 56 | const src = canvas.toDataURL(); 57 | dispatch( 58 | setModal({ 59 | target: 'imageCropper', 60 | data: { src, back: 'avatarUpload' }, 61 | }) 62 | ); 63 | }; 64 | 65 | useEffect(() => { 66 | handleStart(); 67 | }, [modal.webcam]); 68 | 69 | return ( 70 |
{ 79 | e.stopPropagation(); 80 | dispatch(setModal({ target: modal.webcam.back })); 81 | }} 82 | > 83 |
{ 89 | e.stopPropagation(); 90 | }} 91 | > 92 |
93 |
94 | 103 |
104 |
105 |
106 | ); 107 | } 108 | 109 | export default WebCam; 110 | -------------------------------------------------------------------------------- /client/config.js: -------------------------------------------------------------------------------- 1 | const isDev = process.env.NODE_ENV === 'development'; 2 | 3 | export default { 4 | isDev, 5 | brandName: 'LeChat', 6 | avatarUploadLimit: 2 * 1024 * 1024, // 2 MB 7 | }; 8 | -------------------------------------------------------------------------------- /client/containers/chat/foreground.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import axios from 'axios'; 4 | import * as bi from 'react-icons/bi'; 5 | import * as fg from '../../components/chat/foreground'; 6 | import * as page from '../../pages'; 7 | 8 | function ForeGround() { 9 | const chatRoom = useSelector((state) => state.room.chat); 10 | const refreshInbox = useSelector((state) => state.chore.refreshInbox); 11 | 12 | const [inboxes, setInboxes] = useState(null); 13 | const [search, setSearch] = useState(''); 14 | 15 | const handleGetInboxes = async (signal) => { 16 | try { 17 | setInboxes(null); 18 | 19 | const { data } = await axios.get('/inboxes', { 20 | params: { search }, 21 | signal, 22 | }); 23 | setInboxes(data.payload); 24 | } catch (error0) { 25 | console.error(error0.response.data.message); 26 | } 27 | }; 28 | 29 | useEffect(() => { 30 | const abortCtrl = new AbortController(); 31 | handleGetInboxes(abortCtrl.signal); 32 | 33 | return () => { 34 | abortCtrl.abort(); 35 | }; 36 | }, [refreshInbox, search]); 37 | 38 | return ( 39 |
44 | { 45 | // loading animation 46 | !inboxes && ( 47 |
48 | 49 | 50 | 51 | 52 |

Loading

53 |
54 |
55 | ) 56 | } 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
67 | ); 68 | } 69 | 70 | export default ForeGround; 71 | -------------------------------------------------------------------------------- /client/containers/chat/room.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import axios from 'axios'; 4 | import * as md from 'react-icons/md'; 5 | 6 | import { setSelectedChats } from '../../redux/features/chore'; 7 | import socket from '../../helpers/socket'; 8 | import config from '../../config'; 9 | 10 | import * as comp from '../../components/chat/room'; 11 | import FriendProfile from '../../pages/friendProfile'; 12 | import GroupProfile from '../../pages/groupProfile'; 13 | import GroupParticipant from '../../pages/groupParticipant'; 14 | import AddParticipant from '../../pages/addParticipant'; 15 | 16 | import { setPage } from '../../redux/features/page'; 17 | 18 | function Room() { 19 | const dispatch = useDispatch(); 20 | const { 21 | user: { master }, 22 | room: { chat: chatRoom }, 23 | page, 24 | } = useSelector((state) => state); 25 | 26 | const [prevRoom, setPrevRoom] = useState(null); 27 | const [loaded, setLoaded] = useState(false); 28 | const [chats, setChats] = useState(null); 29 | const [newMessage, setNewMessage] = useState(0); 30 | const [control, setControl] = useState({ skip: 0, limit: 20 }); 31 | 32 | const handleGetChats = async (signal) => { 33 | try { 34 | const { data } = await axios.get(`/chats/${chatRoom.data.roomId}`, { 35 | params: { skip: 0, limit: control.limit }, 36 | signal, 37 | }); 38 | 39 | if (data.payload.length > 0) { 40 | setChats(data.payload); 41 | 42 | const callback = (mutationlist, observer) => { 43 | const monitor = document.querySelector('#monitor'); 44 | monitor.scrollTop = monitor.scrollHeight; 45 | 46 | setLoaded(true); 47 | 48 | observer.disconnect(); 49 | }; 50 | 51 | const observer = new MutationObserver(callback); 52 | 53 | const elem = document.querySelector('#monitor-content'); 54 | observer.observe(elem, { childList: true }); 55 | 56 | return; 57 | } 58 | 59 | setLoaded(true); 60 | } catch (error0) { 61 | console.error(error0.response.data.message); 62 | } 63 | }; 64 | 65 | const handleOpenRoom = async (signal) => { 66 | setLoaded(false); 67 | setControl({ skip: 0, limit: 20 }); 68 | setChats(null); 69 | dispatch(setSelectedChats(null)); 70 | dispatch(setPage({ target: 'friendProfile', data: false })); 71 | dispatch(setPage({ target: 'groupProfile', data: false })); 72 | dispatch(setPage({ target: 'groupParticipant', data: false })); 73 | dispatch(setPage({ target: 'addParticipant', data: false })); 74 | 75 | if (chatRoom.isOpen) { 76 | const { roomType, group, roomId } = chatRoom.data; 77 | const isGroup = roomType === 'group'; 78 | 79 | if (!isGroup || (isGroup && group.participantsId.includes(master._id))) { 80 | socket.emit('room/open', { prevRoom, newRoom: roomId }); 81 | // get messages 82 | await handleGetChats(signal); 83 | } else { 84 | await handleGetChats(signal); 85 | } 86 | } 87 | }; 88 | 89 | useEffect(() => { 90 | const abortCtrl = new AbortController(); 91 | handleOpenRoom(abortCtrl.signal); 92 | 93 | return () => { 94 | abortCtrl.abort(); 95 | }; 96 | }, [chatRoom.isOpen, chatRoom.refreshId]); 97 | 98 | useEffect(() => { 99 | socket.on('room/open', (args) => setPrevRoom(args)); 100 | 101 | return () => { 102 | socket.off('room/open'); 103 | }; 104 | }, []); 105 | 106 | return ( 107 |
114 | {chatRoom.data && ( 115 | <> 116 |
122 | 123 | 132 | 137 |
138 | 139 | 140 | 141 | 142 | 143 | )} 144 | {!chatRoom.data && ( 145 |
146 |
147 | 148 | 149 | 150 |

151 | {'You can use '} 152 | {config.brandName} 153 | {' on other devices such as desktop, tablet, and mobile phone.'} 154 |

155 |
156 |
157 | )} 158 |
159 | ); 160 | } 161 | 162 | export default Room; 163 | -------------------------------------------------------------------------------- /client/helpers/base64Encode.js: -------------------------------------------------------------------------------- 1 | const base64Encode = (file) => 2 | new Promise((resolve, reject) => { 3 | const reader = new FileReader(); 4 | reader.readAsDataURL(file); 5 | 6 | reader.onload = () => resolve(reader.result); 7 | reader.onerror = (error) => reject(error); 8 | }); 9 | 10 | export default base64Encode; 11 | -------------------------------------------------------------------------------- /client/helpers/bytesToSize.js: -------------------------------------------------------------------------------- 1 | function bytesToSize(bytes) { 2 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; 3 | if (bytes === 0) return '0 Byte'; 4 | 5 | const i = Number(Math.floor(Math.log(bytes) / Math.log(1024))); 6 | return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`; 7 | } 8 | 9 | export default bytesToSize; 10 | -------------------------------------------------------------------------------- /client/helpers/notification.js: -------------------------------------------------------------------------------- 1 | export default async ({ title, body, icon }) => { 2 | try { 3 | const errData = {}; 4 | 5 | if (!('Notification' in window)) { 6 | // check if the browser supports notifications 7 | errData.message = 'This browser does not support desktop notification'; 8 | throw errData; 9 | } 10 | 11 | if ( 12 | document.visibilityState === 'hidden' && 13 | Notification.permission === 'granted' 14 | ) { 15 | const notif = new Notification(title, { body, icon }); 16 | 17 | notif.onerror = (error1) => { 18 | throw error1; 19 | }; 20 | } else { 21 | // ask the user for permission 22 | await Notification.requestPermission(); 23 | } 24 | } catch (error0) { 25 | console.error(error0.message); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /client/helpers/socket.js: -------------------------------------------------------------------------------- 1 | import { io } from 'socket.io-client'; 2 | 3 | const isDev = process.env.NODE_ENV === 'development'; 4 | 5 | const socket = io(isDev ? 'ws://localhost:8080' : '/'); 6 | export default socket; 7 | -------------------------------------------------------------------------------- /client/helpers/touchAndHold.js: -------------------------------------------------------------------------------- 1 | let interval = 0; 2 | 3 | export const touchAndHoldStart = (callback) => { 4 | let i = 1; 5 | 6 | interval = setInterval(() => { 7 | if (i >= 3) { 8 | clearInterval(interval); 9 | callback(); 10 | 11 | return; 12 | } 13 | 14 | i += 1; 15 | }, 300); 16 | }; 17 | 18 | export const touchAndHoldEnd = () => { 19 | clearInterval(interval); 20 | }; 21 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | LeChat 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /client/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // React 18x root APIs 3 | import * as ReactDOM from 'react-dom/client'; 4 | // redux 5 | import { Provider } from 'react-redux'; 6 | import store from './redux/store'; 7 | import App from './app'; 8 | 9 | const root = ReactDOM.createRoot(document.querySelector('#root')); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /client/pages/friendProfile.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import moment from 'moment'; 4 | import axios from 'axios'; 5 | import * as bi from 'react-icons/bi'; 6 | import * as ri from 'react-icons/ri'; 7 | 8 | import { setModal } from '../redux/features/modal'; 9 | import { setPage } from '../redux/features/page'; 10 | 11 | function FriendProfile() { 12 | const dispatch = useDispatch(); 13 | const { 14 | chore: { refreshFriendProfile }, 15 | page: { friendProfile }, 16 | } = useSelector((state) => state); 17 | 18 | const [profile, setProfile] = useState(null); 19 | 20 | const handleGetProfile = async (signal) => { 21 | try { 22 | // get profile if profile page is opened 23 | if (friendProfile) { 24 | const { data } = await axios.get(`/profiles/${friendProfile}`, { 25 | signal, 26 | }); 27 | setProfile(data.payload); 28 | } else { 29 | // reset when profile page is closed after 150ms 30 | setTimeout(() => setProfile(null), 150); 31 | } 32 | } catch (error0) { 33 | console.error(error0.message); 34 | } 35 | }; 36 | 37 | useEffect(() => { 38 | const abortCtrl = new AbortController(); 39 | handleGetProfile(abortCtrl.signal); 40 | 41 | return () => { 42 | abortCtrl.abort(); 43 | }; 44 | }, [friendProfile, refreshFriendProfile]); 45 | 46 | return ( 47 |
54 | { 55 | // loading animation 56 | !profile && ( 57 |
58 | 59 | 60 | 61 | 62 |

Loading

63 |
64 |
65 | ) 66 | } 67 | {/* header */} 68 |
69 |
70 | 80 |

Profile

81 |
82 | {profile && !profile.saved && ( 83 | 101 | )} 102 |
103 | {profile && ( 104 |
105 |
106 | { 112 | e.stopPropagation(); 113 | dispatch( 114 | setModal({ 115 | target: 'photoFull', 116 | data: profile.avatar || 'assets/images/default-avatar.png', 117 | }) 118 | ); 119 | }} 120 | /> 121 |
122 |

123 | {profile.fullname} 124 |

125 |

126 | {profile.online 127 | ? 'online' 128 | : `last seen ${moment(profile.updatedAt).fromNow()}`} 129 |

130 |
131 |
132 |
133 | {[ 134 | { label: 'Username', data: profile.username, icon: }, 135 | { label: 'Bio', data: profile.bio, icon: }, 136 | { label: 'Phone', data: profile.phone, icon: }, 137 | { label: 'Email', data: profile.email, icon: }, 138 | ].map((elem) => ( 139 |
143 | {elem.icon} 144 | 145 |

{elem.label}

146 |

{elem.data}

147 |
148 |
149 | ))} 150 |
151 | {profile.saved && ( 152 |
153 | 172 |
173 | )} 174 |
175 | )} 176 |
177 | ); 178 | } 179 | 180 | export default FriendProfile; 181 | -------------------------------------------------------------------------------- /client/pages/groupParticipant.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import axios from 'axios'; 4 | import * as bi from 'react-icons/bi'; 5 | import GroupContextMenu from '../components/modals/groupContextMenu'; 6 | 7 | import { touchAndHoldStart, touchAndHoldEnd } from '../helpers/touchAndHold'; 8 | import { setPage } from '../redux/features/page'; 9 | import { setModal } from '../redux/features/modal'; 10 | 11 | function GroupParticipant() { 12 | const dispatch = useDispatch(); 13 | const { 14 | user: { master }, 15 | page: { groupParticipant }, 16 | modal, 17 | } = useSelector((state) => state); 18 | 19 | const [control, setControl] = useState({ skip: 0, limit: 20 }); 20 | const [participants, setParticipants] = useState(null); 21 | 22 | const handleGetParticipants = async (signal) => { 23 | try { 24 | if (groupParticipant) { 25 | // get participants with pagination control 26 | const { data } = await axios.get( 27 | `/groups/${groupParticipant._id}/participants`, 28 | { params: control, signal } 29 | ); 30 | 31 | setParticipants((prev) => { 32 | // merge new participants data 33 | if (prev) return [...prev, ...data.payload]; 34 | return data.payload; 35 | }); 36 | } else { 37 | // reset participants element 38 | setTimeout(() => setParticipants(null), 150); 39 | } 40 | } catch (error0) { 41 | console.error(error0.message); 42 | } 43 | }; 44 | 45 | const handleContextMenu = (e, elem) => { 46 | if (elem.userId !== master._id && groupParticipant.adminId === master._id) { 47 | const parent = document.querySelector('#group-participant'); 48 | 49 | const y = 50 | e.clientY > window.innerHeight / 2 ? e.clientY - 136 : e.clientY; 51 | const x = e.clientX - (window.innerWidth - parent.clientWidth); 52 | 53 | dispatch( 54 | setModal({ 55 | target: 'groupContextMenu', 56 | data: { 57 | group: groupParticipant, 58 | user: elem, 59 | x: x > parent.clientWidth / 2 ? x - 160 : x, 60 | y, 61 | }, 62 | }) 63 | ); 64 | } 65 | }; 66 | 67 | useEffect(() => { 68 | const abortCtrl = new AbortController(); 69 | handleGetParticipants(abortCtrl.signal); 70 | 71 | return () => { 72 | abortCtrl.abort(); 73 | }; 74 | }, [!!groupParticipant, control]); 75 | 76 | return ( 77 |
85 | {/* group context menu */} 86 | {groupParticipant && modal.groupContextMenu && } 87 | {/* header */} 88 |
89 |
90 | 101 |

Participants

102 |
103 |
104 |
105 | {participants && 106 | participants.map((elem) => ( 107 |
{ 121 | if (master._id !== elem.userId) { 122 | dispatch( 123 | setPage({ 124 | target: 'friendProfile', 125 | data: elem.userId, 126 | }) 127 | ); 128 | } 129 | }} 130 | onContextMenu={(e) => { 131 | e.stopPropagation(); 132 | e.preventDefault(); 133 | 134 | handleContextMenu(e, elem); 135 | }} 136 | onTouchStart={(e) => { 137 | touchAndHoldStart(() => handleContextMenu(e, elem)); 138 | }} 139 | onTouchMove={() => touchAndHoldEnd()} 140 | onTouchEnd={() => touchAndHoldEnd()} 141 | > 142 | 147 | 148 |

149 | {elem.fullname} 150 | 151 | {master._id === elem.userId && '~You'} 152 | 153 |

154 |

{elem.bio}

155 |
156 | {/* admin tag */} 157 | {elem.userId === groupParticipant.adminId && ( 158 | 159 |

160 | Admin 161 |

162 |
163 | )} 164 |
165 | ))} 166 | {groupParticipant && 167 | participants && 168 | participants.length < groupParticipant.participantsId.length && ( 169 | 186 | )} 187 |
188 |
189 | ); 190 | } 191 | 192 | export default GroupParticipant; 193 | -------------------------------------------------------------------------------- /client/public/assets/images/default-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/febriadj/messaging-app/2644bc9b6bb44bfade9939879f2d761a2370d188/client/public/assets/images/default-avatar.png -------------------------------------------------------------------------------- /client/public/assets/images/default-group-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/febriadj/messaging-app/2644bc9b6bb44bfade9939879f2d761a2370d188/client/public/assets/images/default-group-avatar.png -------------------------------------------------------------------------------- /client/public/assets/images/error.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/febriadj/messaging-app/2644bc9b6bb44bfade9939879f2d761a2370d188/client/public/assets/images/error.ico -------------------------------------------------------------------------------- /client/public/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/febriadj/messaging-app/2644bc9b6bb44bfade9939879f2d761a2370d188/client/public/assets/images/favicon.ico -------------------------------------------------------------------------------- /client/public/assets/sound/default-ringtone.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/febriadj/messaging-app/2644bc9b6bb44bfade9939879f2d761a2370d188/client/public/assets/sound/default-ringtone.mp3 -------------------------------------------------------------------------------- /client/redux/features/chore.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const ChoreSlice = createSlice({ 4 | name: 'chore', 5 | initialState: { 6 | selectedParticipants: [], 7 | selectedChats: null, 8 | refreshFriendProfile: null, 9 | refreshAvatar: null, 10 | refreshGroupAvatar: null, 11 | refreshContact: null, 12 | refreshInbox: null, 13 | }, 14 | reducers: { 15 | /* eslint-disable no-param-reassign */ 16 | setSelectedParticipants(state, action) { 17 | state.selectedParticipants = action.payload ?? []; 18 | }, 19 | setSelectedChats(state, action) { 20 | const str = typeof action.payload === 'string'; 21 | 22 | if (str) { 23 | const chats = state.selectedChats ?? []; 24 | 25 | state.selectedChats = !chats.includes(action.payload) 26 | ? [...chats, action.payload] 27 | : chats.filter((el) => el !== action.payload); 28 | } else { 29 | state.selectedChats = action.payload; 30 | } 31 | }, 32 | setRefreshFriendProfile(state, action) { 33 | state.refreshFriendProfile = action.payload; 34 | }, 35 | setRefreshAvatar(state, action) { 36 | state.refreshAvatar = action.payload; 37 | }, 38 | setRefreshGroupAvatar(state, action) { 39 | state.refreshGroupAvatar = action.payload; 40 | }, 41 | setRefreshContact(state, action) { 42 | state.refreshContact = action.payload; 43 | }, 44 | setRefreshInbox(state, action) { 45 | state.refreshInbox = action.payload; 46 | }, 47 | /* eslint-enable no-param-reassign */ 48 | }, 49 | }); 50 | 51 | export const { 52 | setSelectedParticipants, 53 | setSelectedChats, 54 | setRefreshFriendProfile, 55 | setRefreshAvatar, 56 | setRefreshGroupAvatar, 57 | setRefreshContact, 58 | setRefreshInbox, 59 | } = ChoreSlice.actions; 60 | 61 | export default ChoreSlice.reducer; 62 | -------------------------------------------------------------------------------- /client/redux/features/modal.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const ModalSlice = createSlice({ 4 | name: 'modal', 5 | initialState: { 6 | minibox: false, 7 | signout: false, 8 | newcontact: false, 9 | changePass: false, 10 | deleteAcc: false, 11 | qr: false, 12 | newGroup: false, 13 | avatarUpload: false, 14 | imageCropper: false, // -> { src: String, back: String | null } 15 | webcam: false, // -> { back: String } 16 | photoFull: false, 17 | confirmDeleteChat: false, 18 | sendFile: false, 19 | attachMenu: false, 20 | confirmAddParticipant: false, 21 | roomHeaderMenu: false, 22 | editGroup: false, 23 | confirmExitGroup: false, 24 | confirmDeleteContact: false, 25 | inboxMenu: false, 26 | confirmDeleteChatAndInbox: false, 27 | groupContextMenu: false, 28 | }, 29 | reducers: { 30 | /* eslint-disable no-param-reassign */ 31 | setModal(state, action) { 32 | const { target = '*', data = null } = action.payload; 33 | 34 | if (target) { 35 | Object.keys(state).forEach((key) => { 36 | if (target === key) { 37 | state[target] = data ?? !state[target]; 38 | } else { 39 | state[key] = false; 40 | } 41 | }); 42 | } 43 | }, 44 | /* eslint-enable no-param-reassign */ 45 | }, 46 | }); 47 | 48 | export const { setModal } = ModalSlice.actions; 49 | export default ModalSlice.reducer; 50 | -------------------------------------------------------------------------------- /client/redux/features/page.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const PageSlice = createSlice({ 4 | name: 'page', 5 | initialState: { 6 | profile: false, 7 | contact: false, 8 | setting: false, 9 | selectParticipant: false, 10 | friendProfile: false, 11 | groupProfile: false, 12 | groupParticipant: false, 13 | addParticipant: false, 14 | }, 15 | reducers: { 16 | /* eslint-disable no-param-reassign */ 17 | setPage(state, action) { 18 | const { target = null, data = null } = action.payload; 19 | state[target] = data ?? !state[target]; 20 | }, 21 | /* eslint-enable no-param-reassign */ 22 | }, 23 | }); 24 | 25 | export const { setPage } = PageSlice.actions; 26 | export default PageSlice.reducer; 27 | -------------------------------------------------------------------------------- /client/redux/features/room.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const RoomSlice = createSlice({ 4 | name: 'room', 5 | initialState: { 6 | chat: { 7 | isOpen: false, 8 | refreshId: null, 9 | data: null, 10 | }, 11 | }, 12 | reducers: { 13 | /* eslint-disable no-param-reassign */ 14 | setChatRoom(state, action) { 15 | const { isOpen, refreshId, data } = action.payload; 16 | 17 | state.chat.isOpen = isOpen; 18 | state.chat.refreshId = refreshId; 19 | state.chat.data = data; 20 | }, 21 | /* eslint-enable no-param-reassign */ 22 | }, 23 | }); 24 | 25 | export const { setChatRoom } = RoomSlice.actions; 26 | export default RoomSlice.reducer; 27 | -------------------------------------------------------------------------------- /client/redux/features/user.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | const UserSlice = createSlice({ 4 | name: 'user', 5 | initialState: { 6 | master: null, 7 | setting: null, 8 | }, 9 | reducers: { 10 | /* eslint-disable no-param-reassign */ 11 | setMaster(state, action) { 12 | state.master = action.payload; 13 | }, 14 | setSetting(state, action) { 15 | state.setting = action.payload; 16 | }, 17 | /* eslint-enable no-param-reassign */ 18 | }, 19 | }); 20 | 21 | export const { setMaster, setSetting } = UserSlice.actions; 22 | export default UserSlice.reducer; 23 | -------------------------------------------------------------------------------- /client/redux/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import * as reducer from './features'; 3 | 4 | const store = configureStore({ reducer }); 5 | export default store; 6 | -------------------------------------------------------------------------------- /client/routes/auth.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import * as comp from '../components/auth'; 3 | import config from '../config'; 4 | 5 | function Auth() { 6 | const [respond, setRespond] = useState({ success: true, message: null }); 7 | const [login, setLogin] = useState(true); 8 | 9 | return ( 10 |
11 |
12 |

13 | {config.brandName} 14 |

15 | {/* body */} 16 |
17 | {/* header */} 18 |
19 |

20 | {login ? 'Sign in' : 'Sign up'} 21 |

22 | {respond.message && ( 23 |

28 | {respond.message} 29 |

30 | )} 31 |
32 | {login ? ( 33 | 34 | ) : ( 35 | 36 | )} 37 |
38 |
39 |

40 | 41 | {login ? "Don't have an account? " : 'Have an account? '} 42 | 43 | 53 |

54 |
55 |
56 |
57 | ); 58 | } 59 | 60 | export default Auth; 61 | -------------------------------------------------------------------------------- /client/routes/chat.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { Helmet } from 'react-helmet'; 4 | import { setModal } from '../redux/features/modal'; 5 | import * as cont from '../containers/chat'; 6 | import * as modal from '../components/modals'; 7 | import config from '../config'; 8 | 9 | function Chat() { 10 | const dispatch = useDispatch(); 11 | const imageCropper = useSelector((state) => state.modal.imageCropper); 12 | const master = useSelector((state) => state.user.master); 13 | 14 | const requestNotification = async () => { 15 | if (Notification.permission !== 'granted') { 16 | // ask the user for permission 17 | await Notification.requestPermission(); 18 | } 19 | }; 20 | 21 | useEffect(() => { 22 | requestNotification(); 23 | 24 | window.history.pushState(null, '', window.location.href); 25 | window.addEventListener('popstate', () => { 26 | window.history.pushState(null, '', window.location.href); 27 | }); 28 | }, []); 29 | 30 | return ( 31 |
{ 35 | // close all modals 36 | dispatch(setModal({ target: '*' })); 37 | }} 38 | > 39 | 40 | {`@${master.username} - ${config.brandName}`} 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | {imageCropper && } 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |
64 | ); 65 | } 66 | 67 | export default Chat; 68 | -------------------------------------------------------------------------------- /client/routes/inactive.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Helmet } from 'react-helmet'; 3 | import config from '../config'; 4 | 5 | function Inactive() { 6 | return ( 7 |
8 | 9 | {`${config.brandName} [inactive]`} 10 | 15 | 16 |
17 |

Error!

18 |

19 | {config.brandName} 20 | {' is open in another window. Click "Use Here" to use '} 21 | {config.brandName} 22 | {' in this window.'} 23 |

24 | 25 | 32 | 33 |
34 |
35 | ); 36 | } 37 | 38 | export default Inactive; 39 | -------------------------------------------------------------------------------- /client/routes/verify.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import axios from 'axios'; 4 | import * as bi from 'react-icons/bi'; 5 | import { setMaster } from '../redux/features/user'; 6 | import config from '../config'; 7 | 8 | function Verify() { 9 | const dispatch = useDispatch(); 10 | const master = useSelector((state) => state.user.master); 11 | 12 | const [respond, setRespond] = useState({ success: true }); 13 | const [otp, setOtp] = useState({ 14 | 0: '', 15 | 1: '', 16 | 2: '', 17 | 3: '', 18 | }); 19 | 20 | const handleSubmit = async (e) => { 21 | try { 22 | e.preventDefault(); 23 | 24 | const { data } = await axios.post('/users/verify', { 25 | userId: master._id, 26 | otp: Number(Object.values(otp).join('')), 27 | }); 28 | 29 | setOtp({ 30 | 0: '', 31 | 1: '', 32 | 2: '', 33 | 3: '', 34 | }); 35 | 36 | setRespond({ success: true }); 37 | dispatch(setMaster(data.payload)); 38 | 39 | setTimeout(() => { 40 | window.location.reload(); 41 | }, 500); 42 | } catch (error0) { 43 | setRespond({ success: false }); 44 | } 45 | }; 46 | 47 | return ( 48 |
49 |
50 |

51 | {config.brandName} 52 |

53 | {/* body */} 54 |
55 |
56 |

OTP Verification

57 |

58 | Please enter the OTP verification code sent to 59 | {master.email} 60 |

61 |
62 | {/* form */} 63 |
64 |
65 | {[...Object.keys(otp)].map((elem, i) => ( 66 | { 77 | const del = e.key === 'Backspace' || e.key === 'Delete'; 78 | const previous = e.target.previousSibling; 79 | 80 | // numbers only 81 | if (!'0123456789'.includes(e.key)) { 82 | // ignore the next event 83 | e.preventDefault(); 84 | } 85 | 86 | // if the backspace and delete keys are clicked 87 | if (del) { 88 | setOtp((prev) => ({ ...prev, [elem]: '' })); 89 | if (previous) previous.focus(); 90 | 91 | // ignore the next event 92 | e.preventDefault(); 93 | } 94 | }} 95 | onChange={(e) => { 96 | setRespond({ success: true }); 97 | setOtp((prev) => ({ ...prev, [elem]: e.target.value })); 98 | 99 | const next = e.target.nextSibling; 100 | if (next) next.focus(); 101 | }} 102 | /> 103 | ))} 104 |
105 | 111 | 117 |
118 |
119 |
120 | 133 |
134 |
135 |
136 | ); 137 | } 138 | 139 | export default Verify; 140 | -------------------------------------------------------------------------------- /client/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import url('https://fonts.googleapis.com/css2?family=Lobster+Two:wght@700&family=Roboto:wght@400;700&display=swap'); 6 | 7 | * { 8 | border: none; 9 | outline: none; 10 | font-family: 'Roboto', sans-serif; 11 | } 12 | 13 | body { 14 | user-select: none; 15 | } 16 | 17 | input:-webkit-autofill { 18 | transition: 'color 9999s ease-out, background-color 9999s ease-out'; 19 | transition-delay: 9999s; 20 | } 21 | 22 | svg { 23 | font-size: x-large; 24 | } 25 | input, 26 | textarea { 27 | background: transparent; 28 | } 29 | 30 | a { 31 | text-decoration: underline; 32 | color: #0c4a6e; 33 | } 34 | .dark a { 35 | color: #bae6fd; 36 | } 37 | -------------------------------------------------------------------------------- /docs/img/dark-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/febriadj/messaging-app/2644bc9b6bb44bfade9939879f2d761a2370d188/docs/img/dark-desktop.png -------------------------------------------------------------------------------- /docs/img/light-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/febriadj/messaging-app/2644bc9b6bb44bfade9939879f2d761a2370d188/docs/img/light-desktop.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lechat", 3 | "version": "1.0.0", 4 | "description": "LeChat is an instant messaging app that allows you to quickly share text messages, emojis, photos, or files with other LeChat users", 5 | "main": "server/index.js", 6 | "private": true, 7 | "engines": { 8 | "vscode": "1.22.0", 9 | "npm": "8.19.2", 10 | "node": "16.17.1" 11 | }, 12 | "scripts": { 13 | "dev:client": "webpack serve --config ./webpack.dev.js", 14 | "dev:server": "nodemon server", 15 | "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", 16 | "build": "webpack --config ./webpack.prod.js", 17 | "start": "node server", 18 | "prepare": "husky install", 19 | "format": "prettier --write ." 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/febriadj/lechat.git" 24 | }, 25 | "keywords": [ 26 | "lechat", 27 | "mern", 28 | "social-media", 29 | "socket.io", 30 | "websocket" 31 | ], 32 | "author": "Febriadji", 33 | "license": "GPL-3.0", 34 | "bugs": { 35 | "url": "https://github.com/febriadj/lechat/issues" 36 | }, 37 | "homepage": "https://github.com/febriadj/lechat#readme", 38 | "devDependencies": { 39 | "@babel/core": "^7.19.3", 40 | "@babel/plugin-transform-runtime": "^7.19.1", 41 | "@babel/preset-env": "^7.19.4", 42 | "@babel/preset-react": "^7.18.6", 43 | "@commitlint/cli": "^17.4.4", 44 | "@commitlint/config-conventional": "^17.4.4", 45 | "autoprefixer": "^10.4.12", 46 | "babel-loader": "^8.2.5", 47 | "babel-plugin-wildcard": "^7.0.0", 48 | "css-loader": "^6.7.1", 49 | "eslint": "^8.25.0", 50 | "eslint-config-airbnb": "^19.0.4", 51 | "eslint-config-prettier": "^8.7.0", 52 | "eslint-plugin-import": "^2.26.0", 53 | "eslint-plugin-jsx-a11y": "^6.6.1", 54 | "eslint-plugin-prettier": "^4.2.1", 55 | "eslint-plugin-react": "^7.31.10", 56 | "eslint-plugin-react-hooks": "^4.6.0", 57 | "file-loader": "^6.2.0", 58 | "html-webpack-plugin": "^5.5.0", 59 | "husky": "^8.0.3", 60 | "nodemon": "^2.0.20", 61 | "postcss-cli": "^10.0.0", 62 | "postcss-loader": "^7.0.1", 63 | "prettier": "^2.8.4", 64 | "style-loader": "^3.3.1", 65 | "webpack": "^5.74.0", 66 | "webpack-cli": "^4.10.0", 67 | "webpack-dev-server": "^4.11.1" 68 | }, 69 | "dependencies": { 70 | "@reduxjs/toolkit": "^1.8.6", 71 | "axios": "^1.1.3", 72 | "bcrypt": "^5.1.0", 73 | "cloudinary": "^1.32.0", 74 | "concurrently": "^7.4.0", 75 | "cors": "^2.8.5", 76 | "dotenv": "^16.0.3", 77 | "express": "^4.18.2", 78 | "jsonwebtoken": "^8.5.1", 79 | "linkify": "^0.2.1", 80 | "linkify-react": "^4.0.2", 81 | "moment": "^2.29.4", 82 | "mongoose": "^6.6.5", 83 | "nodemailer": "^6.8.0", 84 | "qrcode": "^1.5.1", 85 | "react": "^18.2.0", 86 | "react-dom": "^18.2.0", 87 | "react-easy-crop": "^4.6.2", 88 | "react-helmet": "^6.1.0", 89 | "react-icons": "^4.6.0", 90 | "react-redux": "^8.0.4", 91 | "react-router-dom": "^6.4.2", 92 | "socket.io": "^4.5.3", 93 | "socket.io-client": "^4.5.3", 94 | "tailwind-scrollbar": "^2.1.0-preview.0", 95 | "tailwindcss": "^3.1.8", 96 | "uuid": "^9.0.0" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwind = require('tailwindcss'); 2 | const autoprefixer = require('autoprefixer'); 3 | 4 | module.exports = { 5 | plugins: [tailwind('./tailwind.config.js'), autoprefixer], 6 | }; 7 | -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | const isDev = process.env.NODE_ENV === 'development'; 2 | 3 | module.exports = { 4 | isDev, 5 | cors: { 6 | origin: ['http://localhost:3000'], 7 | }, 8 | db: { 9 | uri: process.env.MONGO_URI, 10 | name: 'lechat', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /server/controllers/avatar.js: -------------------------------------------------------------------------------- 1 | const { v2: cloud } = require('cloudinary'); 2 | const ProfileModel = require('../db/models/profile'); 3 | const GroupModel = require('../db/models/group'); 4 | const response = require('../helpers/response'); 5 | 6 | exports.upload = async (req, res) => { 7 | try { 8 | const { avatar, crop, zoom, targetId = null, isGroup = false } = req.body; 9 | 10 | const upload = await cloud.uploader.upload(avatar, { 11 | folder: 'avatars', 12 | public_id: targetId || req.user._id, 13 | overwrite: true, 14 | transformation: [ 15 | { 16 | crop: 'crop', 17 | x: Math.round(crop.x), 18 | y: Math.round(crop.y), 19 | width: Math.round(crop.width), 20 | height: Math.round(crop.height), 21 | zoom, 22 | }, 23 | { 24 | crop: 'scale', 25 | aspect_ratio: '1.0', 26 | width: 460, 27 | }, 28 | ], 29 | }); 30 | 31 | if (isGroup) { 32 | await GroupModel.updateOne( 33 | { _id: targetId }, 34 | { $set: { avatar: upload.url } } 35 | ); 36 | } else { 37 | await ProfileModel.updateOne( 38 | { userId: targetId || req.user._id }, 39 | { $set: { avatar: upload.url } } 40 | ); 41 | } 42 | 43 | response({ 44 | res, 45 | message: 'Avatar uploaded successfully', 46 | payload: upload.url, 47 | }); 48 | } catch (error0) { 49 | response({ 50 | res, 51 | statusCode: error0.statusCode || 500, 52 | success: false, 53 | message: error0.message, 54 | }); 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /server/controllers/chat.js: -------------------------------------------------------------------------------- 1 | const cloud = require('cloudinary').v2; 2 | 3 | const InboxModel = require('../db/models/inbox'); 4 | const ChatModel = require('../db/models/chat'); 5 | const FileModel = require('../db/models/file'); 6 | 7 | const response = require('../helpers/response'); 8 | const Chat = require('../helpers/models/chats'); 9 | 10 | exports.findByRoomId = async (req, res) => { 11 | try { 12 | const { skip, limit } = req.query; 13 | 14 | const chats = await Chat.find(req.params.roomId, { skip, limit }); 15 | 16 | response({ 17 | res, 18 | message: `${chats.length} chats found`, 19 | payload: chats, 20 | }); 21 | } catch (error0) { 22 | response({ 23 | res, 24 | statusCode: error0.statusCode || 500, 25 | success: false, 26 | message: error0.message, 27 | }); 28 | } 29 | }; 30 | 31 | exports.deleteByRoomId = async (req, res) => { 32 | try { 33 | const { roomId } = req.params; 34 | 35 | // push userId into deletedBy field 36 | const inbox = await InboxModel.findOneAndUpdate( 37 | { roomId }, 38 | { $addToSet: { deletedBy: req.user._id } } 39 | ); 40 | 41 | if (inbox.deletedBy.length + 1 >= inbox.ownersId.length) { 42 | await InboxModel.deleteOne({ roomId }); 43 | await ChatModel.deleteMany({ roomId }); 44 | } else { 45 | await ChatModel.updateMany( 46 | { roomId }, 47 | { $addToSet: { deletedBy: req.user._id } } 48 | ); 49 | } 50 | 51 | const x = await ChatModel.deleteMany({ 52 | roomId, 53 | deletedBy: { $size: inbox.ownersId.length }, 54 | }); 55 | const chats = await ChatModel.find( 56 | { roomId, deletedBy: { $size: inbox.ownersId.length } }, 57 | { fileId: 1 } 58 | ); 59 | 60 | if (x.deletedCount > 0) { 61 | const filesId = chats 62 | .filter((elem) => !!elem.fileId) 63 | .map((elem) => elem.fileId); 64 | 65 | if (filesId.length > 0) { 66 | await FileModel.deleteMany({ roomId, fileId: filesId }); 67 | 68 | await cloud.api.delete_resources(filesId, { resource_type: 'image' }); 69 | await cloud.api.delete_resources(filesId, { resource_type: 'video' }); 70 | await cloud.api.delete_resources(filesId, { resource_type: 'raw' }); 71 | } 72 | } 73 | 74 | response({ 75 | res, 76 | message: 'Chat deleted successfully', 77 | }); 78 | } catch (error0) { 79 | response({ 80 | res, 81 | statusCode: error0.statusCode || 500, 82 | success: false, 83 | message: error0.message, 84 | }); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /server/controllers/contact.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4 } = require('uuid'); 2 | const ProfileModel = require('../db/models/profile'); 3 | const ContactModel = require('../db/models/contact'); 4 | const SettingModel = require('../db/models/setting'); 5 | 6 | const response = require('../helpers/response'); 7 | 8 | exports.insert = async (req, res) => { 9 | try { 10 | const errData = {}; 11 | const { username, fullname } = req.body; 12 | // find friend profile by username 13 | const friend = await ProfileModel.findOne({ username }); 14 | 15 | // if the friend profile not found or 16 | // if the contact has been saved 17 | if ( 18 | !friend || 19 | (await ContactModel.findOne({ 20 | userId: req.user._id, 21 | friendId: friend.userId, 22 | })) 23 | ) { 24 | errData.statusCode = 401; 25 | errData.message = !friend 26 | ? 'User not found' 27 | : 'You have saved this contact'; 28 | 29 | throw errData; 30 | } 31 | 32 | // if my contact has been saved by a friend 33 | const ifSavedByFriend = await ContactModel.findOne({ 34 | userId: friend.userId, 35 | friendId: req.user._id, 36 | }); 37 | 38 | const contact = await new ContactModel({ 39 | userId: req.user._id, 40 | roomId: ifSavedByFriend ? ifSavedByFriend.roomId : uuidv4(), 41 | friendId: friend.userId, 42 | fullname: fullname || friend.fullname, 43 | bio: friend.bio, 44 | avatar: friend.avatar, 45 | }).save(); 46 | 47 | response({ 48 | res, 49 | statusCode: 201, 50 | message: 'Successfully added contact', 51 | payload: contact, 52 | }); 53 | } catch (error0) { 54 | response({ 55 | res, 56 | statusCode: error0.statusCode || 500, 57 | success: false, 58 | message: error0.message, 59 | }); 60 | } 61 | }; 62 | 63 | exports.find = async (req, res) => { 64 | try { 65 | const setting = await SettingModel.findOne( 66 | { userId: req.user._id }, 67 | { sortContactByName: 1 } 68 | ); 69 | 70 | const contacts = await ContactModel.aggregate([ 71 | { $match: { userId: req.user._id } }, 72 | { 73 | $lookup: { 74 | from: 'profiles', 75 | localField: 'friendId', 76 | foreignField: 'userId', 77 | as: 'profile', 78 | }, 79 | }, 80 | { $unwind: '$profile' }, 81 | { 82 | $sort: setting.sortContactByName 83 | ? { 'profile.fullname': 1 } 84 | : { 'profile.updatedAt': -1 }, 85 | }, 86 | ]).collation({ locale: 'en' }); 87 | 88 | response({ 89 | res, 90 | payload: contacts, 91 | }); 92 | } catch (error0) { 93 | response({ 94 | res, 95 | statusCode: error0.statusCode || 500, 96 | success: false, 97 | message: error0.message, 98 | }); 99 | } 100 | }; 101 | 102 | exports.deleteByFriendId = async (req, res) => { 103 | try { 104 | const { friendId } = req.params; 105 | await ContactModel.deleteOne({ userId: req.user._id, friendId }); 106 | 107 | response({ 108 | res, 109 | message: 'Contact deleted successfully', 110 | }); 111 | } catch (error0) { 112 | response({ 113 | res, 114 | statusCode: error0.statusCode || 500, 115 | success: false, 116 | message: error0.message, 117 | }); 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /server/controllers/group.js: -------------------------------------------------------------------------------- 1 | const GroupModel = require('../db/models/group'); 2 | const ProfileModel = require('../db/models/profile'); 3 | 4 | const response = require('../helpers/response'); 5 | 6 | exports.findById = async (req, res) => { 7 | try { 8 | const group = await GroupModel.findOne({ _id: req.params.groupId }); 9 | response({ 10 | res, 11 | payload: group, 12 | }); 13 | } catch (error0) { 14 | response({ 15 | res, 16 | statusCode: error0.statusCode || 500, 17 | success: false, 18 | message: error0.message, 19 | }); 20 | } 21 | }; 22 | 23 | exports.participantsName = async (req, res) => { 24 | try { 25 | const { limit = 10 } = req.query; 26 | 27 | // find group by groupId 28 | const group = await GroupModel.findOne({ _id: req.params.groupId }); 29 | 30 | // find participants 31 | const participants = await ProfileModel.find( 32 | { userId: { $in: group.participantsId } }, 33 | { _id: 0, fullname: 1 } 34 | ) 35 | .sort({ updatedAt: -1 }) 36 | .limit(limit); 37 | 38 | const names = participants.map(({ fullname }) => fullname); 39 | 40 | response({ 41 | res, 42 | payload: names, 43 | }); 44 | } catch (error0) { 45 | response({ 46 | res, 47 | statusCode: error0.statusCode || 500, 48 | success: false, 49 | message: error0.message, 50 | }); 51 | } 52 | }; 53 | 54 | exports.participants = async (req, res) => { 55 | try { 56 | const { skip, limit } = req.query; 57 | // find group by groupId 58 | const group = await GroupModel.findOne({ _id: req.params.groupId }); 59 | // find participants 60 | const participants = await ProfileModel.find({ 61 | userId: { $in: group.participantsId }, 62 | }) 63 | .sort({ updatedAt: -1 }) 64 | .skip(skip) 65 | .limit(limit); 66 | 67 | response({ 68 | res, 69 | payload: participants, 70 | }); 71 | } catch (error0) { 72 | response({ 73 | res, 74 | statusCode: error0.statusCode || 500, 75 | success: false, 76 | message: error0.message, 77 | }); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /server/controllers/inbox.js: -------------------------------------------------------------------------------- 1 | const Inbox = require('../helpers/models/inbox'); 2 | const response = require('../helpers/response'); 3 | 4 | exports.find = async (req, res) => { 5 | try { 6 | const inboxes = await Inbox.find( 7 | { ownersId: req.user._id }, 8 | req.query.search 9 | ); 10 | 11 | response({ 12 | res, 13 | payload: inboxes, 14 | }); 15 | } catch (error0) { 16 | response({ 17 | res, 18 | statusCode: error0.statusCode || 500, 19 | success: false, 20 | message: error0.message, 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /server/controllers/profile.js: -------------------------------------------------------------------------------- 1 | const ProfileModel = require('../db/models/profile'); 2 | const ContactModel = require('../db/models/contact'); 3 | const response = require('../helpers/response'); 4 | 5 | exports.findById = async (req, res) => { 6 | try { 7 | const targetId = req.params.userId; 8 | const friendProfile = targetId !== req.user._id; 9 | 10 | const profile = await ProfileModel.findOne({ userId: targetId }); 11 | 12 | const contact = friendProfile 13 | ? await ContactModel.findOne({ 14 | userId: req.user._id, 15 | friendId: targetId, 16 | }) 17 | : false; 18 | 19 | response({ 20 | res, 21 | payload: { ...profile._doc, saved: !!contact }, 22 | }); 23 | } catch (error0) { 24 | response({ 25 | res, 26 | statusCode: error0.statusCode || 500, 27 | success: false, 28 | message: error0.message, 29 | }); 30 | } 31 | }; 32 | 33 | // edit profile 34 | exports.edit = async (req, res) => { 35 | try { 36 | const profile = await ProfileModel.updateOne( 37 | { userId: req.user._id }, 38 | { $set: req.body } // -> object 39 | ); 40 | 41 | response({ 42 | res, 43 | message: 'Profile updated successfully', 44 | payload: profile, 45 | }); 46 | } catch (error0) { 47 | if (error0.name === 'MongoServerError' && error0.code === 11000) { 48 | switch (Object.keys(req.body)[0]) { 49 | case 'username': 50 | error0.message = 'This username is already taken'; 51 | break; 52 | case 'phone': 53 | error0.message = 'This phone number is already taken'; 54 | break; 55 | default: 56 | break; 57 | } 58 | } 59 | 60 | response({ 61 | res, 62 | statusCode: error0.statusCode || 500, 63 | success: false, 64 | message: error0.message, 65 | }); 66 | } 67 | }; 68 | -------------------------------------------------------------------------------- /server/controllers/setting.js: -------------------------------------------------------------------------------- 1 | const SettingModel = require('../db/models/setting'); 2 | const response = require('../helpers/response'); 3 | 4 | exports.find = async (req, res) => { 5 | try { 6 | const setting = await SettingModel.findOne({ userId: req.user._id }); 7 | response({ 8 | res, 9 | payload: setting, 10 | }); 11 | } catch (error0) { 12 | response({ 13 | res, 14 | statusCode: error0.statusCode || 500, 15 | success: false, 16 | message: error0.message, 17 | }); 18 | } 19 | }; 20 | 21 | exports.update = async (req, res) => { 22 | try { 23 | const setting = await SettingModel.updateOne( 24 | { userId: req.user._id }, 25 | { ...req.body } 26 | ); 27 | 28 | response({ 29 | res, 30 | message: 'Successfully updated account settings', 31 | payload: setting, 32 | }); 33 | } catch (error0) { 34 | response({ 35 | res, 36 | statusCode: error0.statusCode || 500, 37 | success: false, 38 | message: error0.message, 39 | }); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /server/controllers/user.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const jwt = require('jsonwebtoken'); 4 | 5 | const UserModel = require('../db/models/user'); 6 | const ProfileModel = require('../db/models/profile'); 7 | const SettingModel = require('../db/models/setting'); 8 | const GroupModel = require('../db/models/group'); 9 | const ContactModel = require('../db/models/contact'); 10 | 11 | const response = require('../helpers/response'); 12 | const mailer = require('../helpers/mailer'); 13 | 14 | const encrypt = require('../helpers/encrypt'); 15 | const decrypt = require('../helpers/decrypt'); 16 | 17 | exports.register = async (req, res) => { 18 | try { 19 | // generate otp code 20 | const otp = Math.floor(1000 + Math.random() * 9000); 21 | 22 | const { _id: userId } = await new UserModel({ 23 | ...req.body, 24 | password: encrypt(req.body.password), 25 | otp, // -> one-time password 26 | }).save(); 27 | 28 | // account setting 29 | await new SettingModel({ userId }).save(); 30 | // save user data (without password) on profile model 31 | await new ProfileModel({ 32 | ...req.body, 33 | userId, 34 | fullname: req.body.username, 35 | }).save(); 36 | 37 | // generate access token 38 | const token = jwt.sign({ _id: userId }, 'shhhhh'); 39 | const template = fs.readFileSync( 40 | path.resolve(__dirname, '../helpers/templates/otp.html'), 41 | 'utf8' 42 | ); 43 | 44 | // send the OTP/verification code to user's email 45 | await mailer({ 46 | to: req.body.email, 47 | fullname: req.body.username, 48 | subject: 'Please activate your account', 49 | html: template, 50 | otp, 51 | }); 52 | 53 | response({ 54 | res, 55 | statusCode: 201, 56 | message: 'Successfully created a new account', 57 | payload: token, 58 | }); 59 | } catch (error0) { 60 | response({ 61 | res, 62 | statusCode: error0.statusCode || 500, 63 | success: false, 64 | message: error0.message, 65 | }); 66 | } 67 | }; 68 | 69 | exports.verify = async (req, res) => { 70 | try { 71 | const { userId, otp } = req.body; 72 | 73 | // find the user by _id and OTP. 74 | // if the user is found, update the verified and OTP fields 75 | const user = await UserModel.findOneAndUpdate( 76 | { _id: userId, otp }, 77 | { $set: { verified: true, otp: null } } 78 | ); 79 | 80 | // if the user not found 81 | if (!user) { 82 | // send a response as an OTP validation error 83 | const errData = { 84 | message: 'Invalid OTP code', 85 | statusCode: 401, 86 | }; 87 | throw errData; 88 | } 89 | 90 | response({ 91 | res, 92 | message: 'Successfully verified an account', 93 | payload: user, 94 | }); 95 | } catch (error0) { 96 | response({ 97 | res, 98 | statusCode: error0.statusCode || 500, 99 | success: false, 100 | message: error0.message, 101 | }); 102 | } 103 | }; 104 | 105 | exports.login = async (req, res) => { 106 | try { 107 | const errData = {}; 108 | const { username, password } = req.body; 109 | 110 | const user = await UserModel.findOne({ 111 | $or: [ 112 | { email: username }, // -> username field can be filled with email 113 | { username }, 114 | ], 115 | }); 116 | 117 | // if user not found or invalid password 118 | if (!user) { 119 | errData.statusCode = 401; 120 | errData.message = 'Username or email not registered'; 121 | 122 | throw errData; 123 | } 124 | 125 | // decrypt password 126 | decrypt(password, user.password); 127 | // generate access token 128 | const token = jwt.sign({ _id: user._id }, 'shhhhh'); 129 | 130 | response({ 131 | res, 132 | statusCode: 200, 133 | message: 'Successfully logged in', 134 | payload: token, // -> send token to store in localStorage 135 | }); 136 | } catch (error0) { 137 | response({ 138 | res, 139 | statusCode: error0.statusCode || 500, 140 | success: false, 141 | message: error0.message, 142 | }); 143 | } 144 | }; 145 | 146 | exports.find = async (req, res) => { 147 | try { 148 | // find user & exclude password 149 | const user = await UserModel.findOne( 150 | { _id: req.user._id }, 151 | { password: 0 } 152 | ); 153 | response({ 154 | res, 155 | payload: user, 156 | }); 157 | } catch (error0) { 158 | response({ 159 | res, 160 | statusCode: error0.statusCode || 500, 161 | success: false, 162 | message: error0.message, 163 | }); 164 | } 165 | }; 166 | 167 | exports.delete = async (req, res) => { 168 | try { 169 | const userId = req.user._id; 170 | 171 | const user = await UserModel.findOne({ _id: userId }); 172 | const compare = decrypt(req.body.password, user.password); 173 | 174 | if (!compare) { 175 | const errData = { 176 | message: 'Invalid password', 177 | statusCode: 401, 178 | }; 179 | throw errData; 180 | } 181 | 182 | // delete permanently user, profile, setting, and contact 183 | await UserModel.deleteOne({ _id: userId }); 184 | await ProfileModel.deleteOne({ userId }); 185 | await SettingModel.deleteOne({ userId }); 186 | await ContactModel.deleteMany({ userId }); 187 | 188 | await GroupModel.updateMany( 189 | { participantsId: userId }, 190 | { $pull: { participantsId: userId } } 191 | ); 192 | 193 | response({ 194 | res, 195 | message: 'Account deleted successfully', 196 | payload: user, 197 | }); 198 | } catch (error0) { 199 | response({ 200 | res, 201 | statusCode: error0.statusCode || 500, 202 | success: false, 203 | message: error0.message, 204 | }); 205 | } 206 | }; 207 | 208 | exports.changePass = async (req, res) => { 209 | try { 210 | const errData = {}; 211 | const userId = req.user._id; 212 | const { oldPass, newPass, confirmNewPass } = req.body; 213 | 214 | const user = await UserModel.findOne({ _id: userId }); 215 | 216 | // compare password 217 | if (!decrypt(oldPass, user.password)) { 218 | errData.statusCode = 401; 219 | errData.message = 'Invalid password'; 220 | 221 | throw errData; 222 | } 223 | 224 | if (newPass !== confirmNewPass) { 225 | errData.statusCode = 400; 226 | errData.message = "New password doesn't match"; 227 | 228 | throw errData; 229 | } 230 | 231 | // change password 232 | await UserModel.updateOne( 233 | { _id: userId }, 234 | { $set: { password: encrypt(newPass) } } 235 | ); 236 | 237 | // exclude password field when sending user data to client 238 | delete user.password; 239 | response({ 240 | res, 241 | message: 'Password changed successfully', 242 | payload: user, 243 | }); 244 | } catch (error0) { 245 | response({ 246 | res, 247 | statusCode: error0.statusCode || 500, 248 | success: false, 249 | message: error0.message, 250 | }); 251 | } 252 | }; 253 | -------------------------------------------------------------------------------- /server/db/connect.js: -------------------------------------------------------------------------------- 1 | const { connect } = require('mongoose'); 2 | const { isDev, db } = require('../config'); 3 | 4 | module.exports = async () => { 5 | try { 6 | const uri = isDev ? `mongodb://localhost:27017/${db.name}` : db.uri; 7 | await connect(uri); 8 | 9 | console.log('database connected'); 10 | } catch (error0) { 11 | console.log(error0.message); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /server/db/models/chat.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const ChatSchema = new Schema( 4 | { 5 | userId: { 6 | type: Schema.Types.String, 7 | required: true, 8 | }, 9 | roomId: { 10 | type: Schema.Types.String, 11 | required: true, 12 | }, 13 | text: { 14 | type: Schema.Types.String, 15 | default: '', 16 | }, 17 | readed: { 18 | type: Schema.Types.Boolean, 19 | required: true, 20 | default: false, 21 | }, 22 | replyTo: { 23 | type: Schema.Types.String, // -> target chat._id 24 | default: null, 25 | }, 26 | deletedBy: { 27 | type: Schema.Types.Array, // -> userId 28 | default: [], 29 | }, 30 | fileId: { 31 | type: Schema.Types.String, 32 | default: null, 33 | }, 34 | }, 35 | { 36 | timestamps: true, 37 | versionKey: false, 38 | } 39 | ); 40 | 41 | module.exports = model('chats', ChatSchema); 42 | -------------------------------------------------------------------------------- /server/db/models/contact.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const ContactSchema = new Schema( 4 | { 5 | userId: { 6 | type: Schema.Types.String, 7 | required: true, 8 | }, 9 | roomId: { 10 | type: Schema.Types.String, 11 | required: true, 12 | }, 13 | friendId: { 14 | type: Schema.Types.String, 15 | required: true, 16 | }, 17 | }, 18 | { 19 | versionKey: false, 20 | } 21 | ); 22 | 23 | module.exports = model('contact', ContactSchema); 24 | -------------------------------------------------------------------------------- /server/db/models/file.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const FileSchema = new Schema( 4 | { 5 | fileId: { 6 | type: Schema.Types.String, 7 | required: true, 8 | unique: true, 9 | }, 10 | originalname: { 11 | type: Schema.Types.String, 12 | default: '', 13 | required: true, 14 | }, 15 | url: { 16 | type: Schema.Types.String, 17 | required: true, 18 | unique: true, 19 | }, 20 | type: { 21 | type: Schema.Types.String, 22 | required: true, 23 | }, 24 | format: { 25 | type: Schema.Types.String, 26 | required: true, 27 | }, 28 | size: { 29 | type: Schema.Types.String, 30 | default: 0, 31 | required: true, 32 | }, 33 | }, 34 | { 35 | versionKey: false, 36 | } 37 | ); 38 | 39 | module.exports = model('files', FileSchema); 40 | -------------------------------------------------------------------------------- /server/db/models/group.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | const uniqueId = require('../../helpers/uniqueId'); 3 | 4 | const GroupSchema = new Schema( 5 | { 6 | roomId: { 7 | type: Schema.Types.String, 8 | required: true, 9 | }, 10 | adminId: { 11 | type: Schema.Types.String, 12 | required: true, 13 | }, 14 | participantsId: { 15 | type: Schema.Types.Array, 16 | required: true, 17 | }, 18 | name: { 19 | type: Schema.Types.String, 20 | maxLength: 32, 21 | required: true, 22 | }, 23 | desc: { 24 | type: Schema.Types.String, 25 | maxLength: 300, 26 | default: '', 27 | }, 28 | avatar: { 29 | type: Schema.Types.String, 30 | default: null, 31 | }, 32 | link: { 33 | type: Schema.Types.String, 34 | unique: true, 35 | required: true, 36 | default: `/group/+${uniqueId(16)}`, 37 | }, 38 | }, 39 | { 40 | timestamps: true, 41 | versionKey: false, 42 | } 43 | ); 44 | 45 | module.exports = model('groups', GroupSchema); 46 | -------------------------------------------------------------------------------- /server/db/models/inbox.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const InboxSchema = new Schema( 4 | { 5 | ownersId: { 6 | type: Schema.Types.Array, 7 | required: true, 8 | }, 9 | roomId: { 10 | type: Schema.Types.String, 11 | required: true, 12 | }, 13 | roomType: { 14 | type: Schema.Types.String, 15 | enum: ['private', 'group'], 16 | required: true, 17 | default: 'private', 18 | }, 19 | archivedBy: { 20 | type: Schema.Types.Array, 21 | default: [], 22 | }, 23 | unreadMessage: { 24 | type: Schema.Types.Number, 25 | default: 0, 26 | }, 27 | fileId: { 28 | type: Schema.Types.String, 29 | default: null, 30 | }, 31 | deletedBy: { 32 | type: Schema.Types.Array, 33 | default: [], 34 | }, 35 | content: { 36 | from: { 37 | type: Schema.Types.String, // -> the sender's userId 38 | required: true, 39 | }, 40 | senderName: { 41 | type: Schema.Types.String, 42 | required: true, 43 | }, 44 | text: { 45 | type: Schema.Types.String, 46 | required: true, 47 | }, 48 | time: { 49 | type: Schema.Types.Date, 50 | required: true, 51 | default: new Date().toISOString(), 52 | }, 53 | }, 54 | }, 55 | { 56 | versionKey: false, 57 | } 58 | ); 59 | 60 | module.exports = model('inboxes', InboxSchema); 61 | -------------------------------------------------------------------------------- /server/db/models/profile.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const ProfileSchema = new Schema( 4 | { 5 | userId: { 6 | type: Schema.Types.String, 7 | required: true, 8 | }, 9 | username: { 10 | type: Schema.Types.String, 11 | unique: true, 12 | trim: true, 13 | required: true, 14 | minLength: 3, 15 | maxLength: 24, 16 | }, 17 | email: { 18 | type: Schema.Types.String, 19 | unique: true, 20 | required: true, 21 | }, 22 | fullname: { 23 | type: Schema.Types.String, 24 | trim: true, 25 | required: true, 26 | minLength: 3, 27 | maxLength: 32, 28 | }, 29 | avatar: { 30 | type: Schema.Types.String, 31 | default: null, 32 | }, 33 | bio: { 34 | type: Schema.Types.String, 35 | trim: true, 36 | default: '', 37 | }, 38 | phone: { 39 | type: Schema.Types.String, 40 | trim: true, 41 | default: '', 42 | }, 43 | dialCode: { 44 | type: Schema.Types.String, 45 | trim: true, 46 | default: '', 47 | }, 48 | online: { 49 | type: Schema.Types.Boolean, 50 | required: true, 51 | default: false, 52 | }, 53 | }, 54 | { 55 | timestamps: true, 56 | versionKey: false, 57 | } 58 | ); 59 | 60 | module.exports = model('profiles', ProfileSchema); 61 | -------------------------------------------------------------------------------- /server/db/models/setting.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const SettingSchema = new Schema({ 4 | userId: { 5 | type: Schema.Types.String, 6 | required: true, 7 | }, 8 | dark: { 9 | type: Schema.Types.Boolean, 10 | default: false, 11 | }, 12 | enterToSend: { 13 | type: Schema.Types.Boolean, 14 | default: true, 15 | }, 16 | mute: { 17 | type: Schema.Types.Boolean, 18 | default: false, 19 | }, 20 | sortContactByName: { 21 | type: Schema.Types.Boolean, 22 | default: false, 23 | }, 24 | }); 25 | 26 | module.exports = model('settings', SettingSchema); 27 | -------------------------------------------------------------------------------- /server/db/models/user.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | const uniqueId = require('../../helpers/uniqueId'); 3 | 4 | const UserSchema = new Schema( 5 | { 6 | username: { 7 | type: Schema.Types.String, 8 | unique: true, 9 | trim: true, 10 | required: true, 11 | minLength: 3, 12 | maxLength: 24, 13 | }, 14 | email: { 15 | type: Schema.Types.String, 16 | unique: true, 17 | required: true, 18 | }, 19 | password: { 20 | type: Schema.Types.String, 21 | required: true, 22 | }, 23 | qrCode: { 24 | type: Schema.Types.String, 25 | required: true, 26 | default: uniqueId(16, { lowercase: false }), 27 | }, 28 | verified: { 29 | type: Schema.Types.Boolean, 30 | required: true, 31 | default: false, 32 | }, 33 | otp: { 34 | type: Schema.Types.Number, 35 | }, 36 | }, 37 | { 38 | versionKey: false, 39 | } 40 | ); 41 | 42 | module.exports = model('users', UserSchema); 43 | -------------------------------------------------------------------------------- /server/helpers/decrypt.js: -------------------------------------------------------------------------------- 1 | const { compareSync } = require('bcrypt'); 2 | 3 | /** 4 | * Decrypt Secret Data 5 | * @param {String} password 6 | * @param {String} hash 7 | * @returns {String|Boolean} 8 | */ 9 | module.exports = (password, hash) => { 10 | // comparing password 11 | const match = compareSync(password, hash); 12 | 13 | // if password & hash password does not match 14 | if (!match) { 15 | const errData = { 16 | statusCode: 401, 17 | message: 'Invalid password', 18 | }; 19 | throw errData; 20 | } 21 | 22 | // return normal password if match 23 | return password; 24 | }; 25 | -------------------------------------------------------------------------------- /server/helpers/encrypt.js: -------------------------------------------------------------------------------- 1 | const { hashSync, genSaltSync } = require('bcrypt'); 2 | 3 | /** 4 | * Encrypt Secret Data 5 | * @param {String|Object} target 6 | * @returns {String|Object} 7 | */ 8 | module.exports = (target = null) => { 9 | // if target data type is string 10 | if (typeof target === 'string') { 11 | // encrypt string 12 | return hashSync(target, genSaltSync(10)); 13 | } 14 | 15 | // if target data type is object 16 | const pass = {}; 17 | 18 | Object.entries(target).forEach((elem) => { 19 | pass[elem[0]] = hashSync(elem[1], genSaltSync(10)); 20 | }); 21 | 22 | return pass; 23 | }; 24 | -------------------------------------------------------------------------------- /server/helpers/mailer.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer'); 2 | const config = require('../config'); 3 | 4 | module.exports = async ({ to, fullname, subject, html, otp }) => { 5 | let options = {}; 6 | 7 | if (config.isDev) { 8 | options = { 9 | host: process.env.TEST_EMAIL_HOST, 10 | port: process.env.TEST_EMAIL_PORT, 11 | auth: { 12 | user: process.env.TEST_EMAIL_USER, 13 | pass: process.env.TEST_EMAIL_PASS, 14 | }, 15 | }; 16 | } else { 17 | options = { 18 | service: 'gmail', 19 | auth: { 20 | user: process.env.EMAIL_USER, 21 | pass: process.env.EMAIL_PASS, 22 | }, 23 | }; 24 | } 25 | 26 | const transporter = nodemailer.createTransport(options); 27 | const body = html.replace('#otp#', otp).replace('#fullname#', fullname); 28 | 29 | const send = await transporter.sendMail({ 30 | from: config.isDev ? 'test@spillgram.com' : process.env.EMAIL_USER, 31 | to, 32 | subject, 33 | html: body, 34 | }); 35 | 36 | return send; 37 | }; 38 | -------------------------------------------------------------------------------- /server/helpers/models/chats.js: -------------------------------------------------------------------------------- 1 | const ChatModel = require('../../db/models/chat'); 2 | 3 | exports.find = async (roomId, { skip = 0, limit = 20 }) => { 4 | const chats = await ChatModel.aggregate([ 5 | { $match: { roomId } }, 6 | { 7 | $lookup: { 8 | from: 'profiles', 9 | localField: 'userId', 10 | foreignField: 'userId', 11 | as: 'profile', 12 | }, 13 | }, 14 | { 15 | $unwind: { 16 | path: '$profile', 17 | preserveNullAndEmptyArrays: true, 18 | }, 19 | }, 20 | { 21 | $lookup: { 22 | from: 'files', 23 | localField: 'fileId', 24 | foreignField: 'fileId', 25 | as: 'file', 26 | }, 27 | }, 28 | { 29 | $unwind: { 30 | path: '$file', 31 | preserveNullAndEmptyArrays: true, 32 | }, 33 | }, 34 | { 35 | $project: { 36 | 'profile.username': 0, 37 | 'profile.email': 0, 38 | 'profile.bio': 0, 39 | 'profile.phone': 0, 40 | 'profile.dialCode': 0, 41 | 'profile.online': 0, 42 | 'profile.createdAt': 0, 43 | 'profile.updatedAt': 0, 44 | }, 45 | }, 46 | { $sort: { createdAt: -1 } }, 47 | { $skip: Number(skip) }, 48 | { $limit: Number(limit) }, 49 | { $sort: { createdAt: 1 } }, 50 | ]); 51 | 52 | return chats; 53 | }; 54 | -------------------------------------------------------------------------------- /server/helpers/models/inbox.js: -------------------------------------------------------------------------------- 1 | const InboxModel = require('../../db/models/inbox'); 2 | 3 | exports.find = async (queries, search = '') => { 4 | const inboxes = await InboxModel.aggregate([ 5 | { $match: queries }, 6 | { 7 | $lookup: { 8 | from: 'profiles', 9 | localField: 'ownersId', 10 | foreignField: 'userId', 11 | as: 'owners', 12 | }, 13 | }, 14 | { 15 | $lookup: { 16 | from: 'groups', 17 | localField: 'roomId', 18 | foreignField: 'roomId', 19 | as: 'group', 20 | }, 21 | }, 22 | { 23 | $unwind: { 24 | path: '$group', 25 | preserveNullAndEmptyArrays: true, 26 | }, 27 | }, 28 | { 29 | $lookup: { 30 | from: 'files', 31 | localField: 'fileId', 32 | foreignField: 'fileId', 33 | as: 'file', 34 | }, 35 | }, 36 | { 37 | $unwind: { 38 | path: '$file', 39 | preserveNullAndEmptyArrays: true, 40 | }, 41 | }, 42 | { 43 | $match: { 44 | $or: [ 45 | { 46 | roomType: 'private', 47 | owners: { 48 | $elemMatch: { 49 | fullname: { $regex: new RegExp(search), $options: 'i' }, 50 | }, 51 | }, 52 | }, 53 | { 54 | roomType: 'group', 55 | 'group.name': { $regex: new RegExp(search), $options: 'i' }, 56 | }, 57 | ], 58 | }, 59 | }, 60 | ]).sort({ 'content.time': -1 }); 61 | 62 | return inboxes; 63 | }; 64 | -------------------------------------------------------------------------------- /server/helpers/response.js: -------------------------------------------------------------------------------- 1 | /** @returns {Response} */ 2 | module.exports = ({ 3 | res, 4 | success = true, 5 | statusCode = 200, 6 | message = null, 7 | payload = null, 8 | }) => { 9 | res.status(statusCode).json({ 10 | code: statusCode, 11 | success, 12 | message, 13 | payload, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /server/helpers/templates/otp.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | Please activate your account 16 | 23 | 24 | 25 |
26 |
29 |

Hi, #fullname#!

30 |

31 | Please use the verification code below to activate your account. 32 |

33 | 44 |

#otp#

45 |
46 |

If you did not request this, you can ignore this email.

47 |

Thanks,

48 |

The Spillgram Team

49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /server/helpers/uniqueId.js: -------------------------------------------------------------------------------- 1 | module.exports = (length, options = null) => { 2 | let schema = ''; 3 | 4 | if (options?.uppercase ?? true) schema += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 5 | if (options?.lowercase ?? true) schema += 'abcdefghijklmnopqrstuvwxyz'; 6 | if (options?.number ?? true) schema += '0123456789'; 7 | 8 | let unique = ''; 9 | let i = 0; 10 | 11 | while (i < length) { 12 | unique += schema.charAt(Math.floor(Math.random() * schema.length)); 13 | i += 1; 14 | } 15 | 16 | return unique; 17 | }; 18 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const server = require('./server'); 2 | 3 | const port = process.env.PORT || 8080; 4 | 5 | server.listen(port); 6 | console.log(`[${port}] server running...`); 7 | -------------------------------------------------------------------------------- /server/middleware/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const response = require('../helpers/response'); 3 | 4 | module.exports = (req, res, next) => { 5 | try { 6 | const headers = req.headers.authorization; 7 | const token = headers ? headers.split(' ')[1] : null; 8 | 9 | req.user = jwt.verify(token, 'shhhhh'); 10 | next(); 11 | } catch (error0) { 12 | response({ 13 | res, 14 | statusCode: 401, 15 | success: false, 16 | message: error0.message, 17 | }); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /server/middleware/cloudinary.js: -------------------------------------------------------------------------------- 1 | const { v2: cloudinary } = require('cloudinary'); 2 | 3 | module.exports = () => { 4 | cloudinary.config({ 5 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 6 | api_key: process.env.CLOUDINARY_API_KEY, 7 | api_secret: process.env.CLOUDINARY_API_SECRET, 8 | secure: true, 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /server/routes/avatar.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authenticate = require('../middleware/auth'); 3 | const ctrl = require('../controllers/avatar'); 4 | 5 | router.post('/avatars', authenticate, ctrl.upload); 6 | 7 | module.exports = router; 8 | -------------------------------------------------------------------------------- /server/routes/chat.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authenticate = require('../middleware/auth'); 3 | const ctrl = require('../controllers/chat'); 4 | 5 | router.get('/chats/:roomId', authenticate, ctrl.findByRoomId); 6 | router.delete('/chats/:roomId', authenticate, ctrl.deleteByRoomId); 7 | 8 | module.exports = router; 9 | -------------------------------------------------------------------------------- /server/routes/contact.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authenticate = require('../middleware/auth'); 3 | const ctrl = require('../controllers/contact'); 4 | 5 | router.post('/contacts', authenticate, ctrl.insert); 6 | router.get('/contacts', authenticate, ctrl.find); 7 | router.delete('/contacts/:friendId', authenticate, ctrl.deleteByFriendId); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /server/routes/group.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authenticate = require('../middleware/auth'); 3 | const ctrl = require('../controllers/group'); 4 | 5 | router.get('/groups/:groupId', authenticate, ctrl.findById); 6 | router.get( 7 | '/groups/:groupId/participants/name', 8 | authenticate, 9 | ctrl.participantsName 10 | ); 11 | router.get('/groups/:groupId/participants', authenticate, ctrl.participants); 12 | 13 | module.exports = router; 14 | -------------------------------------------------------------------------------- /server/routes/inbox.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authenticate = require('../middleware/auth'); 3 | const ctrl = require('../controllers/inbox'); 4 | 5 | router.get('/inboxes', authenticate, ctrl.find); 6 | 7 | module.exports = router; 8 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | // routes 3 | const user = require('./user'); 4 | const chat = require('./chat'); 5 | const contact = require('./contact'); 6 | const setting = require('./setting'); 7 | const profile = require('./profile'); 8 | const inbox = require('./inbox'); 9 | const group = require('./group'); 10 | const avatar = require('./avatar'); 11 | 12 | router.use(user); 13 | router.use(chat); 14 | router.use(contact); 15 | router.use(setting); 16 | router.use(profile); 17 | router.use(inbox); 18 | router.use(group); 19 | router.use(avatar); 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /server/routes/profile.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authenticate = require('../middleware/auth'); 3 | const ctrl = require('../controllers/profile'); 4 | 5 | router.get('/profiles/:userId', authenticate, ctrl.findById); 6 | router.put('/profiles', authenticate, ctrl.edit); 7 | 8 | module.exports = router; 9 | -------------------------------------------------------------------------------- /server/routes/setting.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authenticate = require('../middleware/auth'); 3 | const ctrl = require('../controllers/setting'); 4 | 5 | router.get('/settings', authenticate, ctrl.find); 6 | router.put('/settings', authenticate, ctrl.update); 7 | 8 | module.exports = router; 9 | -------------------------------------------------------------------------------- /server/routes/user.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | const authenticate = require('../middleware/auth'); 3 | 4 | const ctrl = require('../controllers/user'); 5 | 6 | router.post('/users/register', ctrl.register); 7 | router.post('/users/login', ctrl.login); 8 | router.post('/users/verify', authenticate, ctrl.verify); 9 | router.get('/users', authenticate, ctrl.find); 10 | router.delete('/users', authenticate, ctrl.delete); 11 | router.patch('/users/change-pass', authenticate, ctrl.changePass); 12 | 13 | module.exports = router; 14 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: './.env' }); 2 | 3 | const express = require('express'); 4 | const { Server: SocketServer } = require('socket.io'); 5 | const http = require('http'); 6 | const path = require('path'); 7 | const cors = require('cors'); 8 | const routes = require('./routes'); 9 | const config = require('./config'); 10 | const db = require('./db/connect'); 11 | const cloudinary = require('./middleware/cloudinary'); 12 | 13 | const app = express(); 14 | const server = http.createServer(app); 15 | 16 | // middleware 17 | app.use(cors(config.cors)); 18 | app.use(express.json({ limit: '10mb' })); 19 | app.use( 20 | express.urlencoded({ 21 | limit: '10mb', 22 | parameterLimit: 100000, 23 | extended: false, 24 | }) 25 | ); 26 | 27 | cloudinary(); 28 | db(); 29 | 30 | app.use('/api', routes); 31 | 32 | if (!config.isDev) { 33 | app.use(express.static('client/public')); 34 | const client = path.join(__dirname, '..', 'client', 'public', 'index.html'); 35 | 36 | app.get('*', (req, res) => res.sendFile(client)); 37 | } 38 | 39 | // store socket on global object 40 | global.io = new SocketServer(server, { cors: config.cors }); 41 | require('./socket'); 42 | 43 | module.exports = server; 44 | -------------------------------------------------------------------------------- /server/socket/events/chat.js: -------------------------------------------------------------------------------- 1 | const { io } = global; 2 | const cloud = require('cloudinary').v2; 3 | 4 | const InboxModel = require('../../db/models/inbox'); 5 | const ChatModel = require('../../db/models/chat'); 6 | const FileModel = require('../../db/models/file'); 7 | const ProfileModel = require('../../db/models/profile'); 8 | 9 | const Inbox = require('../../helpers/models/inbox'); 10 | const uniqueId = require('../../helpers/uniqueId'); 11 | 12 | module.exports = (socket) => { 13 | // event when user sends message 14 | socket.on('chat/insert', async (args) => { 15 | try { 16 | let fileId = null; 17 | let file; 18 | 19 | if (args.file) { 20 | const arrOriname = args.file.originalname.split('.'); 21 | const format = 22 | arrOriname.length === 1 ? 'txt' : arrOriname.reverse()[0]; 23 | 24 | const upload = await cloud.uploader.upload(args.file.url, { 25 | folder: 'chat', 26 | public_id: `${uniqueId(20)}.${format}`, 27 | resource_type: 'auto', 28 | }); 29 | 30 | fileId = upload.public_id; 31 | 32 | file = await new FileModel({ 33 | fileId, 34 | url: upload.url, 35 | originalname: args.file.originalname, 36 | type: upload.resource_type, 37 | format, 38 | size: upload.bytes, 39 | }).save(); 40 | } 41 | 42 | const chat = await new ChatModel({ ...args, fileId }).save(); 43 | const profile = await ProfileModel.findOne( 44 | { userId: args.userId }, 45 | { userId: 1, avatar: 1, fullname: 1 } 46 | ); 47 | 48 | // create a new inbox if it doesn't exist and update it if exists 49 | await InboxModel.findOneAndUpdate( 50 | { roomId: args.roomId }, 51 | { 52 | $inc: { unreadMessage: 1 }, 53 | $set: { 54 | roomId: args.roomId, 55 | ownersId: args.ownersId, 56 | fileId, 57 | deletedBy: [], 58 | content: { 59 | from: args.userId, 60 | senderName: profile.fullname, 61 | text: 62 | chat.text || chat.text.length > 0 63 | ? chat.text 64 | : file.originalname, 65 | time: chat.createdAt, 66 | }, 67 | }, 68 | }, 69 | { new: true, upsert: true } 70 | ); 71 | 72 | const inboxes = await Inbox.find({ ownersId: { $all: args.ownersId } }); 73 | 74 | io.to(args.roomId).emit('chat/insert', { ...chat._doc, profile, file }); 75 | // send the latest inbox data to be merge with old inbox data 76 | io.to(args.ownersId).emit('inbox/find', inboxes[0]); 77 | } catch (error0) { 78 | console.log(error0.message); 79 | } 80 | }); 81 | 82 | // event when a friend join to chat room and reads your message 83 | socket.on('chat/read', async (args) => { 84 | try { 85 | await InboxModel.updateOne( 86 | { roomId: args.roomId, ownersId: { $all: args.ownersId } }, 87 | { $set: { unreadMessage: 0 } } 88 | ); 89 | await ChatModel.updateMany( 90 | { roomId: args.roomId, readed: false }, 91 | { $set: { readed: true } } 92 | ); 93 | 94 | const inboxes = await Inbox.find({ ownersId: { $all: args.ownersId } }); 95 | 96 | io.to(args.ownersId).emit('inbox/read', inboxes[0]); 97 | io.to(args.roomId).emit('chat/read', true); 98 | } catch (error0) { 99 | console.log(error0.message); 100 | } 101 | }); 102 | 103 | let typingEnds = null; 104 | socket.on('chat/typing', async ({ roomId, roomType, userId }) => { 105 | clearTimeout(typingEnds); 106 | 107 | const isGroup = roomType === 'group'; 108 | const typer = isGroup 109 | ? await ProfileModel.findOne({ userId }, { fullname: 1 }) 110 | : null; 111 | 112 | socket.broadcast 113 | .to(roomId) 114 | .emit( 115 | 'chat/typing', 116 | isGroup ? `${typer.fullname} typing...` : 'typing...' 117 | ); 118 | 119 | typingEnds = setTimeout(() => { 120 | socket.broadcast.to(roomId).emit('chat/typing-ends', true); 121 | }, 1000); 122 | }); 123 | 124 | // delete chats 125 | socket.on( 126 | 'chat/delete', 127 | async ({ userId, chatsId, roomId, deleteForEveryone }) => { 128 | try { 129 | // delete attached files 130 | const handleDeleteFiles = async (query = {}) => { 131 | const chats = await ChatModel.find( 132 | { _id: { $in: chatsId }, roomId, ...query }, 133 | { fileId: 1 } 134 | ); 135 | 136 | const filesId = chats 137 | .filter((elem) => !!elem.fileId) 138 | .map((elem) => elem.fileId); 139 | 140 | if (filesId.length > 0) { 141 | await FileModel.deleteMany({ roomId, fileId: filesId }); 142 | 143 | await cloud.api.delete_resources(filesId, { 144 | resource_type: 'image', 145 | }); 146 | await cloud.api.delete_resources(filesId, { 147 | resource_type: 'video', 148 | }); 149 | await cloud.api.delete_resources(filesId, { resource_type: 'raw' }); 150 | } 151 | }; 152 | 153 | if (deleteForEveryone) { 154 | await handleDeleteFiles({}); 155 | await ChatModel.deleteMany({ roomId, _id: { $in: chatsId } }); 156 | 157 | io.to(roomId).emit('chat/delete', { userId, chatsId }); 158 | } else { 159 | await ChatModel.updateMany( 160 | { roomId, _id: { $in: chatsId } }, 161 | { $push: { deletedBy: userId } } 162 | ); 163 | 164 | // delete permanently if this message has been 165 | // deleted by all room participants 166 | const { ownersId } = await InboxModel.findOne( 167 | { roomId }, 168 | { _id: 0, ownersId: 1 } 169 | ); 170 | 171 | await handleDeleteFiles({ deletedBy: { $size: ownersId.length } }); 172 | await ChatModel.deleteMany({ 173 | roomId, 174 | $expr: { $gte: [{ $size: '$deletedBy' }, ownersId.length] }, 175 | }); 176 | 177 | socket.emit('chat/delete', { userId, chatsId }); 178 | } 179 | } catch (error0) { 180 | console.error(error0.message); 181 | } 182 | } 183 | ); 184 | }; 185 | -------------------------------------------------------------------------------- /server/socket/events/room.js: -------------------------------------------------------------------------------- 1 | const { io } = global; 2 | 3 | module.exports = (socket) => { 4 | // join room 5 | socket.on('room/open', (args) => { 6 | if (args.prevRoom) { 7 | socket.leave(args.prevRoom); 8 | } 9 | 10 | socket.join(args.newRoom); 11 | io.to(args.newRoom).emit('room/open', args.newRoom); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /server/socket/events/user.js: -------------------------------------------------------------------------------- 1 | const ProfileModel = require('../../db/models/profile'); 2 | 3 | module.exports = (socket) => { 4 | // user connect 5 | socket.on('user/connect', async (userId) => { 6 | socket.join(userId); 7 | socket.broadcast.to(userId).emit('user/inactivate', true); 8 | 9 | /* eslint-disable */ 10 | // store userId in socket object 11 | socket.userId = userId; 12 | /* eslint-enable */ 13 | 14 | await ProfileModel.updateOne({ userId }, { $set: { online: true } }); 15 | 16 | socket.broadcast.emit('user/connect', userId); 17 | }); 18 | 19 | socket.on('disconnect', async () => { 20 | const { userId } = socket; 21 | await ProfileModel.updateOne({ userId }, { $set: { online: false } }); 22 | 23 | socket.broadcast.emit('user/disconnect', userId); 24 | }); 25 | 26 | socket.on('user/disconnect', async () => { 27 | const { userId } = socket; 28 | await ProfileModel.updateOne({ userId }, { $set: { online: false } }); 29 | 30 | socket.broadcast.emit('user/disconnect', userId); 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /server/socket/index.js: -------------------------------------------------------------------------------- 1 | const { io } = global; 2 | 3 | const user = require('./events/user'); 4 | const chat = require('./events/chat'); 5 | const room = require('./events/room'); 6 | const group = require('./events/group'); 7 | 8 | io.on('connection', (socket) => { 9 | user(socket); 10 | room(socket); 11 | chat(socket); 12 | group(socket); 13 | }); 14 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const { colors: defaultColors } = require('tailwindcss/defaultTheme'); 3 | const scrollbar = require('tailwind-scrollbar'); 4 | 5 | module.exports = { 6 | content: ['./client/**/*.{js,jsx,tsx}'], 7 | darkMode: 'class', 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | display: 'Lobster Two', 12 | }, 13 | colors: { 14 | ...defaultColors, 15 | spill: { 16 | DEFAULT: '#141D26', 17 | 50: '#F9FAFB', 18 | 100: '#EDF0F3', 19 | 200: '#DDE3E9', 20 | 300: '#CBD4DC', 21 | 400: '#B6C2CE', 22 | 500: '#3E5265', 23 | 600: '#334557', 24 | 700: '#273645', 25 | 800: '#1D2935', 26 | 900: '#141D26', 27 | 950: '#0C1116', 28 | }, 29 | }, 30 | }, 31 | }, 32 | plugins: [scrollbar], 33 | }; 34 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | 3 | module.exports = { 4 | entry: './client/index.jsx', 5 | resolve: { 6 | extensions: ['.js', '.jsx', '.css'], 7 | }, 8 | plugins: [ 9 | new HtmlWebpackPlugin({ 10 | template: './client/index.html', 11 | }), 12 | ], 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.jsx?$/, 17 | exclude: /node_modules/, 18 | loader: 'babel-loader', 19 | }, 20 | { 21 | test: /\.css$/, 22 | use: ['style-loader', 'css-loader', 'postcss-loader'], 23 | }, 24 | { 25 | test: /\.(jpe?g|png|mp3)$/, 26 | use: [ 27 | { 28 | loader: 'file-loader', 29 | options: { 30 | outputPath: 'public/images', 31 | }, 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const common = require('./webpack.common'); 3 | 4 | module.exports = { 5 | ...common, 6 | mode: 'development', 7 | devServer: { 8 | static: { 9 | directory: path.resolve(__dirname, 'client/public'), 10 | }, 11 | port: 3000, 12 | historyApiFallback: true, 13 | // allows to open the browser automatically when the project is run 14 | open: true, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const common = require('./webpack.common'); 3 | 4 | module.exports = { 5 | ...common, 6 | mode: 'production', 7 | output: { 8 | path: path.resolve(__dirname, 'client/public'), 9 | filename: '[fullhash].js', 10 | }, 11 | }; 12 | --------------------------------------------------------------------------------