[React + Node.js]: Create Messenger App with MERN Stack + Socket.io

React

Node.js

04/22/2020


messages

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

BASH
git clone https://github.com/EllisMin/mern-custom.git ~/YOUR_FOLDER/messenger-app

Install dependencies (Server & Client)

BASH
cd ~/YOUR_FOLDER/messenger-app && npm run install-both

Install Extra dependencies needed for this project

  • Front-end: React Bootstrap | Socket.io Client | Fontawesome

    BASH
    npm i react-bootstrap bootstrap socket.io-client react-timeago @fortawesome/fontawesome-svg-core @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons
  • Back-end: Socket.io | GeoIP-lite | express-useragent | express-validator | express-rate-limit

    BASH
    npm i socket.io geoip-lite express-useragent express-validator express-rate-limit
  • Both at once

    BASH
    npm 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

config.js
JS
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.

/utils/socket.js
JS
let io
module.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.

/models/message.js
JS
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

/controllers/message.js
JS
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.

/controllers/message.js
JS
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.

/controllers/message.js
JS
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.

routes/code.js
JS
const express = require('express')
const { body } = require('express-validator')
const router = express.Router()
const messageController = require('../controllers/message')
// GET /messages
router.get('/messages', messageController.getMessages)
// POST /message
router.post(
'/message',
[
body('message')
.trim()
.isLength({ min: 1, max: 100 }),
],
messageController.postMessage
)
module.exports = router

Create controller for socket.io

/controllers/socket.js
JS
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 retrieve platform and os information. It will be accessible in req.useragent which is used in controller
  • rateLimit: used to limit number of requests
/app.js
JS
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 requests
const appLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 150, // limit each IP # requests per windowMs
})
app.use(appLimiter)
// Serves mern-demo/client/build statically
app.use(express.static(path.join('client', 'build')))
// Set CORS header
app.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 routes
app.use(messageRoutes)
app.use((req, res, next) => {
res.sendFile(path.resolve(__dirname, 'client', 'build', 'index.html'))
})
// Error Handler
app.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 connection
mongoose
.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:

TEXT
.
└── 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

.../Chat/index.js
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

.../Chat/ChatIcon/index.js
JSX
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/ChatIcon/styles.scss
SCSS
.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

App/index.js
JSX
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

App/styles.scss
SCSS
@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

ChatModal/index.js
JSX
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.

.../src/constants/enum.js
JS
export const MESSAGE = {
CHAR_LIMIT: 100,
TEXTAREA_MAX_ROWS: 5,
}

Add styling for ChatModal

ChatModal/styles.scss
SCSS
$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

App/index.js
JSX
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

App/index.js
JS
// ...
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

WRITTEN BY

Keeping a record