├── .babelrc ├── .commitlintrc.json ├── .env ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── 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 │ ├── 158242035f16c7093e47.js │ ├── 158242035f16c7093e47.js.LICENSE.txt │ ├── assets │ │ ├── images │ │ │ ├── bg.jpg │ │ │ ├── default-avatar.png │ │ │ ├── default-group-avatar.png │ │ │ ├── error.ico │ │ │ ├── favicon.ico │ │ │ └── photo.ico │ │ └── sound │ │ │ └── default-ringtone.mp3 │ ├── cf52cde87746d3388124.js │ ├── cf52cde87746d3388124.js.LICENSE.txt │ ├── fbfaf6e4adae8af45d90.js │ ├── fbfaf6e4adae8af45d90.js.LICENSE.txt │ └── index.html ├── 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 ├── 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 └── test │ ├── profile.test.js │ └── user.test.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: -------------------------------------------------------------------------------- 1 | 2 | NODE_ENV = development 3 | 4 | # cloud mongodb 5 | MONGO_URI = 'mongodb+srv://rohitjha1:mPAnT8Vz4QAoqdh7@cluster0.oromcia.mongodb.net/?retryWrites=true&w=majority' 6 | 7 | # https://cloudinary.com/ 8 | CLOUDINARY_API_KEY = 628383253831627 9 | CLOUDINARY_API_SECRET = PB_X9Ei-_lC_7AT1NJb1gKMy-WA 10 | CLOUDINARY_CLOUD_NAME = dpsfmnjoh 11 | 12 | # nodemailer 13 | EMAIL_USER = securesally@gmail.com 14 | EMAIL_PASS = vrsonynuvbmrtmfc 15 | 16 | # fake SMTP/email server 17 | TEST_EMAIL_USER = 8e64726763968f 18 | TEST_EMAIL_PASS = 48adbdfbcc262b 19 | TEST_EMAIL_HOST = sandbox.smtp.mailtrap.io 20 | TEST_EMAIL_PORT = 25 or 465 or 587 or 2525 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.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 | # Let's Chat 2 | 3 | Let's Chat is a web-based instant messaging app that allows you to quickly send and receive text messages, emojis, photos, or files with other Let's Chat users. Let's Chat 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 | ## Getting Started 6 | 7 | **Step 1:** Rename `.env.example` file to `.env` and complete the required [environment variables](#environment-variables). 8 | 9 | **Step 2:** Install dependencies. 10 | 11 | ```bash 12 | npm install 13 | ``` 14 | 15 | **Step 3:** Run the app in development mode. 16 | 17 | ```bash 18 | npm run dev 19 | ``` 20 | 21 | ## Requirements 22 | 23 | - **Node.js:** _latest_ 24 | - **NPM**: _latest_ 25 | - **MongoDB**: _^6.0.4_ 26 | - [Cloudinary account](https://cloudinary.com): _third-party for media cloud_ 27 | 28 | ## Features 0f LetsCHAT 29 | 30 | - User authentication 31 | - Sharing text messages, emojis, photos, or files 32 | - Online/offline, last seen time, blue tick, and typing indicators 33 | - Photo capture 34 | - Browser notification 35 | - Peer-to-peer and group chat 36 | - User profile 37 | - Contact 38 | - Account settings 39 | - Dark mode 40 | - Change account password 41 | - Delete account 42 | - ... 43 | 44 | ## Environment Variables 45 | 46 | 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. 47 | 48 | ``` 49 | NODE_ENV = development 50 | ``` 51 | 52 | ### Connect to MongoDB 53 | 54 | By default, LeChat will use your local MongoDB server and the `let's chat` 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). 55 | 56 | ``` 57 | MONGO_URI = mongodb+srv://{username}:{password}@node.deu00vc.mongodb.net/{dbname}?retryWrites=true&w=majority 58 | ``` 59 | 60 | ### Cloudinary 61 | 62 | We rely on Cloudinary service to store all media uploaded by users, follow the instructions below to getting started with Cloudinary: 63 | 64 | - Create [Cloudinary Account](https://cloudinary.com/) for free and you will get **Product Environment Credentials** like Cloud Name, API Key, and API Secret. 65 | - Open the **Media Library** then create `avatars` and `chat` folders. 66 | 67 | ``` 68 | CLOUDINARY_API_KEY = 69 | CLOUDINARY_API_SECRET = 70 | CLOUDINARY_CLOUD_NAME = 71 | ``` 72 | 73 | ### Nodemailer 74 | 75 | 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: 76 | 77 | - Go to your [Google Account](https://myaccount.google.com/) > **Security** > **2-Step Verification** 78 | - At the bottom, choose **Select app** and choose Other (custom name) > give this App Password a name, e.g. "Nodemailer" > **Generate** 79 | - Follow the instructions to enter the App Password. The App Password is the 16-character code in the yellow bar on your device 80 | - **Done** 81 | 82 | ``` 83 | EMAIL_USER = your@gmail.com 84 | EMAIL_PASS = 85 | ``` 86 | 87 | ### Fake SMTP Server 88 | 89 | 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. 90 | 91 | ``` 92 | TEST_EMAIL_USER = 93 | TEST_EMAIL_PASS = 94 | TEST_EMAIL_HOST = smtp.mailtrap.io 95 | TEST_EMAIL_PORT = 2525 96 | ``` 97 | 98 | #Video: https://youtu.be/Q_sz7j3AICE 99 | -------------------------------------------------------------------------------- /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 | {`Unlock the door to communication by contacting us with any inquiries, questions, or curiosities about the ${config.brandName} web-based application. `} 115 | 116 | Contact Us 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: 'Lets Chat', 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 | Let's Chat 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/public/158242035f16c7093e47.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! ***************************************************************************** 8 | Copyright (c) Microsoft Corporation. 9 | 10 | Permission to use, copy, modify, and/or distribute this software for any 11 | purpose with or without fee is hereby granted. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 14 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 15 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 16 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 17 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 18 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 19 | PERFORMANCE OF THIS SOFTWARE. 20 | ***************************************************************************** */ 21 | 22 | /** 23 | * @license React 24 | * react-dom.production.min.js 25 | * 26 | * Copyright (c) Facebook, Inc. and its affiliates. 27 | * 28 | * This source code is licensed under the MIT license found in the 29 | * LICENSE file in the root directory of this source tree. 30 | */ 31 | 32 | /** 33 | * @license React 34 | * react-is.production.min.js 35 | * 36 | * Copyright (c) Facebook, Inc. and its affiliates. 37 | * 38 | * This source code is licensed under the MIT license found in the 39 | * LICENSE file in the root directory of this source tree. 40 | */ 41 | 42 | /** 43 | * @license React 44 | * react.production.min.js 45 | * 46 | * Copyright (c) Facebook, Inc. and its affiliates. 47 | * 48 | * This source code is licensed under the MIT license found in the 49 | * LICENSE file in the root directory of this source tree. 50 | */ 51 | 52 | /** 53 | * @license React 54 | * scheduler.production.min.js 55 | * 56 | * Copyright (c) Facebook, Inc. and its affiliates. 57 | * 58 | * This source code is licensed under the MIT license found in the 59 | * LICENSE file in the root directory of this source tree. 60 | */ 61 | 62 | /** 63 | * @license React 64 | * use-sync-external-store-shim.production.min.js 65 | * 66 | * Copyright (c) Facebook, Inc. and its affiliates. 67 | * 68 | * This source code is licensed under the MIT license found in the 69 | * LICENSE file in the root directory of this source tree. 70 | */ 71 | 72 | /** 73 | * @license React 74 | * use-sync-external-store-shim/with-selector.production.min.js 75 | * 76 | * Copyright (c) Facebook, Inc. and its affiliates. 77 | * 78 | * This source code is licensed under the MIT license found in the 79 | * LICENSE file in the root directory of this source tree. 80 | */ 81 | 82 | /** 83 | * @remix-run/router v1.6.3 84 | * 85 | * Copyright (c) Remix Software Inc. 86 | * 87 | * This source code is licensed under the MIT license found in the 88 | * LICENSE.md file in the root directory of this source tree. 89 | * 90 | * @license MIT 91 | */ 92 | 93 | /** 94 | * Checks if an event is supported in the current execution environment. 95 | * 96 | * NOTE: This will not work correctly for non-generic events such as `change`, 97 | * `reset`, `load`, `error`, and `select`. 98 | * 99 | * Borrows from Modernizr. 100 | * 101 | * @param {string} eventNameSuffix Event name, e.g. "click". 102 | * @param {?boolean} capture Check if the capture phase is supported. 103 | * @return {boolean} True if the event is supported. 104 | * @internal 105 | * @license Modernizr 3.0.0pre (Custom Build) | MIT 106 | */ 107 | 108 | /** 109 | * React Router v6.12.1 110 | * 111 | * Copyright (c) Remix Software Inc. 112 | * 113 | * This source code is licensed under the MIT license found in the 114 | * LICENSE.md file in the root directory of this source tree. 115 | * 116 | * @license MIT 117 | */ 118 | 119 | /** @license React v16.13.1 120 | * react-is.production.min.js 121 | * 122 | * Copyright (c) Facebook, Inc. and its affiliates. 123 | * 124 | * This source code is licensed under the MIT license found in the 125 | * LICENSE file in the root directory of this source tree. 126 | */ 127 | 128 | //! moment.js 129 | 130 | //! moment.js locale configuration 131 | -------------------------------------------------------------------------------- /client/public/assets/images/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohitofficial/ReactJs-Chatapp_Mangodb/7dd46d00684071ecc93236b03570b192568d864f/client/public/assets/images/bg.jpg -------------------------------------------------------------------------------- /client/public/assets/images/default-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohitofficial/ReactJs-Chatapp_Mangodb/7dd46d00684071ecc93236b03570b192568d864f/client/public/assets/images/default-avatar.png -------------------------------------------------------------------------------- /client/public/assets/images/default-group-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohitofficial/ReactJs-Chatapp_Mangodb/7dd46d00684071ecc93236b03570b192568d864f/client/public/assets/images/default-group-avatar.png -------------------------------------------------------------------------------- /client/public/assets/images/error.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohitofficial/ReactJs-Chatapp_Mangodb/7dd46d00684071ecc93236b03570b192568d864f/client/public/assets/images/error.ico -------------------------------------------------------------------------------- /client/public/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohitofficial/ReactJs-Chatapp_Mangodb/7dd46d00684071ecc93236b03570b192568d864f/client/public/assets/images/favicon.ico -------------------------------------------------------------------------------- /client/public/assets/images/photo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohitofficial/ReactJs-Chatapp_Mangodb/7dd46d00684071ecc93236b03570b192568d864f/client/public/assets/images/photo.ico -------------------------------------------------------------------------------- /client/public/assets/sound/default-ringtone.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrohitofficial/ReactJs-Chatapp_Mangodb/7dd46d00684071ecc93236b03570b192568d864f/client/public/assets/sound/default-ringtone.mp3 -------------------------------------------------------------------------------- /client/public/cf52cde87746d3388124.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! ***************************************************************************** 8 | Copyright (c) Microsoft Corporation. 9 | 10 | Permission to use, copy, modify, and/or distribute this software for any 11 | purpose with or without fee is hereby granted. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 14 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 15 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 16 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 17 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 18 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 19 | PERFORMANCE OF THIS SOFTWARE. 20 | ***************************************************************************** */ 21 | 22 | /** 23 | * @license React 24 | * react-dom.production.min.js 25 | * 26 | * Copyright (c) Facebook, Inc. and its affiliates. 27 | * 28 | * This source code is licensed under the MIT license found in the 29 | * LICENSE file in the root directory of this source tree. 30 | */ 31 | 32 | /** 33 | * @license React 34 | * react-is.production.min.js 35 | * 36 | * Copyright (c) Facebook, Inc. and its affiliates. 37 | * 38 | * This source code is licensed under the MIT license found in the 39 | * LICENSE file in the root directory of this source tree. 40 | */ 41 | 42 | /** 43 | * @license React 44 | * react.production.min.js 45 | * 46 | * Copyright (c) Facebook, Inc. and its affiliates. 47 | * 48 | * This source code is licensed under the MIT license found in the 49 | * LICENSE file in the root directory of this source tree. 50 | */ 51 | 52 | /** 53 | * @license React 54 | * scheduler.production.min.js 55 | * 56 | * Copyright (c) Facebook, Inc. and its affiliates. 57 | * 58 | * This source code is licensed under the MIT license found in the 59 | * LICENSE file in the root directory of this source tree. 60 | */ 61 | 62 | /** 63 | * @license React 64 | * use-sync-external-store-shim.production.min.js 65 | * 66 | * Copyright (c) Facebook, Inc. and its affiliates. 67 | * 68 | * This source code is licensed under the MIT license found in the 69 | * LICENSE file in the root directory of this source tree. 70 | */ 71 | 72 | /** 73 | * @license React 74 | * use-sync-external-store-shim/with-selector.production.min.js 75 | * 76 | * Copyright (c) Facebook, Inc. and its affiliates. 77 | * 78 | * This source code is licensed under the MIT license found in the 79 | * LICENSE file in the root directory of this source tree. 80 | */ 81 | 82 | /** 83 | * @remix-run/router v1.6.3 84 | * 85 | * Copyright (c) Remix Software Inc. 86 | * 87 | * This source code is licensed under the MIT license found in the 88 | * LICENSE.md file in the root directory of this source tree. 89 | * 90 | * @license MIT 91 | */ 92 | 93 | /** 94 | * Checks if an event is supported in the current execution environment. 95 | * 96 | * NOTE: This will not work correctly for non-generic events such as `change`, 97 | * `reset`, `load`, `error`, and `select`. 98 | * 99 | * Borrows from Modernizr. 100 | * 101 | * @param {string} eventNameSuffix Event name, e.g. "click". 102 | * @param {?boolean} capture Check if the capture phase is supported. 103 | * @return {boolean} True if the event is supported. 104 | * @internal 105 | * @license Modernizr 3.0.0pre (Custom Build) | MIT 106 | */ 107 | 108 | /** 109 | * React Router v6.12.1 110 | * 111 | * Copyright (c) Remix Software Inc. 112 | * 113 | * This source code is licensed under the MIT license found in the 114 | * LICENSE.md file in the root directory of this source tree. 115 | * 116 | * @license MIT 117 | */ 118 | 119 | /** @license React v16.13.1 120 | * react-is.production.min.js 121 | * 122 | * Copyright (c) Facebook, Inc. and its affiliates. 123 | * 124 | * This source code is licensed under the MIT license found in the 125 | * LICENSE file in the root directory of this source tree. 126 | */ 127 | 128 | //! moment.js 129 | 130 | //! moment.js locale configuration 131 | -------------------------------------------------------------------------------- /client/public/fbfaf6e4adae8af45d90.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! ***************************************************************************** 8 | Copyright (c) Microsoft Corporation. 9 | 10 | Permission to use, copy, modify, and/or distribute this software for any 11 | purpose with or without fee is hereby granted. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 14 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 15 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 16 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 17 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 18 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 19 | PERFORMANCE OF THIS SOFTWARE. 20 | ***************************************************************************** */ 21 | 22 | /** 23 | * @license React 24 | * react-dom.production.min.js 25 | * 26 | * Copyright (c) Facebook, Inc. and its affiliates. 27 | * 28 | * This source code is licensed under the MIT license found in the 29 | * LICENSE file in the root directory of this source tree. 30 | */ 31 | 32 | /** 33 | * @license React 34 | * react-is.production.min.js 35 | * 36 | * Copyright (c) Facebook, Inc. and its affiliates. 37 | * 38 | * This source code is licensed under the MIT license found in the 39 | * LICENSE file in the root directory of this source tree. 40 | */ 41 | 42 | /** 43 | * @license React 44 | * react.production.min.js 45 | * 46 | * Copyright (c) Facebook, Inc. and its affiliates. 47 | * 48 | * This source code is licensed under the MIT license found in the 49 | * LICENSE file in the root directory of this source tree. 50 | */ 51 | 52 | /** 53 | * @license React 54 | * scheduler.production.min.js 55 | * 56 | * Copyright (c) Facebook, Inc. and its affiliates. 57 | * 58 | * This source code is licensed under the MIT license found in the 59 | * LICENSE file in the root directory of this source tree. 60 | */ 61 | 62 | /** 63 | * @license React 64 | * use-sync-external-store-shim.production.min.js 65 | * 66 | * Copyright (c) Facebook, Inc. and its affiliates. 67 | * 68 | * This source code is licensed under the MIT license found in the 69 | * LICENSE file in the root directory of this source tree. 70 | */ 71 | 72 | /** 73 | * @license React 74 | * use-sync-external-store-shim/with-selector.production.min.js 75 | * 76 | * Copyright (c) Facebook, Inc. and its affiliates. 77 | * 78 | * This source code is licensed under the MIT license found in the 79 | * LICENSE file in the root directory of this source tree. 80 | */ 81 | 82 | /** 83 | * @remix-run/router v1.6.3 84 | * 85 | * Copyright (c) Remix Software Inc. 86 | * 87 | * This source code is licensed under the MIT license found in the 88 | * LICENSE.md file in the root directory of this source tree. 89 | * 90 | * @license MIT 91 | */ 92 | 93 | /** 94 | * Checks if an event is supported in the current execution environment. 95 | * 96 | * NOTE: This will not work correctly for non-generic events such as `change`, 97 | * `reset`, `load`, `error`, and `select`. 98 | * 99 | * Borrows from Modernizr. 100 | * 101 | * @param {string} eventNameSuffix Event name, e.g. "click". 102 | * @param {?boolean} capture Check if the capture phase is supported. 103 | * @return {boolean} True if the event is supported. 104 | * @internal 105 | * @license Modernizr 3.0.0pre (Custom Build) | MIT 106 | */ 107 | 108 | /** 109 | * React Router v6.12.1 110 | * 111 | * Copyright (c) Remix Software Inc. 112 | * 113 | * This source code is licensed under the MIT license found in the 114 | * LICENSE.md file in the root directory of this source tree. 115 | * 116 | * @license MIT 117 | */ 118 | 119 | /** @license React v16.13.1 120 | * react-is.production.min.js 121 | * 122 | * Copyright (c) Facebook, Inc. and its affiliates. 123 | * 124 | * This source code is licensed under the MIT license found in the 125 | * LICENSE file in the root directory of this source tree. 126 | */ 127 | 128 | //! moment.js 129 | 130 | //! moment.js locale configuration 131 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | Let's Chat
-------------------------------------------------------------------------------- /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 | 38 | @media { 39 | .sm\:bg-spill-100 { 40 | --tw-bg-opacity: 1; 41 | background: linear-gradient(to right, #0094d8, #f5f5f5) !important; 42 | } 43 | } 44 | 45 | /* .bg-white { 46 | --tw-bg-opacity: 1; 47 | background: linear-gradient(to right, #696969, #004ad4) !important; 48 | } */ 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lets Chat", 3 | "version": "1.0.0", 4 | "description": "Lets Chat is an instant messaging app that allows you to quickly share text messages, emojis, photos, or files with other Lets Chat 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 | "letschat", 27 | "mern", 28 | "social-media", 29 | "socket.io", 30 | "websocket" 31 | ], 32 | "author": "Rohit Jha", 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.14", 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.3", 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 | "dependencies": "^0.0.1", 77 | "dotenv": "^16.0.3", 78 | "express": "^4.18.2", 79 | "jsonwebtoken": "^8.5.1", 80 | "linkify": "^0.2.1", 81 | "linkify-react": "^4.0.2", 82 | "mangodb": "^1.0.0", 83 | "moment": "^2.29.4", 84 | "mongodb": "^5.6.0", 85 | "mongoose": "^6.6.5", 86 | "nodemailer": "^6.8.0", 87 | "qrcode": "^1.5.1", 88 | "react": "^18.2.0", 89 | "react-dom": "^18.2.0", 90 | "react-easy-crop": "^4.6.2", 91 | "react-helmet": "^6.1.0", 92 | "react-icons": "^4.6.0", 93 | "react-redux": "^8.0.4", 94 | "react-router-dom": "^6.4.2", 95 | "socket.io": "^4.5.3", 96 | "socket.io-client": "^4.5.3", 97 | "tailwind-scrollbar": "^2.1.0-preview.0", 98 | "tailwindcss": "^3.1.8", 99 | "uuid": "^9.0.0" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /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/db/connect.js: -------------------------------------------------------------------------------- 1 | const { connect } = require('mongoose'); 2 | 3 | module.exports = async () => { 4 | try { 5 | const uri = 6 | 'mongodb+srv://rohitjha1:mPAnT8Vz4QAoqdh7@cluster0.oromcia.mongodb.net/?retryWrites=true&w=majority'; 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 | -------------------------------------------------------------------------------- /server/test/profile.test.js: -------------------------------------------------------------------------------- 1 | // const chai = require('chai'); 2 | // const chaiHttp = require('chai-http'); 3 | // const { describe, it, beforeEach } = require('mocha'); 4 | // const sinon = require('sinon'); 5 | // const ProfileModel = require('../db/models/profile'); 6 | // const ContactModel = require('../db/models/contact'); 7 | // const response = require('../helpers/response'); 8 | // const app = require('../your-express-app'); // Import your express app 9 | 10 | // chai.use(chaiHttp); 11 | // const {expect} = chai; 12 | 13 | // describe('Profile Controller', () => { 14 | // describe('findById', () => { 15 | // beforeEach(() => { 16 | // sinon.restore(); 17 | // }); 18 | 19 | // it('should find user profile by id', async () => { 20 | // const fakeProfile = { _id: 'fakeId', userId: 'userId', username: 'testuser' }; 21 | // const fakeContact = { _id: 'contactId', userId: 'currentUserId', friendId: 'userId' }; 22 | 23 | // sinon.stub(ProfileModel, 'findOne').resolves(fakeProfile); 24 | // sinon.stub(ContactModel, 'findOne').resolves(fakeContact); 25 | // sinon.stub(response, 'response'); 26 | 27 | // const req = { 28 | // params: { userId: 'userId' }, 29 | // user: { _id: 'currentUserId' }, 30 | // }; 31 | // const res = {}; 32 | 33 | // // eslint-disable-next-line no-undef 34 | // await yourController.findById(req, res); 35 | 36 | // sinon.assert.calledOnce(ProfileModel.findOne); 37 | // sinon.assert.calledOnce(ContactModel.findOne); 38 | // sinon.assert.calledOnce(response.response); 39 | 40 | // const expectedPayload = { ...fakeProfile, saved: true }; 41 | // sinon.assert.calledWith(response.response, { 42 | // res, 43 | // payload: expectedPayload, 44 | // }); 45 | // }); 46 | 47 | // it('should handle errors', async () => { 48 | // const errorMessage = 'An error occurred'; 49 | // sinon.stub(ProfileModel, 'findOne').throws(new Error(errorMessage)); 50 | // sinon.stub(response, 'response'); 51 | 52 | // const req = { 53 | // params: { userId: 'userId' }, 54 | // user: { _id: 'currentUserId' }, 55 | // }; 56 | // const res = {}; 57 | 58 | // // eslint-disable-next-line no-undef 59 | // await yourController.findById(req, res); 60 | 61 | // sinon.assert.calledOnce(ProfileModel.findOne); 62 | // sinon.assert.calledOnce(response.response); 63 | 64 | // sinon.assert.calledWith(response.response, { 65 | // res, 66 | // statusCode: 500, 67 | // success: false, 68 | // message: errorMessage, 69 | // }); 70 | // }); 71 | // }); 72 | 73 | // describe('edit', () => { 74 | // beforeEach(() => { 75 | // sinon.restore(); 76 | // }); 77 | 78 | // it('should edit user profile', async () => { 79 | // const fakeProfileUpdateResult = { nModified: 1 }; 80 | // sinon.stub(ProfileModel, 'updateOne').resolves(fakeProfileUpdateResult); 81 | // sinon.stub(response, 'response'); 82 | 83 | // const req = { 84 | // user: { _id: 'currentUserId' }, 85 | // body: { username: 'newusername' }, 86 | // }; 87 | // const res = {}; 88 | 89 | // // eslint-disable-next-line no-undef 90 | // await yourController.edit(req, res); 91 | 92 | // sinon.assert.calledOnce(ProfileModel.updateOne); 93 | // sinon.assert.calledOnce(response.response); 94 | 95 | // sinon.assert.calledWith(response.response, { 96 | // res, 97 | // message: 'Profile updated successfully', 98 | // payload: fakeProfileUpdateResult, 99 | // }); 100 | // }); 101 | 102 | // it('should handle duplicate username error', async () => { 103 | // const fakeError = new Error(); 104 | // fakeError.name = 'MongoServerError'; 105 | // fakeError.code = 11000; 106 | // sinon.stub(ProfileModel, 'updateOne').throws(fakeError); 107 | // sinon.stub(response, 'response'); 108 | 109 | // const req = { 110 | // user: { _id: 'currentUserId' }, 111 | // body: { username: 'newusername' }, 112 | // }; 113 | // const res = {}; 114 | 115 | // // eslint-disable-next-line no-undef 116 | // await yourController.edit(req, res); 117 | 118 | // sinon.assert.calledOnce(ProfileModel.updateOne); 119 | // sinon.assert.calledOnce(response.response); 120 | 121 | // sinon.assert.calledWith(response.response, { 122 | // res, 123 | // statusCode: 500, 124 | // success: false, 125 | // message: 'This username is already taken', 126 | // }); 127 | // }); 128 | 129 | // it('should handle other errors', async () => { 130 | // const errorMessage = 'An error occurred'; 131 | // sinon.stub(ProfileModel, 'updateOne').throws(new Error(errorMessage)); 132 | // sinon.stub(response, 'response'); 133 | 134 | // const req = { 135 | // user: { _id: 'currentUserId' }, 136 | // body: { username: 'newusername' }, 137 | // }; 138 | // const res = {}; 139 | 140 | // // eslint-disable-next-line no-undef 141 | // await yourController.edit(req, res); 142 | 143 | // sinon.assert.calledOnce(ProfileModel.updateOne); 144 | // sinon.assert.calledOnce(response.response); 145 | 146 | // sinon.assert.calledWith(response.response, { 147 | // res, 148 | // statusCode: 500, 149 | // success: false, 150 | // message: errorMessage, 151 | // }); 152 | // }); 153 | // }); 154 | // }); 155 | -------------------------------------------------------------------------------- /server/test/user.test.js: -------------------------------------------------------------------------------- 1 | // const chai = require('chai'); 2 | // const { describe, it } = require('mocha'); 3 | // const mongoose = require('mongoose'); 4 | // const UserModel = require('../path-to-your-user-model'); // Replace with the actual path 5 | 6 | // chai.should(); 7 | 8 | // describe('User Model', () => { 9 | // before(async () => { 10 | // await mongoose.connect('mongodb://localhost:27017/testdb', { 11 | // useNewUrlParser: true, 12 | // useUnifiedTopology: true, 13 | // }); 14 | // }); 15 | 16 | // after(async () => { 17 | // await mongoose.connection.close(); 18 | // }); 19 | 20 | // it('should be able to save a user', async () => { 21 | // const userData = { 22 | // username: 'testuser', 23 | // email: 'test@example.com', 24 | // password: 'testpassword', 25 | // }; 26 | 27 | // const newUser = new UserModel(userData); 28 | // const savedUser = await newUser.save(); 29 | 30 | // savedUser.should.have.property('_id'); 31 | // savedUser.username.should.equal(userData.username); 32 | // savedUser.email.should.equal(userData.email); 33 | // savedUser.password.should.equal(userData.password); 34 | // savedUser.should.have.property('qrCode'); 35 | // savedUser.should.have.property('verified'); 36 | // savedUser.should.have.property('otp'); 37 | // }); 38 | 39 | // it('should fail to save a user with invalid data', async () => { 40 | // const invalidUserData = { 41 | // // Missing required fields 42 | // }; 43 | 44 | // try { 45 | // const newUser = new UserModel(invalidUserData); 46 | // await newUser.save(); 47 | // } catch (error) { 48 | // error.should.have.property('name').equal('ValidationError'); 49 | // } 50 | // }); 51 | 52 | // // Add more test cases for other scenarios 53 | 54 | // }); 55 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------