[React + Node.js]: Create Messenger App with MERN Stack + Socket.io
React
Node.js
04/22/2020
Overview
This post explains how to build a basic real-time messenger application with MERN (MongoDB, Express.js, React, Node.js) Stack + Socket.io.
Demo
Left and right screens are to demonstrate different clients by using differnet browsers.
Try it yourself here
Prerequisite
If you don't have a MERN Stack application set up, here is a starter that you can clone.
clone
git clone https://github.com/EllisMin/mern-custom.git ~/YOUR_FOLDER/messenger-app
Install dependencies (Server & Client)
cd ~/YOUR_FOLDER/messenger-app && npm run install-both
Install Extra dependencies needed for this project
Front-end: React Bootstrap | Socket.io Client | Fontawesome
BASHnpm i react-bootstrap bootstrap socket.io-client react-timeago @fortawesome/fontawesome-svg-core @fortawesome/react-fontawesome @fortawesome/free-solid-svg-iconsBack-end: Socket.io | GeoIP-lite | express-useragent | express-validator | express-rate-limit
BASHnpm i socket.io geoip-lite express-useragent express-validator express-rate-limitBoth at once
BASHnpm i react-bootstrap bootstrap socket.io-client react-timeago @fortawesome/fontawesome-svg-core @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons socket.io geoip-lite express-useragent express-validator express-rate-limit
Server side
MongoDB URI
Update the MongoDB URI to yours
module.exports = { MONGODB_URI: `mongodb+srv://<DB_USERNAME>:<PASSWORD>@cluster0-abcde.mongodb.net/test`,}
Socket util
This fils is used to share socket io instance across different files.
let iomodule.exports = { init: httpServer => { io = require('socket.io')(httpServer) return io }, getIO: () => { if (!io) { throw new Error('Socket.io is not initialized') } return io },}
Create message schema
I'll allow a public user or a visitor to join the messenger. So, each message on client side will display partial IP address i.e. (11.222)
as well as user's current time zone. i.e. America/Chicago
with the help of geoip-lite
package.
To limit the number of post request from the public users, ip
, platform
, and os
will be used to check for the same device under same ip address. Checking the same ip
may not be sufficient as multiple devices may be used under the same external ip addresses. Although this is not a perfect solution, I've decided to check for the platform
, i.e. Apple Mac
and os
, i.e. macOS Mojave
provided with express-useragent
package.
const mongoose = require('mongoose')const Schema = mongoose.Schema
const messageSchema = new Schema( { ip: String, agent: { platform: String, os: String, }, partialIp: String, timezone: String, message: { type: String, required: true, }, }, { timestamps: true })
module.exports = mongoose.model('Message', messageSchema)
Create controller
Imports
const geoip = require('geoip-lite')const io = require('../utils/socket')const Message = require('../models/message')
Get messages
This method retrieves the latest 10 messages and send to the client.
exports.getMessages = async (req, res, next) => { try { // Get last(latest) 10 messages const messages = await Message.find() .select('-updatedAt -__v -_id') .sort({ createdAt: -1 }) .limit(10)
if (!messages) { const error = new Error('Failed to fetch messages') error.statusCode = 404 throw error }
res.status(200).json({ message: 'Fetched messages', messages: messages, }) } catch (err) { if (!err.statusCode) { err.statusCode = 500 } next(err) }}
Post message
When a client requests to post a message, this function looks up how many messages user has posted for the past 3 minutes to limit the request. Also it removes the oldest message from database when it reaches limit.
exports.postMessage = async (req, res, next) => { // Retrieve IP from the header let ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress if (ip.includes(',')) { ip = ip.substring(0, ip.indexOf(',')) }
// Retrieve partial IP from ip. i.e. 111.22 let partialIp = '' const splittedIps = ip.split('.') if (splittedIps.length < 1) { } else if (splittedIps.length === 1) { partialIp = splittedIps[0] } else { partialIp = `${splittedIps[0]}.${splittedIps[1]}` }
try { // Get timezone from ip address. When not found, set default to prevent error let geo = geoip.lookup(ip) if (!geo) { geo = { timezone: '', agent: { isBot: false } } }
const message = req.body.message
if (!message) { const error = new Error('Failed to get message') error.statusCode = 404 throw error }
// Bot check if (geo.agent && geo.agent.isBot) { const error = new Error('Failed to post') error.statusCode = 400 throw error }
// Query messages created last 3 minutes to check for # request limit const recentMessages = await Message.find({ createdAt: { $gt: new Date(Date.now() - 3 * 60 * 1000) }, })
let ipMatchCount = 0 let platformMatchCount = 0
// Check if there are multiple entries with same ip & device recentMessages.forEach(m => { if (m.ip === ip) { ipMatchCount++ if ( m.agent && m.agent.platform === req.useragent.platform && m.agent.os === req.useragent.os ) { platformMatchCount++ } } })
// Limit requests with same ip and device to 10 or same ip to 20 if ( (ipMatchCount >= 10 && platformMatchCount >= 10) || ipMatchCount >= 20 ) { const error = new Error('Message limit reached') error.statusCode = 400 throw error }
/* Optional: When number of messages reaches the limit (50), it removes the oldest message */ const totalMessages = await Message.find().countDocuments() if (totalMessages >= 50) { const oldestMessage = await Message.find() .sort({ createdAt: 1 }) .limit(1)
if (!oldestMessage) { const error = new Error("Couldn't find message to remove") error.statusCode = 404 throw error }
await Message.findByIdAndRemove(oldestMessage[0]._id) }
const messageDoc = new Message({ ip: ip, agent: { isBot: req.useragent.isBot, platform: req.useragent.platform, os: req.useragent.os, }, partialIp: partialIp, timezone: geo.timezone, message: message, }) await messageDoc.save()
// Object to pass to socket.io client const message_ = new Message({ partialIp: messageDoc.partialIp, timezone: geo.timezone, message: message, createdAt: messageDoc.createdAt, })
// Sends message to all connected users io.getIO().emit('message event', { action: 'add', message: { ...message_._doc }, })
res.status(201).json({ message: 'Message created', }) } catch (err) { if (!err.statusCode) { err.statusCode = 500 } next(err) }}
Create route
express-validator
validates the input passed in req.body
. Character limit is between 1 and 100.
const express = require('express')const { body } = require('express-validator')const router = express.Router()const messageController = require('../controllers/message')
// GET /messagesrouter.get('/messages', messageController.getMessages)
// POST /messagerouter.post( '/message', [ body('message') .trim() .isLength({ min: 1, max: 100 }), ], messageController.postMessage)
module.exports = router
Create controller for socket.io
exports.setUpIOConnection = server => { const io = require('../utils/socket').init(server) const users = []
io.on('connection', async socket => { users.push({ id: socket.id })
io.emit('init', { users: users })
// Handle disconnect socket.on('disconnect', reason => { // Remove user let index = -1 for (let i = 0; i < users.length; i++) { const user = users[i] if (user.id === socket.id) { index = i } } // Remove user if (index !== -1) { users.splice(index, 1) } io.emit('init', { users: users }) }) })}
app.js
useragent
: used to parses user-agent inside header to retrieveplatform
andos
information. It will be accessible inreq.useragent
which is used in controllerrateLimit
: used to limit number of requests
require('dotenv').config()const path = require('path')const express = require('express')const mongoose = require('mongoose')const useragent = require('express-useragent') const rateLimit = require('express-rate-limit') const { MONGODB_URI } = require('./config')const messageRoutes = require('./routes/message') const { setUpIOConnection } = require('./controllers/socket')
const app = express()app.use(express.json())
app.use(useragent.express()) // Set up rate limiter for entire app requestsconst appLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 150, // limit each IP # requests per windowMs})app.use(appLimiter)
// Serves mern-demo/client/build staticallyapp.use(express.static(path.join('client', 'build')))
// Set CORS headerapp.use((req, res, next) => { res.setHeader('Access-control-Allow-Origin', '*') res.setHeader( 'Access-Control-Allow-Methods', 'GET, POST, DELETE, PUT, OPTIONS' ) res.setHeader('Set-Cookie', 'HttpOnly;Secure;SameSite=Strict') // Allow client to set headers with Content-Type res.setHeader('Access-Control-Allow-Headers', 'Content-Type') next()})
// Register routesapp.use(messageRoutes)
app.use((req, res, next) => { res.sendFile(path.resolve(__dirname, 'client', 'build', 'index.html'))})
// Error Handlerapp.use((error, req, res, next) => { const status = error.statusCode || 500 const message = error.message const data = error.data // Passing original error data res.status(status).json({ message: message, data: data })})
// DB connectionmongoose .connect(MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false, }) .then(result => { const port = process.env.PORT || 8080 const server = app.listen(port, () => { console.log(`Listening on port ${port}...`) }) setUpIOConnection(server) }) .catch(err => { // Handle error })
Client side – Creating views
Create Chat
directory under Components
as well as children files:
.└── client └── src └── Components ├── Chat | ├── ChatIcon | | └── index.js | | └── styles.scss | ├── ChatModal | | ├── index.js | | └── styles.scss | └── index.js └── ...
Set default exports for ChatIcon
and ChatModal
inside Chat/index.js
export { default as ChatIcon } from './ChatIcon'export { default as ChatModal } from './ChatModal'
ChatIcon
In ChatIcon
component, userCount
and toggleShowChat
will be passed down from App
component later
import React from 'react'import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'import { faCommentDots } from '@fortawesome/free-solid-svg-icons'
import './styles.scss'
const ChatIcon = ({ userCount, toggleShowChat }) => { return ( <div className="chat-icon-container" onClick={toggleShowChat}> <FontAwesomeIcon className="icon-fa icon-comment-dots" icon={faCommentDots} /> <span className="chat-icon-user-count disable-selection"> {`chat(${userCount})`} </span> </div> )}
export default ChatIcon
Add ChatIcon
Styling
.chat-icon-container { cursor: pointer; position: fixed; bottom: 0; right: 0; z-index: 1; margin: 1rem; padding: 1rem; border-radius: 50%; background: #fff; box-shadow: 0 2px 6px #ccc;
&:hover { .icon-comment-dots { opacity: 1; } }
.icon-comment-dots { color: #ea4334; opacity: 0.8; width: 25px; height: 25px; }
.chat-icon-user-count { position: absolute; bottom: 3px; left: 50%; transform: translateX(-50%); font-size: 0.7rem; color: #333; }}
App
Import ChatIcon
inside App
and create state to toggle showing/hiding the chat
import React, { useState } from 'react' import Header from '../Header'import { ChatIcon, ChatModal } from '../Chat'
import './styles.scss'
const App = () => { const [showChat, setShowChat] = useState(false) const toggleShowChat = () => { setShowChat(!showChat) } return ( <div className="App"> <Header title="Messenger App" /> <ChatIcon toggleShowChat={toggleShowChat} /> </div> )}export default App
In App/styles.scss
, add relative position and import bootstrap
@import '~bootstrap/scss/bootstrap'; @import '../Shared/config';@import '../Shared/util';
.App { position: relative; text-align: center;}
At this point, your app should have a fixed positioned chat icon available on your app
ChatModal
import React, { useState, useRef, useEffect } from 'react'import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'import { faWindowMinimize, faEllipsisH,} from '@fortawesome/free-solid-svg-icons'import { InputGroup, FormControl, Button as Btn, Form } from 'react-bootstrap'import TimeAgo from 'react-timeago'import { MESSAGE } from '../../../constants'import Button from '../../Button'import Alert from '../../Alert'
import './styles.scss'
const ChatModal = ({ toggleShowChat, userCount, messages }) => { const [sendLoading, setSendLoading] = useState(false) const [message, setMessage] = useState('') const [error, setError] = useState(undefined) const scrollDivRef = useRef(null) const inputRef = useRef(null)
const scrollToBottom = () => { scrollDivRef.current.scrollIntoView({ behavior: 'auto' }) }
// Scroll to bottom when messages change useEffect(scrollToBottom, [messages])
const handleChange = e => { const value = e.target.value const rowCount = value.split('\n').length
// Character limit & row limit if ( value.length <= MESSAGE.CHAR_LIMIT && rowCount <= MESSAGE.TEXTAREA_MAX_ROWS && value !== '\n' ) { setMessage(value) } }
// Submit on enter in textarea. Shift + enter still valid to type newline const handleKeyPress = e => { // Submit if enter was pressed without shift if (e.key === `Enter` && !e.shiftKey) { e.preventDefault() // Prevents newline handleSubmit(e) } }
const handleSubmit = async e => { e.preventDefault()
if (message.trim().length > 0 && message.length <= MESSAGE.CHAR_LIMIT) { setSendLoading(true) try { const res = await fetch(process.env.REACT_APP_FETCH_URL + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message: message, }), }) if (res.status === 400) { throw new Error("You're typing too fast! Please try again soon") } if (res.status !== 200 && res.status !== 201) { throw new Error('Failed to post') } await res.json() setMessage('') // Clear input field setSendLoading(false) } catch (err) { setError(err) setMessage('') setSendLoading(false) } } }
return ( <div className="chat-modal"> <div className="chat-main"> <div className="chat-delay-show"> <header className="chat-header"> <div className="icon-container"> <Button disabled iconBtn> <FontAwesomeIcon className={`icon-ellipsis-h`} icon={faEllipsisH} /> </Button> </div> <h1 className="chat-header-text">{`Chat with others! (${userCount})`}</h1> <div className="icon-container"> <Button iconBtn onClick={toggleShowChat}> <FontAwesomeIcon className={`icon-window-minimize`} icon={faWindowMinimize} /> </Button> </div> </header> <div className="chat-content"> <div className="chat-content-inner"> <Alert error={error} onClose={() => setError(undefined)} /> {messages.length > 0 ? ( messages.map((m, i) => ( <div key={i} className="message-container"> <div className="message-sender"> <span className="message-sender-location"> {`${m.partialIp}${ m.timezone ? ` (${m.timezone})` : `` }`} </span> <span className="message-sender-time"> <TimeAgo date={m.createdAt} /> </span> </div> <div className="message-block"> <span>{m.message}</span> </div> </div> )) ) : ( <Loader /> )} <div ref={scrollDivRef} /> </div> </div> <div className="chat-input-container" onSubmit={handleSubmit}> <Form> <InputGroup className="mb-2" size="sm"> <FormControl required as="textarea" rows={MESSAGE.TEXTAREA_MAX_ROWS} maxLength={MESSAGE.CHAR_LIMIT} placeholder="Text Message" aria-label="Text Message" aria-describedby="basic-addon2" value={message} onChange={handleChange} onKeyPress={handleKeyPress} ref={inputRef} /> <InputGroup.Append> <Btn variant="outline-secondary" type="submit" disabled={sendLoading} > Send </Btn> </InputGroup.Append> </InputGroup> </Form> </div> </div> </div> </div> )}
export default ChatModal
{ MESSAGE }
comes from the following file. It specifies the character limit (for the client side!) and the maximum number of rows for the textarea.
export const MESSAGE = { CHAR_LIMIT: 100, TEXTAREA_MAX_ROWS: 5,}
Add styling for ChatModal
$chat-width: 320px;$chat-height: 620px;
.chat-modal { position: fixed; bottom: 0; right: 0; z-index: 2; padding: 1rem;
.chat-main { width: $chat-width; height: $chat-height; background: #f7f7f7; border-radius: 10px; box-shadow: 0 2px 6px #aaa; animation: expand 250ms ease-in;
.chat-delay-show { position: relative; display: flex; flex-direction: column; overflow: hidden; height: 100%; opacity: 0; animation: delayShow 200ms forwards 350ms; } .chat-header { display: flex; justify-content: space-between; align-items: center; background: #fff; border-top-left-radius: 10px; border-top-right-radius: 10px; padding: 0.3rem 1rem;
&-text { font-size: 1rem; color: #333; margin: 0; }
.icon-container { .icon-window-minimize { color: #333; opacity: 0.7; &:hover { opacity: 1; } }
.icon-ellipsis-h { color: #ddd; } } }
.chat-content { display: block; position: relative; height: 100%; white-space: pre-wrap; word-wrap: break-word;
.chat-content-inner { position: absolute; top: 0; left: 0; right: 0; bottom: 0; padding: 0.2rem; height: 100%; overflow-y: auto; background: transparent; overscroll-behavior: contain;
.message-container { display: flex; flex-direction: column; border-top: 1px solid #ddd; padding: 0.5rem; .message-sender { display: flex; justify-content: space-between; span { font-size: 0.7rem; text-align: left; margin-bottom: 0.1rem; margin-left: 0.2rem; color: #555; } } .message-block { padding: 0.3rem 0.6rem; background: #fff; border: 1px solid #eee; color: #333; border-radius: 13px; font-size: 0.8rem; text-align: left; animation: fadeInMessage 800ms ease-in; } }
.alert-custom { position: sticky; top: 0; } } }
.chat-input-container { background: #fbfaff; padding: 0.3rem; .btn-outline-secondary { border-color: #cbc7e6; } .btn:hover { background: #e7f2ff; color: #333; }
.form-control { font-size: 16px !important; } textarea { resize: none; height: 40px; } } }}
@keyframes expand { from { width: 0px; height: 0px; } to { width: $chat-width; height: $chat-height; }}
@keyframes delayShow { to { opacity: 1; }}
@keyframes fadeInMessage { from { opacity: 0.2; background: #aebaeb; } to { opacity: 1; background: #fff; }}
Back to App
import React, { useState, useEffect } from 'react'import openSocket from 'socket.io-client'import Alert from '../Alert'import Header from '../Header'import { ChatIcon, ChatModal } from '../Chat'
import './styles.scss'
const App = () => { const [showChat, setShowChat] = useState(false) const toggleShowChat = () => setShowChat(!showChat) const [publicUsers, setPublicUsers] = useState([]) const [messages, setMessages] = useState([]) const [error, setError] = useState(null)
useEffect(() => { createSocketConnection() fetchMessages() }, [])
const createSocketConnection = () => { const socket = openSocket(process.env.REACT_APP_FETCH_URL) // init socket.on('init', data => { setPublicUsers(data.users) }) // Message events socket.on('message event', data => { if (data.action === 'add') { setMessages(prevMessages => [...prevMessages, data.message]) } }) }
const fetchMessages = async () => { try { const res = await fetch(process.env.REACT_APP_FETCH_URL + '/messages') if (res.status !== 200) { throw new Error('Failed to fetch messages') } const resData = await res.json() // Set reversed 10 messages setMessages(resData.messages.reverse()) } catch (err) { setError(err) } }
return ( <div className="App"> <Header title="Messenger App" /> <Alert error={error} onClose={() => setError(null)} /> {showChat ? ( <ChatModal toggleShowChat={toggleShowChat} userCount={publicUsers.length} messages={messages} /> ) : ( <ChatIcon toggleShowChat={toggleShowChat} userCount={publicUsers.length} /> )} </div> )}
export default App
Playing sound when sending or receiving message
On desktop, setting const [audio] = useState(new Audio(popSoundSrc))
and audio.play()
are just enough to play sound; however, iOS prevents JavaScript play()
and load()
methods. In other words, user must interact with the website first i.e. clicking a button before audio gets played. In this example, we'll play muted audio on clicking chat icon to work around this problem. Read more about how iOS handles HTML5 Audio and Video
// ...
import popSoundSrc from "../../assets/pop.mp3"let audioFirstPlayed = false
const App = () => { // ... const [audio] = useState(new Audio(popSoundSrc))
// ...
const createSocketConnection = () => { const socket = openSocket(process.env.REACT_APP_FETCH_URL) // init socket.on("init", data => { setPublicUsers(data.users) })
// Message events socket.on("message event", data => { if (data.action === "add") { setMessages(prevMessages => [...prevMessages, data.message]) if (audio.muted) { audio.muted = false } audio.play() } }) }
const toggleShowChat = () => { if (!audioFirstPlayed) { audio.muted = true audio.play() audioFirstPlayed = true } setShowChat(!showChat) }
return ( ... )}
export default App
Possible enhancements from here
- Queue sending multiple messages
- [Git + cron]: Schedule your git commands on AWS EC2
- [React]: Use global state with React Hook and Context