Build a chat app with sentiment analysis using Next.js

Introduction

Realtime applications have been around for quite a long time as we can see in contexts such as multi-player games, realtime collaboration services, instant messaging services, realtime data analytics tools, to mention a few. As a result, several technologies have been developed over the years to tackle and simplify some of the most challenging aspects of building apps that are sensitive to changes in realtime.

In this tutorial, we’ll build a very simple realtime chat application with sentiments. With sentiment analysis, we will be able to detect the mood of a person based on the words they use in their chat messages.

Prerequisites

Before you begin, ensure that you have Node and npm or Yarn installed on your machine. Here is a run-down of the core technologies we will be using.

  • Next.js - A framework for building server-side rendered(SSR) React applications with ease. It handles most of the challenges that come with building SSR React apps.

  • Pusher - Pusher is a technology for building apps with varying realtime needs like push notifications and pub/sub messaging. It is the engine behind the realtime ability of our comments widget.

  • Sentiment - Sentiment is a module that uses the AFINN-165 wordlist and Emoji Sentiment Ranking to perform sentiment analysis on arbitrary blocks of input text.

  • React - A very popular JavaScript DOM rendering framework for building scalable web applications using a component-based architecture.

A few other libraries will be used as we will see in a moment. Also ensure that you have Node installed on your machine.

Installing dependencies

Create a new directory for the application and run the following command to install the required dependencies for the app.

1# Create a new directory
2    mkdir realtime-chat-app
3    
4    # cd into the new directory
5    cd realtime-chat-app
6    
7    # Initiate a new package and install app dependencies
8    npm init -y
9    
10    npm install react react-dom next pusher pusher-js sentiment
11    npm install express body-parser cors dotenv axios

Setting environment variables

Create a new application on your Pusher Dashboard to get your application credentials. Create a .env file in the root directory of your application and add your application credentials as follows.

1PUSHER_APP_ID=YOUR_APP_ID
2    PUSHER_APP_KEY=YOUR_APP_KEY
3    PUSHER_APP_SECRET=YOUR_APP_SECRET
4    PUSHER_APP_CLUSTER=YOUR_APP_CLUSTER

Ensure that you use the same variable names as specified in the above snippet. We will make reference to them at several points in our code.

Next create a Next.js configuration file named next.config.js in the root directory of your application with the following content:

1/* next.config.js */
2    
3    const webpack = require('webpack');
4    require('dotenv').config();
5    
6    module.exports = {
7      webpack: config => {
8        const env = Object.keys(process.env).reduce((acc, curr) => {
9          acc[`process.env.${curr}`] = JSON.stringify(process.env[curr]);
10          return acc;
11        }, {});
12        
13        config.plugins.push(new webpack.DefinePlugin(env));
14        
15        return config;
16      }
17    };

Since Next.js uses Webpack in the background for module loading and bundling, we are simply configuring Webpack to be able to provide the environment variables we have defined and make them available to our React components by accessing the process.env object.

Getting started

Setting up the server

We will go ahead to set up a simple server using Next.js to wrap an Express application server. We will also load the necessary middlewares for the Express server and then we will configure Pusher using the credentials we added to our environment variables.

Create a server.js file in the root directory of your application and add the following code snippet to it to set up the server:

1/* server.js */
2    
3    const cors = require('cors');
4    const next = require('next');
5    const Pusher = require('pusher');
6    const express = require('express');
7    const bodyParser = require('body-parser');
8    const dotenv = require('dotenv').config();
9    const Sentiment = require('sentiment');
10    
11    const dev = process.env.NODE_ENV !== 'production';
12    const port = process.env.PORT || 3000;
13    
14    const app = next({ dev });
15    const handler = app.getRequestHandler();
16    const sentiment = new Sentiment();
17    
18    // Ensure that your pusher credentials are properly set in the .env file
19    // Using the specified variables
20    const pusher = new Pusher({
21      appId: process.env.PUSHER_APP_ID,
22      key: process.env.PUSHER_APP_KEY,
23      secret: process.env.PUSHER_APP_SECRET,
24      cluster: process.env.PUSHER_APP_CLUSTER,
25      encrypted: true
26    });
27    
28    app.prepare()
29      .then(() => {
30      
31        const server = express();
32        
33        server.use(cors());
34        server.use(bodyParser.json());
35        server.use(bodyParser.urlencoded({ extended: true }));
36        
37        server.get('*', (req, res) => {
38          return handler(req, res);
39        });
40        
41        server.listen(port, err => {
42          if (err) throw err;
43          console.log(`> Ready on http://localhost:${port}`);
44        });
45        
46      })
47      .catch(ex => {
48        console.error(ex.stack);
49        process.exit(1);
50      });

Modify npm scripts

Finally, we will modify the "scripts" section of the package.json file to look like the following snippet:

1/* package.json */
2    
3    "scripts": {
4      "dev": "node server.js",
5      "build": "next build",
6      "start": "NODE_ENV=production node server.js"
7    }

We have gotten all we need to start building our app components. If you run the command npm run dev on your terminal now, it will start up the application server on port 3000 if it is available. However, nothing happens on the browser yet, because we have not built any index page component. Let’s start building the app components.

Building the index page

Next.js requires that you create the page components of your app in a pages directory. We will go ahead and create a pages directory in our app root directory and create a new index.js file inside it for the index page of our application.

Before we add content to the index page, we will build a Layout component that can be used in our app pages as a kind of template. Go ahead and create a components directory in your app root. Create a new Layout.js file inside the just created components directory with the following content:

1/* components/Layout.js */
2    
3    import React, { Fragment } from 'react';
4    import Head from 'next/head';
5    
6    const Layout = props => (
7      <Fragment>
8        <Head>
9          <meta charSet="utf-8" />
10          <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
11          <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossOrigin="anonymous" />
12          <title>{props.pageTitle || 'Realtime Chat'}</title>
13        </Head>
14        {props.children}
15      </Fragment>
16    );
17    
18    export default Layout;

Here, we try not to do so much. We are simply using the next/head component to add meta information to the <head> of our pages. We have also added a link to the Bootstrap CDN file to add some default styling to our app. We are also setting the page title dynamically from props and rendering the page contents using {props.children}.

Now let's go ahead and add content to the index.js file we created earlier:

1/* pages/index.js */
2    
3    import React, { Component } from 'react';
4    import Layout from '../components/Layout';
5    
6    class IndexPage extends Component {
7    
8      state = { user: null }
9      
10      handleKeyUp = evt => {
11        if (evt.keyCode === 13) {
12          const user =  evt.target.value;
13          this.setState({ user });
14        }
15      }
16      
17      render() {
18        const { user } = this.state;
19        
20        const nameInputStyles = {
21          background: 'transparent',
22          color: '#999',
23          border: 0,
24          borderBottom: '1px solid #666',
25          borderRadius: 0,
26          fontSize: '3rem',
27          fontWeight: 500,
28          boxShadow: 'none !important'
29        };
30        
31        return (
32          <Layout pageTitle="Realtime Chat">
33          
34            <main className="container-fluid position-absolute h-100 bg-dark">
35            
36              <div className="row position-absolute w-100 h-100">
37              
38                <section className="col-md-8 d-flex flex-row flex-wrap align-items-center align-content-center px-5">
39                  <div className="px-5 mx-5">
40                  
41                    <span className="d-block w-100 h1 text-light" style={{marginTop: -50}}>
42                      {
43                        user
44                          ? (<span>
45                              <span style={{color: '#999'}}>Hello!</span> {user}
46                            </span>)
47                          : `What is your name?`
48                      }
49                    </span>
50                    
51                    { !user && <input type="text" className="form-control mt-3 px-3 py-2" onKeyUp={this.handleKeyUp} autoComplete="off" style={nameInputStyles} /> }
52                    
53                  </div>
54                </section>
55                
56                <section className="col-md-4 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-white px-0"></section>
57                
58              </div>
59              
60            </main>
61            
62          </Layout>
63        );
64      }
65      
66    }
67    
68    export default () => (
69      <IndexPage />
70    );

We created a component IndexPage for the index page of our app. We initialized the state of the component with an empty name property. The name property is meant to contain the name of the currently active user.

We also added an input field to receive the name of the user, if no user is currently active. Once the input field is filled and the enter or return key is pressed, the name supplied is stored in state.

If we test the app on our browser now, we should see a screen that looks like the following screenshot.

chat-app-next-sentiments-demo-1

Building the Chat component

We will go ahead and build the chat component. Create a new Chat.js file inside the components directory and add the following content:

1/* components/Chat.js */
2    
3    import React, { Component, Fragment } from 'react';
4    import axios from 'axios';
5    import Pusher from 'pusher-js';
6    
7    class Chat extends Component {
8    
9      state = { chats: [] }
10      
11      componentDidMount() {
12      
13        this.pusher = new Pusher(process.env.PUSHER_APP_KEY, {
14          cluster: process.env.PUSHER_APP_CLUSTER,
15          encrypted: true
16        });
17        
18        this.channel = this.pusher.subscribe('chat-room');
19        
20        this.channel.bind('new-message', ({ chat = null }) => {
21          const { chats } = this.state;
22          chat && chats.push(chat);
23          this.setState({ chats });
24        });
25        
26        this.pusher.connection.bind('connected', () => {
27          axios.post('/messages')
28            .then(response => {
29              const chats = response.data.messages;
30              this.setState({ chats });
31            });
32        });
33        
34      }
35      
36      componentWillUnmount() {
37        this.pusher.disconnect();
38      }
39      
40    }
41    
42    export default Chat;

Here is a simple break down of what we've done:

  • We first initialized the state to contain an empty chats array property. This chats property will be populated with chat messages as they keep coming. When the component mounts, we set up a Pusher connection and channel subscription inside the componentDidMount() lifecycle method.

  • You can see that we are subscribing to a Pusher channel called chat-room for our chat application. We are then binding to the new-message event on the channel, which is triggered when a new chat message comes in. Next, we simply populate the state chats property by appending the new chat.

  • Also, on the componentDidMount() method, we are binding to the connected event on the Pusher client, when it is freshly connected, to fetch all the chat messages from history by making a POST /messages HTTP request using the axios library. Afterwards, we populate the state chats property with the chat messages received in the response.

The Chat component is not completed yet. We still need to add a render() method. Let’s do that quickly. Add the following snippet to the Chat component class.

1/* components/Chat.js */
2      
3    handleKeyUp = evt => {
4      const value = evt.target.value;
5      
6      if (evt.keyCode === 13 && !evt.shiftKey) {
7        const { activeUser: user } = this.props;
8        const chat = { user, message: value, timestamp: +new Date };
9        
10        evt.target.value = '';
11        axios.post('/message', chat);
12      }
13    }
14    
15    render() {
16      return (this.props.activeUser && <Fragment>
17      
18        <div className="border-bottom border-gray w-100 d-flex align-items-center bg-white" style={{ height: 90 }}>
19          <h2 className="text-dark mb-0 mx-4 px-2">{this.props.activeUser}</h2>
20        </div>
21        
22        <div className="border-top border-gray w-100 px-4 d-flex align-items-center bg-light" style={{ minHeight: 90 }}>
23          <textarea className="form-control px-3 py-2" onKeyUp={this.handleKeyUp} placeholder="Enter a chat message" style={{ resize: 'none' }}></textarea>
24        </div>
25        
26      </Fragment> )
27    }

As seen in the render() method, we require an activeUser prop to identify the currently active user. We also rendered a <textarea> element for entering a chat message. We added an onKeyUp event handler to the <textarea> to send the chat message when you press the enter or return button.

On the handleKeyUp() event handler, we construct a chat object containing the user sending the message (currently active user), the message itself, and then the timestamp for when the message was sent. We clean up the <textarea> and then make a POST /message HTTP request, passing the chat object we created as payload.

Let's add the Chat component to our index page. First, add the following line to the import statements in the pages/index.js file.

1/* pages/index.js */
2    
3    // other import statements here ...
4    import Chat from '../components/Chat';

Next, locate the render() method of the IndexPage component. Render the Chat component in the empty <section> element. It should look like the following snippet:

1/* pages/index.js */
2    
3    <section className="col-md-4 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-white px-0">
4      { user && <Chat activeUser={user} /> }
5    </section>

You can reload the app now in your browser to see the changes.

Defining the messaging routes

For now, nothing really happens when you try to send a chat message. You don't see any message or any chat history. This is because we have not implemented the two routes we are making requests to.

We will go ahead and create the /message and /messages routes. Modify the server.js file and add the following just before the call to server.listen() inside the then() callback function.

1/* server.js */
2    
3    // server.get('*') is here ...
4    
5    const chatHistory = { messages: [] };
6    
7    server.post('/message', (req, res, next) => {
8      const { user = null, message = '', timestamp = +new Date } = req.body;
9      const sentimentScore = sentiment.analyze(message).score;
10      
11      const chat = { user, message, timestamp, sentiment: sentimentScore };
12      
13      chatHistory.messages.push(chat);
14      pusher.trigger('chat-room', 'new-message', { chat });
15    });
16    
17    server.post('/messages', (req, res, next) => {
18      res.json({ ...chatHistory, status: 'success' });
19    });
20    
21    // server.listen() is here ...

First, we created a kind of in-memory store for our chat history, to store chat messages in an array. This is useful for new clients that join the chat room to see previous messages. Whenever the Pusher client makes a POST request to the /messages endpoint on connection, it gets all the messages in the chat history in the returned response.

On the POST /message route, we are fetching the chat payload from req.body through the help of the body-parser middleware we added earlier. We then use the sentiment module to calculate the overall sentiment score of the chat message. Next, we reconstruct the chat object, adding the sentiment property containing the sentiment score.

Finally, we add the chat to the chat history messages, and then trigger a new-message event on the chat-room Pusher channel, passing the chat object in the event data. This does the real time magic.

We are just a few steps away from completing our chat application. If you load the app on your browser now and try sending a chat message, you don't see any feedback yet. That's not because our app is not working. It is working perfectly. It's simply because we are not yet rendering the chat messages on the view. Let's head on to that and finish this up.

Displaying the chat messages

Create a new ChatMessage.js file inside the components directory and add the following content to it.

1/* components/ChatMessage.js */
2    
3    import React, { Component } from 'react';
4    
5    class ChatMessage extends Component {
6    
7      render() {
8        const { position = 'left', message } = this.props;
9        const isRight = position.toLowerCase() === 'right';
10        
11        const align = isRight ? 'text-right' : 'text-left';
12        const justify = isRight ? 'justify-content-end' : 'justify-content-start';
13        
14        const messageBoxStyles = {
15          maxWidth: '70%',
16          flexGrow: 0
17        };
18        
19        const messageStyles = {
20          fontWeight: 500,
21          lineHeight: 1.4,
22          whiteSpace: 'pre-wrap'
23        };
24        
25        return <div className={`w-100 my-1 d-flex ${justify}`}>
26          <div className="bg-light rounded border border-gray p-2" style={messageBoxStyles}>
27            <span className={`d-block text-secondary ${align}`} style={messageStyles}>
28              {message}
29            </span>
30          </div>
31        </div>
32      }
33      
34    }
35    
36    export default ChatMessage;

The ChatMessage component is a very simple component requiring two props: message for the chat message and position for the positioning of the message - either right or left. This is useful for positioning the messages of the active user on one side and then the messages of other users on the other side as we would do in a moment.

Finally, we will modify the components/Chat.js file to render the chat messages from the state. Make the following changes to the Chat component.

First add the following constants before the class definition of the Chat component. Each constant is an array of the code points required for a particular sentiment emoji. Also ensure to import the ChatMessage component.

1/* components/Chat.js */
2    
3    // Module imports here ...
4    import ChatMessage from './ChatMessage';
5    
6    const SAD_EMOJI = [55357, 56864];
7    const HAPPY_EMOJI = [55357, 56832];
8    const NEUTRAL_EMOJI = [55357, 56848];
9    
10    // Chat component class here ...

Then, add the following snippet between the chat header <div> and the chat message box <div> we created earlier in the Chat component.

1/* components/Chat.js */
2    
3    {/** CHAT HEADER HERE **/}
4    
5    <div className="px-4 pb-4 w-100 d-flex flex-row flex-wrap align-items-start align-content-start position-relative" style={{ height: 'calc(100% - 180px)', overflowY: 'scroll' }}>
6    
7      {this.state.chats.map((chat, index) => {
8      
9        const previous = Math.max(0, index - 1);
10        const previousChat = this.state.chats[previous];
11        const position = chat.user === this.props.activeUser ? "right" : "left";
12        
13        const isFirst = previous === index;
14        const inSequence = chat.user === previousChat.user;
15        const hasDelay = Math.ceil((chat.timestamp - previousChat.timestamp) / (1000 * 60)) > 1;
16        
17        const mood = chat.sentiment > 0 ? HAPPY_EMOJI : (chat.sentiment === 0 ? NEUTRAL_EMOJI : SAD_EMOJI);
18        
19        return (
20          <Fragment key={index}>
21          
22            { (isFirst || !inSequence || hasDelay) && (
23              <div className={`d-block w-100 font-weight-bold text-dark mt-4 pb-1 px-1 text-${position}`} style={{ fontSize: '0.9rem' }}>
24                <span className="d-block" style={{ fontSize: '1.6rem' }}>
25                  {String.fromCodePoint(...mood)}
26                </span>
27                <span>{chat.user || 'Anonymous'}</span>
28              </div>
29            ) }
30            
31            <ChatMessage message={chat.message} position={position} />
32            
33          </Fragment>
34        );
35        
36      })}
37      
38    </div>
39    
40    {/** CHAT MESSAGE BOX HERE **/}

Let's try to understand what this code snippet is doing. First, we are going through each chat object in the state chats array property. We check if the sender of the message is the same as the currently active user and use that to determine the position of the displayed chat message. As you can see, the active user's messages will appear on the right.

We also use the sentiment score in the chat object to set the mood of the user while typing the message to either happy, sad or neutral using the earlier defined constants.

We conditionally render the name of the user before the chat message based on one of the following conditions being met.

  • isFirst - the current chat message is the first in the list

  • !inSequence - the current chat message directly follows a message from another user

  • hasDelay - the current chat message has a delay of over 1 minute from the previous message of the same user

Also notice how we are using the [String.fromCodePoint()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/fromCodePoint) method added in ES6 to get the emoji from the code points we defined in our constants earlier.

We are finally done with our chat app. You can go ahead to test what you have built on your browser. Here are some screenshots showing a chat between 9lad, Steve and Bob.

9lad

chat-app-next-sentiments-demo-2

Steve

chat-app-next-sentiments-demo-3

Bob

chat-app-next-sentiments-demo-4

Conclusion

In this tutorial, we have been able to build a very simple chat application with chat sentiment using Next.js(React), Pusher and Sentiment Node module. While this tutorial focuses on just the basics, there are a lot of advanced stuffs you can do to make a better chat app. You can check the source code of this tutorial on GitHub.

Do check the documentation for each technology we used in this project to learn more about other ways of using them. I hope that this tutorial is of help to you.