├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------