What a day to build! Today we are exploring how far chat has come, and how easy it is build a fully functional chat application complete with the ability to send SMS messages with just a few (super-rad) developer tools that are available to anyone with fingertips. There's quite a lot to cover so let's dive in!
TLDR:
Introduction
Let's talk about goals, what we want to accomplish in the next hour or so.
Our application is at it's core a chat system. We're going to keep things very simple here. Our system will be able to register new users with minimal user information, create and emit messages to users in the chat room, and finally log users out of the chat room when they want to leave.
For bonus points we are also going to configure our system with an SMS notification system, that will send a Text notification to an admin account whenever a message is sent across our system. Pretty cool right?
Before We Start
This system is going to utilize a number of technologies to function. Most importantly Cosmic JS for managing all of our data: our Users, and our Messages. In order to follow along with this tutorial you should a free bucket on their platform and create a User and a Message Object.
in order to send SMS messages we are leveraging Twilio. A communications platform that allows developers to make phone calls and send text messages through a web API. To implement our notifications you'll have to register for a free Twilio number and start a trial account.
Software Requirements?
We are using Node JS as our runtime environment so please be sure you have a recent(ish) version of node installed. With that business out of the way we can start building.
High Level Overview
This is a full stack application, which means we are building a web-server to handle our requests and serve our client side application. We are going to create an Express application that will run on our Node JS server to handle routing to a small API and serve HTML, and Webpack to bundle our client interface built with React and Graphql. This way we can utilize a central server to make requests to the different parts of our application: our Interface, our Controllers, and our Web Services.
There are quite a few moving parts here so let's jump in!
Building Our Web Server
This is backbone our our app which will allow us to control the various pieces of our core application. We are going to start by creating and initializing a project directory where all of our dependencies will be installed. Let's open up our terminal and create some files:
$ mkdir chat
$ cd chat
This creates a directory called chat and changes our current directory into that chat directory. Now we can initialize this directory as a Node project:
$ npm init
Your terminal will present you with a couple of prompts to create our package.json file that will contain most of the metadata about our project. I recommend hitting the enter key through these steps unless you know some specific information you'd like to give your application. You can always change these values later.
Now let's install some Node Modules we need to run our project and save them to package.json dependency list. We are going to install our bundler webpack along with the necessary loaders we need to bundle our JS, HTML, and SASS /CSS files as well as our server framework express:
$ npm install --save webpack webpack-cli clean-webpack-plugin @babel/core @babel/preset-env @babel/preset-react babel-loader file-loader sass-loader css-loader node-sass url-loader style-loader express express-session cookie-parser body-parser socket.io socket.io-client cosmicjs dotenv
We are saving these all as project dependencies since any deployment container we use will need to use these to build and run our application.
Next we are also going to install the dependencies required for rendering our User Interface:
$ npm install --save react react-dom react-router-dom react-icons graphql react-apollo apollo-boost axios
Setting Up Our Entry File
Now that we have some dependencies installed, we need to create an entry file that will handle all of the requests to our application. When a user makes a request to the default route '/', we will serve an HTML file. When the client makes a request to our API, we use endpoints appended with '/api'. The first step is just creating the file:
$ touch index.js
Let's open this file in our text editor and set up Express so that we serve some HTML when a browser navigates to our localhost server:
const express = require('express'); | |
const path = require('path'); | |
const PORT = process.env.PORT || 3000; | |
const app = express(); | |
const http = require('http').createServer(app); | |
app.get('/', (req, res) => { | |
res.sendFile(path.join(__dirname, './public', 'index.html')); | |
}); | |
http.listen(PORT, () => { | |
console.log(`Cosmic Messenger listening on port : ${PORT}`); | |
}); |
We are looking for an index.html file that is kept in a directory located at ./public so let's go ahead and create this file at ./public/index.html and insert some boilerplate to make sure our HTML is being served by our express server.
const express = require('express'); | |
const path = require('path'); | |
const PORT = process.env.PORT || 3000; | |
const app = express(); | |
const http = require('http').createServer(app); | |
app.get('/', (req, res) => { | |
res.sendFile(path.join(__dirname, './public', 'index.html')); | |
}); | |
http.listen(PORT, () => { | |
console.log(`Cosmic Messenger listening on port : ${PORT}`); | |
}); |
We should be able to start our server using node:
$ node index.js
When this command runs we should see a simple console message:
Cosmic Messenger listening on port : 3000
Now pointing our browser to http://localhost:3000 will show us a mostly blank screen but now we see a little "Hello World" at top of our page. Now our server is set up to serve content from our html file.
Configuring Webpack
We want to build our interface with React, but we need to serve this interface from a directory that our node server can access. We also need to compile our javascript from the fancy syntax that we use to build our components to something that all browsers can process. To do this we are going to use Webpack to bundle all of our files into a specific location, and Babel to compile all of our code.
Let's create a file called webpack.config.js and add some configuration for bundling our client interface:
const webpack = require('webpack'); | |
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); | |
const dev = Boolean(process.env.NODE_ENV !== 'production'); | |
module.exports = { | |
entry: './src/app.js', | |
mode: dev ? 'development' : 'production', | |
module: { | |
rules: [ | |
{ | |
test: /\.(js|jsx)$/, | |
exclude: /node_modules/, | |
use: ['babel-loader'] | |
}, | |
{ | |
test: /\.(scss|css)$/, | |
use: ['style-loader', 'css-loader', 'sass-loader'] | |
}, | |
{ | |
test: /\.(png|jpg|gif|svg)$/, | |
use: [ | |
{ | |
loader: 'url-loader', | |
options: { | |
limit: 5000 | |
} | |
} | |
] | |
} | |
] | |
}, | |
resolve: { | |
extensions: ['*', '.js', '.jsx'] | |
}, | |
output: { | |
path: __dirname + '/dist', | |
publicPath: '/', | |
filename: 'bundle.js' | |
}, | |
plugins: [ | |
new webpack.HotModuleReplacementPlugin(), | |
new CleanWebpackPlugin, | |
], | |
devServer: { | |
contentBase: './dist', | |
hot: true | |
} | |
}; |
This is going to allow us to create source code and organize it in a meaningful way using whatever directory structure we like for structuring logic, then bundle it all into one file that our index.html can reference while it's served from our webServer.
Initializing Our React App
We have our config file created, but now we need to create some source code and make sure webpack bundles everything properly. Let's go ahead and create a folder called src and touch a file called app.js within. From there we can create a simple React component that will render the same thing as before, but now we are serving javascript bundled together and injected into our index.html served from our web server. Server side rendering baby!
Here's what our app.js file will look like initially:
import React from 'react'; | |
import { hydrate } from 'react-dom'; | |
class App extends React.Component { | |
render() { | |
return ( | |
<div className="app-container"> | |
Hello World | |
</div> | |
); | |
} | |
} | |
hydrate( | |
<App />, | |
document.getElementById('app') | |
); | |
module.hot.accept(); |
Before we run webpack and serve our interface we need to install some developer dependencies and add some simple configuration values to our package.json. Specifically we need to tell our server that we are using babel to compile our interface code and some npm scripts so that we can run our Web Server and bundle our React code.
Let's install some dependencies that we only need to use for local development purposes:
$ npm install --save-dev morgan nodemon webpack-dev-server
With those installed, let's open package.json and add a prestart, start, and dev properties to our scripts object as well as a babel config object. Here's how things should look:
{ | |
"name": "", | |
"version": "1.0.0", | |
"description": "", | |
"main": "index.js", | |
"scripts": { | |
"test": "echo \"Error: no test specified\" && exit 1", | |
"prestart": "webpack -p", | |
"start": "NODE_ENV=production node index.js", | |
"dev": "NODE_ENV=development nodemon index.js & webpack --config ./webpack.config.js --watch", | |
"client": "webpack-dev-server --config ./webpack.config.js --watch --mode development" | |
}, | |
"author": "", | |
"license": "ISC", | |
"dependencies": { | |
"@babel/core": "^7.4.5", | |
"@babel/preset-env": "^7.4.5", | |
"@babel/preset-react": "^7.0.0", | |
"apollo-boost": "^0.4.0", | |
"babel-loader": "^8.0.6", | |
"body-parser": "^1.19.0", | |
"clean-webpack-plugin": "^3.0.0", | |
"cosmicjs": "^3.2.17", | |
"css-loader": "^2.1.1", | |
"dotenv": "^8.0.0", | |
"express": "^4.17.0", | |
"express-session": "^1.16.1", | |
"file-loader": "^3.0.1", | |
"graphql": "^14.3.1", | |
"node-sass": "^4.12.0", | |
"react": "^16.8.6", | |
"react-apollo": "^2.5.6", | |
"react-dom": "^16.8.6", | |
"react-icons": "^3.7.0", | |
"react-router-dom": "^5.0.0", | |
"sass-loader": "^7.1.0", | |
"style-loader": "^0.23.1", | |
"twilio": "^3.31.0", | |
"url-loader": "^1.1.2", | |
"webpack": "^4.32.2", | |
"webpack-cli": "^3.3.2" | |
}, | |
"devDependencies": { | |
"morgan": "^1.9.1", | |
"nodemon": "^1.19.1", | |
"webpack-dev-server": "^3.4.1" | |
}, | |
"babel": { | |
"presets": [ | |
"@babel/preset-env", | |
"@babel/preset-react" | |
] | |
}, | |
"engines": { | |
"node": "8.x" | |
} | |
} |
Now we can run webpack and node simultaneously by simply running:
$ npm run dev
In a second you'll see some output text from webpack:
Heading back to http://localhost:3000 should produce the same result as before, but now we are serving a React application allowing us to create sensible component classes and render them within our index.html.
Creating our REST API
We are going to interface with our Cosmic JS resources by making requests to our server code via express routes which we'll configure right now.
We will need three POST routes that will handle requests to our server. One for registering users that visit the app, one for messages that get sent through the messenger, and a logout route for users wanting to leave the chat.
We also want to configure middleware for handling the request bodies sent through our api, body-parser, a module for creating session cookies to weakly authenticate requests to our messaging service, express-session. Finally we need to configure web sockets so that we can emit events to all the clients connected to our server via socket.io.
Socket.io will emit 3 separate events for the time being: One when a user registers so that other clients can track who is logged in to the app in real time. Inversely, we are tracking a logout event that will let user know when users have left the chat. And one for when a *message is sent.
If you didn't install them at the beginning of our server setup, you can install them with a quick npm command:
$ npm install --save socket.io socket.io-client express-session body-parser
Now let's open up our entry file: index.js, and add our routes and middleware configuration:
// import our js libraries | |
require('dotenv').config(); | |
const express = require('express'); | |
const path = require('path'); | |
const bodyparser = require('body-parser'); | |
const cookieParser = require('cookie-parser'); | |
const app = express(); | |
const session = require('express-session'); | |
const http = require('http').createServer(app); | |
const io = require('socket.io')(http); | |
const twilioNotificatations = require('./middleware/twilioNotifications'); | |
const Cosmic = require('cosmicjs'); | |
const api = Cosmic(); | |
const bucket = api.bucket({ | |
slug: 'cosmic-messenger', | |
read_key: process.env.__COSMIC_READ_KEY__, | |
write_key: process.env.__COSMIC_WRITE_KEY__ | |
}); | |
// configure our application level middleware | |
if (process.env.NODE_ENV === 'development') { | |
const morgan = require('morgan'); | |
app.use(morgan('dev')); | |
} | |
app.use('/', express.static('./dist')); | |
app.use('/api', bodyparser.json()); | |
app.use(cookieParser()); | |
app.use(session({ | |
secret: process.env.__API_SECRET__, | |
resave: true, | |
saveUninitialized: true, | |
})) | |
const PORT = process.env.PORT || 3000; | |
/** | |
* Socket configuration for client events | |
* | |
* Events: | |
* @register - should emit when a user registers a username. | |
* @logout - should emit when a new user logs out. | |
* @message - should emit a message to users when users send a message. | |
* @isOnline - should emit when a user enters the chat room. | |
* | |
*/ | |
io.on('connection', function (socket) { | |
socket.on('register', function (user) { | |
io.emit('register', user); | |
}); | |
socket.on('logout', function (user) { | |
io.emit('logout', user); | |
}); | |
socket.on('message', function (msg) { | |
io.emit('message', msg); | |
}); | |
}); | |
/** | |
* | |
* Below we are configuring our server routes for creating | |
* resources on Cosmic JS and serving our React Application | |
* | |
* Login Route that returns a user object | |
*/ | |
app.post('/api/register', async function (req, response) { | |
const { username } = req.body; | |
if (!username) { | |
response.status(400).send({ 'message': '/api/register error, no userName on request body' }); | |
return; | |
} | |
try { | |
let user = await bucket.getObjects({ type: 'users', filters: { title: username } }); | |
if (user.status !== 'empty' && user.objects.find(i => i.title === username)) { | |
response.status(400).send({ "message": "user is already logged in" }); | |
return; | |
} | |
user = await bucket.addObject({ title: username, type_slug: 'users' }); | |
req.session.user_id = user.object._id; | |
response.status(200) | |
.cookie('session_user', user.object._id) | |
.send({ _id: user.object._id, name: user.object.title, created_at: user.object.created_at }); | |
return; | |
} catch (err) { | |
response.status(400).send({ "message": 'Error registering username', "error": err }); | |
return; | |
} | |
}); | |
/** | |
* Logout route that destroys user object | |
*/ | |
app.post('/api/logout', async function (req, response) { | |
const { username } = req.body; | |
if (!username) { | |
response.status(400).send('No username'); | |
} | |
if (req.session) { | |
req.session.destroy(); | |
} | |
try { | |
let deleteUserData = await bucket.deleteObject({ | |
slug: username | |
}); | |
response.status(204).send(deleteUserData); | |
return; | |
} catch (err) { | |
response.status(400).send({ "message": "unable to remove user" }); | |
} | |
}); | |
app.post('/api/message', twilioNotificatations.notifyOnMessage, async function (req, response) { | |
const { title, content } = req.body; | |
if (!req.session.user_id) { | |
response.status(401).send({ "message": "Unauthorized, no session data present" }); | |
return; | |
} | |
try { | |
let message = await bucket.addObject({ | |
title: title, | |
type_slug: "messages", | |
content: content, | |
metafields: [ | |
{ "key": "user_id", "type": "text", "value": req.session.user_id } | |
], | |
}); | |
response.status(200).send(message); | |
} catch (err) { | |
response.status(400).send({ "message": "Error creating message", "error": err }); | |
} | |
}) | |
/** | |
* Serves our entry file for our compiled react applications | |
*/ | |
app.get(['/', '/:username'], (req, res) => { | |
if (req.cookies.session_user) { | |
req.session.user_id = req.cookies.session_user; | |
} | |
res.sendFile(path.join(__dirname, './public', 'index.html')); | |
}); | |
http.listen(PORT, () => { | |
console.log(`Cosmic Messenger listening on port : ${PORT}`); | |
}); |
You'll notice several new libraries we are importing. Most notably we are using a middleware called twilioNotifications and cosmicjs which need to be configured before our server will function properly. For twilio notifications to work, we need to create some middleware that will control when an SMS message is sent. We also need to configure this middleware with authentication tokens and keys for the twilio web server. For cosmicjs we need to do the same, grab some Auth Tokens and save them in our environment variable config file.
Configure Our Environment Variables
Let's create a file called .env at the root of our project directory. In it we'll need to store some environment variables, but also some sensitive config variables for our web services. Here's what you need:
__API_SECRET__=secret-string-for-testing | |
__API_ORIGIN__=http://localhost:3000 | |
__COSMIC_READ_KEY__=read-key-goes-here | |
__COSMIC_WRITE_KEY__=write-key-goes-here | |
__TWILIO_ACCOUNT_SID__=account-sid-goes-here | |
__TWILIO_AUTH_TOKEN__=auth-token-goes-here | |
__TWILIO_NUMBER__=your-twilio-number | |
__ADMIN_NUMBER__=phone-number-to-receive-sms |
You'll need to grab two sets of authentication credentials for our environment variables. From twilio you'll need your ACCOUNT SID and AUTH TOKEN as well as the phone number associated with your account, which will be located at https://www.twilio.com/console. From cosmicjs we need to grab our read and write keys to authenticate our requests. These can be found at https://cosmicjs.com/cosmic-messenger/settings/main. You may have to generate these from the general settings panel.
Once these are here we must update our webpack.config.js so that we can reference these variables in our client side javascript. The updated file should look something like this:
require('dotenv').config(); | |
const webpack = require('webpack'); | |
const { CleanWebpackPlugin } = require('clean-webpack-plugin'); | |
const dev = Boolean(process.env.NODE_ENV !== 'production'); | |
let client_api_url = 'http://localhost:3000/api'; | |
if (!dev) { | |
client_api_url = `${process.env.__API_ORIGIN__}/api`; | |
} | |
module.exports = { | |
entry: './src/app.js', | |
mode: dev ? 'development' : 'production', | |
module: { | |
rules: [ | |
{ | |
test: /\.(js|jsx)$/, | |
exclude: /node_modules/, | |
use: ['babel-loader'] | |
}, | |
{ | |
test: /\.(scss|css)$/, | |
use: ['style-loader', 'css-loader', 'sass-loader'] | |
}, | |
{ | |
test: /\.(png|jpg|gif|svg)$/, | |
use: [ | |
{ | |
loader: 'url-loader', | |
options: { | |
limit: 5000 | |
} | |
} | |
] | |
} | |
] | |
}, | |
resolve: { | |
extensions: ['*', '.js', '.jsx'] | |
}, | |
output: { | |
path: __dirname + '/dist', | |
publicPath: '/', | |
filename: 'bundle.js' | |
}, | |
plugins: [ | |
new webpack.HotModuleReplacementPlugin(), | |
new CleanWebpackPlugin, | |
new webpack.DefinePlugin({ | |
__API_URL__: JSON.stringify(client_api_url), | |
__API_ORIGIN__: JSON.stringify(process.env.__API_ORIGIN__), | |
__COSMIC_READ_KEY__: JSON.stringify(process.env.__COSMIC_READ_KEY__), | |
}) | |
], | |
devServer: { | |
contentBase: './dist', | |
hot: true | |
} | |
}; |
You'll notice that we added some global app variables using the DefinePlugin method of webpack. Now these variables can be used globally throughout our application thanks to Webpack's bundling.
Our SMS Notification Middleware
Create a directory called middleware and place a couple files within:
$ mkdir middleware
$ touch middleware/twilioNotifications.js middleware/twilioClient.js middleware/config.js
Our twilioClient file will handle making the request to the Twilio API:
var config = require('./config'); | |
module.exports.sendSms = function (to, message) { | |
var client = require('twilio')(config.accountSid, config.authToken); | |
return client.api.messages | |
.create({ | |
body: message, | |
to: to, | |
from: config.sendingNumber, | |
}).then(function (data) { | |
console.log('Administrator notified'); | |
}).catch(function (err) { | |
console.error('Could not notify administrator'); | |
console.error(err); | |
}); | |
}; |
Our twilioNotification file will handle the request object from express and make sure that any routes that use the module will trigger the Twilio client:
var twilioClient = require('./twilioClient'); | |
const admins = require('./config').adminNumbers; | |
function formatMessage(user, messageText) { | |
return `Message from ${user}: ${messageText}`; | |
}; | |
exports.notifyOnMessage = function (req, res, next) { | |
if (req.session) { | |
const { title, content } = req.body; | |
admins.forEach(function (admin) { | |
var messageToSend = formatMessage(title, content); | |
twilioClient.sendSms(admin.phoneNumber, messageToSend); | |
}); | |
} | |
next(); | |
}; |
Finally we are going to create a config.js to configure our middleware with the necessary configuration variables required to make our app play nicely with Twilio's API::
require('dotenv').config(); | |
var cfg = {}; | |
cfg.accountSid = process.env.__TWILIO_ACCOUNT_SID__; | |
cfg.authToken = process.env.__TWILIO_AUTH_TOKEN__; | |
cfg.sendingNumber = process.env.__TWILIO_NUMBER__; | |
cfg.adminNumbers = [{ phoneNumber: process.env.__ADMIN_NUMBER__ }]; | |
var requiredConfig = [cfg.accountSid, cfg.authToken, cfg.sendingNumber, cfg.adminNumbers]; | |
var isConfigured = requiredConfig.every(function (configValue) { | |
return configValue || false; | |
}); | |
if (!isConfigured) { | |
var errorMessage = | |
'TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_NUMBER must be set.'; | |
throw new Error(errorMessage); | |
} | |
// Export configuration object | |
module.exports = cfg; |
Now our app is all set to function as a chat server. All thats left is to create our React components and make them talk to our server to function as a chat interface.
Building Our Interface Components
Our interface will be very straight forward. We'll start by building out our app.js file and set up two routes, one for our registration form, and another for our chat input, messages, and user list. We also want to configure our graphql client so that we can fetch data directly from Cosmic JS when we are render each page.
import React from 'react'; | |
import { hydrate } from 'react-dom'; | |
import ApolloClient from 'apollo-boost'; | |
import { ApolloProvider } from 'react-apollo'; | |
import { BrowserRouter, Route } from 'react-router-dom'; | |
import { FiLogOut } from "react-icons/fi"; | |
import axios from 'axios'; | |
import { socket } from './lib/socket.js'; | |
import LoginForm from './components/loginForm/loginForm.js'; | |
import Chat from './components/chat/index.js'; | |
import logo from '../public/logo.svg'; | |
import './_app.scss'; | |
const client = new ApolloClient({ | |
uri: "https://graphql.cosmicjs.com/v1" | |
}); | |
class App extends React.Component { | |
constructor() { | |
super() | |
this.state = { | |
user: {}, | |
} | |
this.handleUser = this.handleUser.bind(this); | |
this.handleLogout = this.handleLogout.bind(this); | |
} | |
componentWillMount() { | |
if (localStorage.getItem('cosmic-messenger-user')) { | |
this.setState({ user: JSON.parse(localStorage.getItem('cosmic-messenger-user')) }); | |
} | |
} | |
componentDidUpdate(prevProps, prevState) { | |
if (this.state.user.name && this.state.user !== prevState.user) { | |
localStorage.setItem('cosmic-messenger-user', JSON.stringify(this.state.user)); | |
} | |
if (!this.state.user.name && this.state.user !== prevState.user) { | |
localStorage.removeItem('cosmic-messenger-user'); | |
} | |
} | |
render() { | |
return ( | |
<ApolloProvider client={client}> | |
<div className="app-container"> | |
{this.state.user.name | |
? <header> | |
<img src={logo} /> | |
<div> | |
<h3>{this.state.user.name} <span style={styles.appBttn} onClick={this.handleLogout}><FiLogOut /></span></h3> | |
</div> | |
</header> | |
: null | |
} | |
<div className="app-content"> | |
<BrowserRouter> | |
<Route exact path='/' | |
render={props => ( | |
<LoginForm | |
user={this.state.user} | |
handleUser={this.handleUser} | |
{...props} | |
/> | |
)} | |
/> | |
<Route path='/:user' | |
render={props => ( | |
<Chat | |
handleLogout={this.handleLogout} | |
user={this.state.user} | |
{...props} | |
/> | |
)} | |
/> | |
</BrowserRouter> | |
</div> | |
</div> | |
</ApolloProvider> | |
); | |
} | |
handleUser(user) { | |
this.setState({ user }) | |
} | |
handleLogout() { | |
axios.post(`${__API_URL__}/logout`, { username: this.state.user.name.replace(/\s+/g, '-').toLowerCase() }) | |
.then(() => this.setState({ user: {} }, () => socket.emit('logout', {}))) | |
.catch(err => console.error(err)); | |
} | |
} | |
hydrate( | |
<App />, | |
document.getElementById('app') | |
); | |
module.hot.accept(); |
Let's create a folder called components under the src directory. In here we will put all of our React components that we want to import into app.js.
Now we need to create our two components rendered within our routing logic: Chat and loginForm. We'll start with our login form at src/components/loginForm.js:
import React from 'react'; | |
import { Redirect } from 'react-router-dom'; | |
import { socket } from '../../lib/socket.js'; | |
import axios from 'axios'; | |
import Logo from '../../../public/logo.svg'; | |
class LoginForm extends React.Component { | |
constructor() { | |
super() | |
this.state = { | |
username: '', | |
requestError: {}, | |
} | |
this.handleInput = this.handleInput.bind(this) | |
this.handleRegister = this.handleRegister.bind(this) | |
} | |
render() { | |
if (!Object.keys(this.props.user).length) { | |
return ( | |
<div className="loginForm-container"> | |
<img src={Logo}/> | |
<form id="loginForm"> | |
<h5><strong>Hi!</strong> Welcome to Cosmic Messenger</h5> | |
<p>Please give us a little bit of info.</p> | |
<div> | |
{this.state.requestError.message | |
? <label>That Username is already in use</label> | |
: <label>Enter a Username</label> | |
} | |
<input | |
name="username" | |
placeholder="Some name you like..." | |
style={styles.userInput} | |
value={this.state.username} | |
onChange={this.handleInput} | |
autoFocus | |
/> | |
</div> | |
<button onClick={this.handleRegister}> | |
Start Chatting | |
</button> | |
</form> | |
<footer> | |
<a href="https://cosmicjs.com/add-bucket?import_bucket=5cf1605916e7ec14adabbb89"><img src="https://cdn.cosmicjs.com/51fe54d0-4f6e-11e9-9f32-8d001da69630-powered-by-cosmicjs.svg" /></a> | |
</footer> | |
</div> | |
) | |
} | |
return <Redirect to={`/${this.props.user.name.replace(/\s+/g, '-').toLowerCase()}`} /> | |
} | |
handleInput(e) { | |
if (this.state.requestError.message) { | |
this.setState({ requestError: {} }) | |
} | |
const { name, value } = e.target | |
this.setState({ [name]: value }) | |
} | |
handleRegister(e) { | |
e.preventDefault() | |
axios.post(`${__API_URL__}/register`, { | |
username: this.state.username, | |
}) | |
.then(res => { | |
this.props.handleUser(res.data); | |
socket.emit('register', res.data); | |
}) | |
.catch(err => this.setState({ requestError: err })); | |
} | |
} | |
export default LoginForm; |
Next we need to create the components for our chat form, for which we'll create a new directory called chat in the components directory. In here we'll create three files, one for the parent chat form component: src/components/chat/index.js:, one for the list of messages: src/components/chat/messageList.js and one for the list of users: src/components/chat/userList.js. Let's start with our chat form:
import React from 'react'; | |
import { Redirect } from 'react-router-dom'; | |
import { socket } from '../../lib/socket.js'; | |
import axios from 'axios'; | |
import { IoIosSend } from "react-icons/io"; | |
import MessageList from './messageList.js'; | |
import UserList from './userList.js'; | |
class Chat extends React.Component { | |
constructor() { | |
super(); | |
this.state = { | |
content: '', | |
selectedUsers: [], | |
} | |
this.handleInput = this.handleInput.bind(this); | |
this.onEnterPress = this.onEnterPress.bind(this); | |
this.handleMessage = this.handleMessage.bind(this); | |
this.handleUserSelect = this.handleUserSelect.bind(this); | |
} | |
componentDidMount() { | |
socket.emit('isOnline', this.props.user); | |
} | |
render() { | |
if (!Object.keys(this.props.user).length) { | |
return <Redirect to='/' /> | |
} | |
return ( | |
<div className="chat-container"> | |
<UserList | |
mobileMenuActive={this.props.mobileMenuActive} | |
handleMobileMenu={this.props.handleMobileMenu} | |
handleLogout={this.props.handleLogout} | |
user={this.props.user} | |
selectedUsers={this.state.selectedUsers} | |
/> | |
<MessageList | |
user={this.props.user} | |
selectedUsers={this.state.selectedUsers} | |
/> | |
<form | |
id="message-input" | |
onSubmit={this.handleMessage} | |
> | |
<textarea | |
className='input-area' | |
name="content" | |
value={this.state.content} | |
onChange={this.handleInput} | |
onKeyDown={this.onEnterPress} | |
placeholder="Send a message ..." | |
/> | |
<button | |
id="send-btn" | |
type="submit" | |
> | |
<IoIosSend /> | |
</button> | |
</form> | |
</div> | |
); | |
} | |
handleInput(e) { | |
const { name, value } = e.target; | |
this.setState({ [name]: value }); | |
} | |
onEnterPress(e) { | |
if (e.keyCode == 13 && e.shiftKey == false) { | |
this.handleMessage(e); | |
} | |
} | |
handleMessage(e) { | |
e.preventDefault(); | |
if (this.state.content) { | |
axios({ | |
method: 'post', | |
url: `${__API_URL__}/message`, | |
headers: { | |
withCredentials: 'true', | |
}, | |
data: { | |
title: this.props.user.name, | |
content: this.state.content, | |
}, | |
}) | |
.then(res => { | |
this.setState({ content: '' }); | |
socket.emit('message', res.data); | |
}) | |
.catch(err => this.setState({ requestError: err })); | |
} | |
} | |
handleUserSelect(user) { | |
this.setState({ | |
selectedUsers: [...this.state.selectedUsers, user] | |
}); | |
} | |
} | |
export default Chat; |
This component contains our message form that sends text data to our chat server. You'll notice it also emits an event using a module we need to build for handling web socket events. We'll get to that in a second, before that let's create our userList and messageList:
import React from 'react'; | |
import gql from 'graphql-tag'; | |
import { graphql } from 'react-apollo'; | |
import Socket from '../../lib/socket.js'; | |
const GET_USERS = gql` | |
query UserList($read_key: String!) { | |
objectsByType(bucket_slug: "cosmic-messenger", type_slug: "users", read_key: $read_key ) { | |
_id | |
title | |
created_at | |
metadata | |
} | |
} | |
` | |
class UserList extends React.Component { | |
constructor(props) { | |
super(props) | |
this.state = { | |
users: [] | |
} | |
this.handleUserIsOnline = this.handleUserIsOnline.bind(this); | |
} | |
componentDidMount() { | |
Socket.subscribeToRegister(this.props.data.refetch); | |
Socket.subscribeToLogout(this.props.data.refetch); | |
} | |
static getDerivedStateFromProps(props, state) { | |
let userFound = false; | |
const tempState = Object.assign({}, state); | |
if (props.data.objectsByType) { | |
for (const user of props.data.objectsByType) { | |
if (user._id === props.user._id) { | |
userFound = true | |
} | |
} | |
if (!userFound) { | |
props.handleLogout(); | |
} | |
tempState.users = props.data.objectsByType | |
} | |
return tempState | |
} | |
render() { | |
return ( | |
<div className={`userList-container ${this.props.mobileMenuActive}`}> | |
<div | |
className={`mobileMenuModal ${this.props.mobileMenuActive}`} | |
onClick={this.props.handleMobileMenu} | |
/> | |
{this.state.users.map(user => { | |
if (user._id !== this.props.user._id) { | |
return ( | |
<p key={user._id}>{user.title}</p> | |
) | |
} | |
return null | |
})} | |
{this.state.users.length < 2 | |
? <div>No Users in chat</div> | |
: null | |
} | |
</div> | |
) | |
} | |
handleUserIsOnline(user) { | |
let temp = Object.assign([], this.state.users); | |
for (const u of temp) { | |
if (u._id === user._id) { | |
u.isOnline = true; | |
} | |
} | |
this.setState({ users: temp }); | |
} | |
} | |
export default graphql(GET_USERS, { | |
options: { | |
variables: { | |
read_key: __COSMIC_READ_KEY__, | |
} | |
}, | |
props: ({ data }) => ({ | |
data, | |
}) | |
})(UserList); |
Our UserList simply displays our user's within our UI. It fetches those users from Cosmic JS's graphql servers and then subscribes to our socket module which refetches data every time our server emits those events.
Now let's create our MessageList:
import React from 'react'; | |
import gql from 'graphql-tag'; | |
import { graphql } from 'react-apollo'; | |
import Socket from '../../lib/socket.js'; | |
import { IoIosPulse, IoMdChatbubbles } from 'react-icons/io'; | |
const GET_MESSAGES = gql` | |
query MessageList($read_key: String!) { | |
objectsByType(bucket_slug: "cosmic-messenger", type_slug: "messages", read_key: $read_key ) { | |
_id | |
title | |
content | |
created_at | |
metadata | |
} | |
} | |
` | |
function sortArrayByDate(arr) { | |
return arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); | |
} | |
class MessageList extends React.Component { | |
constructor() { | |
super(); | |
this.state = { | |
messages: [], | |
} | |
this.scrollToBottom = this.scrollToBottom.bind(this); | |
} | |
componentDidMount() { | |
Socket.subscribeToMessages(this.props.data.refetch); | |
} | |
componentDidUpdate(prevProps) { | |
if ( | |
!this.props.data.loading | |
|| prevProps.data.objectsByType.length !== this.props.data.objectByType.length | |
) { | |
this.scrollToBottom(); | |
} | |
} | |
static getDerivedStateFromProps(props, state) { | |
const tempState = state; | |
if (props.data.objectsByType) { | |
tempState.messages = sortArrayByDate(props.data.objectsByType); | |
} | |
return tempState; | |
} | |
render() { | |
if (this.props.data.loading) { | |
return ( | |
<div className="loading-container"> | |
<IoIosPulse style={{ fontSize: '200%' }} /> | |
<p>Loading Messages</p> | |
</div> | |
) | |
} else if (!this.state.messages.length) { | |
return ( | |
<div className="messageList-container"> | |
<div> | |
<h4>Uh Oooh..</h4> | |
<IoMdChatbubbles style={{ fontSize: '400%', color: '#29ABE2' }} /> | |
<p>It looks like there are no messages here. Start chatting and others will join you :)</p> | |
</div> | |
</div> | |
) | |
} | |
function formatDate(datestring) { | |
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', "Aug", 'Sep', 'Oct', 'Nov', 'Dec']; | |
const date = new Date(datestring); | |
const month = date.getMonth(); | |
const dateNum = date.getDate(); | |
const time = date.toLocaleTimeString(); | |
return `${months[month]} ${dateNum}, ${time}`; | |
} | |
return ( | |
<div className="messageList-container"> | |
<div>End of Messages</div> | |
{this.state.messages.map(message => { | |
return ( | |
<div key={message._id}> | |
{this.props.user._id !== message.metadata.user_id | |
? <div className="message-info"> | |
<p>{message.title}</p> | |
<p>{formatDate(message.created_at)}</p> | |
</div> | |
: null | |
} | |
<span | |
className="message-container" | |
dangerouslySetInnerHTML={{ __html: message.content }} | |
style={this.props.user._id === message.metadata.user_id | |
? Object.assign({}, styles.message, styles.isUserMessage) | |
: styles.message} | |
/> | |
{this.props.user._id === message.metadata.user_id | |
? <div className="message-info"> | |
<p>You</p> | |
<p>{formatDate(message.created_at)}</p> | |
</div> | |
: null | |
} | |
</div> | |
) | |
}) | |
} | |
<div id="bottomRef" /> | |
</div > | |
) | |
} | |
scrollToBottom() { | |
const bottomRef = document.getElementById('bottomRef'); | |
if (bottomRef) { | |
bottomRef.scrollIntoView({ behavior: 'smooth' }); | |
} | |
} | |
} | |
export default graphql(GET_MESSAGES, { | |
options: { | |
variables: { | |
read_key: __COSMIC_READ_KEY__, | |
} | |
}, | |
props: ({ data }) => ({ | |
data, | |
}) | |
})(MessageList); |
Now Let's create our socket module that will let these components subscribe to our server events. Create a new folder called lib and create a file within called socket.js:
import openSocket from 'socket.io-client'; | |
export const socket = openSocket(__API_ORIGIN__); | |
const subscribeToMessages = (callback) => { | |
socket.on('message', message => callback(null, message)); | |
socket.emit('subscribeToMessages'); | |
} | |
const subscribeToRegister = (callback) => { | |
socket.on('register', user => callback(null, user)); | |
socket.emit('subscribeToRegister'); | |
} | |
const subscribeToLogout = (callback) => { | |
socket.on('logout', user => callback(null, user)); | |
socket.emit('subscribeToLogout'); | |
} | |
export default { | |
subscribeToMessages, | |
subscribeToRegister, | |
subscribeToLogout, | |
} |
With that, we now have a complete full stack chat application, equipped with a client interface rendered server side. With a bonus of notifying an admin when messages are sent over the server.
Next Steps
Since this app is built and run completely from web server, we can easily deploy this using any hosting service that supports Node JS containers. I'd recommend Heroku or Cosmic JS since they both support project structures like this and can quickly create deployments.
That's all for me this week ya'll. Until the next time.
Top comments (0)