Build a comments widget with sentiment analysis using Next.js

  • Christian Nwamba
May 5th, 2018
You will need Node and npm or Yarn installed on your machine. Some knowledge of JavaScript will be helpful when following this tutorial.

With the advent of the social media and other platforms that aim at connecting people, commenting has become an integral part of online activity. As a result, it is very common to see comment sections in most of the services we tend to use daily. Virtually every service that supports commenting functionality builds it to handle comments in realtime.

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

Here is a screenshot of what we will end up building in this tutorial.

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.

  1. 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.
  1. 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.
  1. 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.
  1. 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.

    # Create a new directory
    mkdir realtime-comments-app

    # cd into the new directory
    cd realtime-comments-app

    # Initiate a new package and install app dependencies
    npm init -y

    npm install react react-dom next pusher pusher-js sentiment
    npm install express body-parser cors dotenv axios
    npm install --save-dev cross-env npm-run-all

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.

    PUSHER_APP_ID=YOUR_APP_ID
    PUSHER_APP_KEY=YOUR_APP_KEY
    PUSHER_APP_SECRET=YOUR_APP_SECRET
    PUSHER_APP_CLUSTER=YOUR_APP_CLUSTER

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

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

    /* next.config.js */

    const webpack = require('webpack');
    require('dotenv').config();

    module.exports = {
      webpack: config => {
        const env = Object.keys(process.env).reduce((acc, curr) => {
          acc[`process.env.${curr}`] = JSON.stringify(process.env[curr]);
          return acc;
        }, {});

        config.plugins.push(new webpack.DefinePlugin(env));

        return config;
      }
    };

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 and setup 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:

    /* server.js */

    const cors = require('cors');
    const next = require('next');
    const Pusher = require('pusher');
    const express = require('express');
    const bodyParser = require('body-parser');
    const dotenv = require('dotenv').config();
    const Sentiment = require('sentiment');

    const dev = process.env.NODE_ENV !== 'production';
    const port = process.env.PORT || 3000;

    const app = next({ dev });
    const handler = app.getRequestHandler();
    const sentiment = new Sentiment();

    // Ensure that your pusher credentials are properly set in the .env file
    // Using the specified variables
    const pusher = new Pusher({
      appId: process.env.PUSHER_APP_ID,
      key: process.env.PUSHER_APP_KEY,
      secret: process.env.PUSHER_APP_SECRET,
      cluster: process.env.PUSHER_APP_CLUSTER,
      encrypted: true
    });

    app.prepare()
      .then(() => {

        const server = express();

        server.use(cors());
        server.use(bodyParser.json());
        server.use(bodyParser.urlencoded({ extended: true }));

        server.get('*', (req, res) => {
          return handler(req, res);
        });

        server.listen(port, err => {
          if (err) throw err;
          console.log(`> Ready on http://localhost:${port}`);
        });

      })
      .catch(ex => {
        console.error(ex.stack);
        process.exit(1);
      });

Modify npm scripts

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

    /* package.json */

    "scripts": {
      "dev": "node server.js",
      "build": "next build",
      "prod:server": "cross-env NODE_ENV=production node server.js",
      "start": "npm-run-all -s build prod:server"
    }

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.

It is considered a good practice to have a layout that can be reused across multiple pages. It gives you a form of boilerplate and saves you from unnecessary repetitions.

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

    /* components/Layout.js */

    import React, { Fragment } from 'react';
    import Head from 'next/head';

    const Layout = props => (
      <Fragment>
        <Head>
          <meta charSet="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
          <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossOrigin="anonymous" />
          <title>{props.pageTitle || 'Realtime Comments'}</title>
        </Head>
        {props.children}
      </Fragment>
    );

    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 pages/index.js file we created earlier:

    /* pages/index.js */

    import React, { Component } from 'react';
    import axios from 'axios';
    import Layout from '../components/Layout';

    class IndexPage extends Component {

      state = { post: null }

      componentDidMount() {
        axios.get('https://baconipsum.com/api/?type=meat-and-filler&paras=4&format=text')
          .then(response => this.setState({ post: response.data }));
      }

      render() {
        return (
          <Layout pageTitle="Realtime Comments">
            <main className="container-fluid position-absolute h-100 bg-white">
              <div className="row position-absolute w-100 h-100">

                <section className="col-md-8 d-flex flex-row flex-wrap align-items-center align-content-center border-right border-gray px-0">

                  { this.state.post && <div className="position-relative h-100">

                    <div className="px-5 mt-5 pt-5 mx-5">
                      <span className="d-block px-5 mx-5 pt-5 h5 text-uppercase text-primary font-weight-bold mb-3">Editor's Pick</span>
                      <span className="d-block px-5 mx-5 pb-5 h1 text-dark border-bottom border-gray">Getting Started with Lorem Ipsum</span>
                    </div>

                    <div className="d-block h-50 px-5 mt-5 pt-3 mx-5 position-relative" style={{ overflowY: 'auto' }}>
                      <span className="d-block px-5 mx-5 text-secondary text-justify" style={{ fontSize: '1rem', whiteSpace: 'pre-line' }}>{ this.state.post }</span>
                    </div>

                  </div> }

                </section>

                <section className="col-md-4 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-light px-0"></section>

              </div>
            </main>
          </Layout>
        );
      }
    };

    export default () => (
      <IndexPage />
    );

We created a component IndexPage for the index page of our app. We initialized the state of the component with an empty post property. The post property will contain the post body that will be displayed.

As you can see in the componentDidMount() method, we are fetching a random lorem-ipsum-style text by calling the BaconIpsum JSON API ****and setting the state post property to the returned text.

Finally, we simply render the post text when it has been set on the state. If we test the app in our browser now, with npm run dev, we should see a screen that looks like the following screenshot.

Building the CommentsWidget component

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

    /* components/CommentsWidget.js */

    import React, { Component, Fragment } from 'react';
    import axios from 'axios';
    import Pusher from 'pusher-js';

    class CommentsWidget extends Component {

      state = { comments: [], person: null }

      componentDidMount() {

        this.pusher = new Pusher(process.env.PUSHER_APP_KEY, {
          cluster: process.env.PUSHER_APP_CLUSTER,
          encrypted: true
        });

        this.channel = this.pusher.subscribe('post-comments');

        this.channel.bind('new-comment', ({ comment = null }) => {
          const { comments } = this.state;
          comment && comments.push(comment);
          this.setState({ comments });
        });

        this.pusher.connection.bind('connected', () => {
          axios.post('/comments')
            .then(response => {
              const comments = response.data.comments;
              this.setState({ comments });
            });
        });

      }

      componentWillUnmount() {
        this.pusher.disconnect();
      }

      render() {
        return <Fragment>

          <div className="border-bottom border-gray w-100 px-2 d-flex align-items-center bg-white justify-content-between" style={{ height: 90 }}>
            <h2 className="text-dark mb-0 mx-4">Comments</h2>
            <span class="badge badge-pill badge-primary mx-4" style={{ fontSize: '1.2rem' }}>{ this.state.comments.length }</span>
          </div>

        </Fragment>
      }
    };

    export default CommentsWidget;

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

  1. We first initialized the state to contain an empty comments array property and a null person property. The comments property will be populated with people’s comments as they come in. The person property will store the name of the person about to make a comment. When the component mounts, we set up a Pusher connection and channel subscription inside the componentDidMount() lifecycle method.
  1. You can see that we are subscribing to a Pusher channel called post-comments. We are then binding to the new-comment event on the channel, which is triggered when a new comment comes in. Next, we simply populate the state comments property by appending the new comment.
  1. 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 previous comments from history by making a POST /comments HTTP request using the axios library. Afterwards, we populate the state comments property with the comments received in the response.

To keep things simple, we will use a predefined list of names that a person can choose from before making a comment. Each time the CommentsWidget component is rendered, we will show four random personas that the user can choose from.

Go ahead and make changes to the render() method as shown in the following snippet. Ensure that you declare the constants before the return statement.

    /* components/CommentsWidget.js */

    render() {

      const people = [ 'Stephanie', 'John', 'Steve', 'Anna', 'Margaret', 'Felix', 'Chris', 'Jamie', 'Rose', 'Bob', 'Vanessa', '9lad', 'Bridget', 'Sebastian', 'Richard' ];

      const nameBadgeStyles = {
        fontSize: '0.8rem',
        height: 40,
        borderRadius: 20,
        cursor: 'pointer'
      };

      const choosePersona = person => evt => this.setState({ person });

      const randomPeople = count => {
        const selected = [];
        let i = 0;

        count = Math.max(0, Math.min(count, people.length));

        while (i < count) {
          const index = Math.floor(Math.random() * people.length);
          if (selected.includes(index)) continue;
          ++i && selected.push(index);
        }

        return selected.map(index => {
          const person = people[index];
          const className = 'd-block d-flex align-items-center text-center text-white bg-secondary font-weight-bold py-2 px-4 mr-3';

          return <span key={index} className={className} style={nameBadgeStyles} title={person} onClick={choosePersona(person)}>{person}</span>
        });
      };

      // return statement is here ....
    }

As you can see, we created a list of 15 personas that we will be choosing from. The randomPeople() function takes a count as its only argument - which is the number of random personas to pick from the initial list. It then uses .map() to create an array of <span> elements for each picked persona and returns the array.

Notice the onClick event handler for each <span>. The choosePersona(person) function is used as the handler. It simply sets the person property in the state to the name of the chosen persona.

Next, we will go ahead and render the random personas. Make the following changes to the render() method. The return statement should look like the following code snippet:

    /* components/CommentsWidget.js*/

    return <Fragment>

      <div className="border-bottom border-gray w-100 px-2 d-flex align-items-center bg-white justify-content-between" style={{ height: 90 }}>
        <h2 className="text-dark mb-0 mx-4">Comments</h2>
        <span class="badge badge-pill badge-primary mx-4" style={{ fontSize: '1.2rem' }}>{ this.state.comments.length }</span>
      </div>

      <div className="border-top border-gray w-100 px-4 d-flex flex-wrap align-items-center align-content-center bg-light" style={{ height: 160 }}>

        {
          !this.state.person &&
          <span className="text-dark py-2" style={{ fontSize: '1.5rem', fontWeight: 500 }}>Choose your Persona</span>
        }

        <div className="w-100 py-2 pb-3 d-flex justify-content-start">
          {
            this.state.person
              ? <span className="d-block d-flex align-items-center text-center text-white bg-primary font-weight-bold py-2 px-4 mr-3" style={nameBadgeStyles} title={this.state.person}>{this.state.person}</span>
              : randomPeople(4)
          }
        </div>

        {
          this.state.person &&
          <textarea className="form-control px-3 py-2" onKeyUp={this.handleKeyUp} placeholder="Make a comment" style={{ resize: 'none' }}></textarea>
        }

      </div>

    </Fragment>

As you can see, we are doing some conditional rendering here based on this.state.person. When this.state.person is null, we render four random personas for the user to choose from by calling randomPeople(4).

When the user chooses a persona, we render a <textarea> element for the user to enter a comment. We added an onKeyUp event handler to the <textarea> to add the comment when you press the enter or return button.

Let’s add the handleKeyUp() event handler to the CommentsWidget component as shown in the following snippet:

    /* components/CommentsWidget.js*/

    class CommentsWidget extends Component {

      handleKeyUp = evt => {
        const value = evt.target.value;

        if (evt.keyCode === 13 && !evt.shiftKey) {
          const { person } = this.state;
          const comment = { person, comment: value, timestamp: +new Date };

          evt.target.value = '';
          this.setState({ person: null }, () => axios.post('/comment', comment));
        }
      }

    }

On the handleKeyUp() event handler, we construct a comment object containing the person making the comment (the selected persona), the comment itself, and then the timestamp for when the comment was made. Finally, we clean up the <textarea>, reset the person property to null and then make a POST /comment HTTP request, passing the comment object we created as payload.

Let's add the CommentsWidget component to our index page. Modify the pages/index.js file with the following changes.

    /* pages/index.js */

      import React, { Component } from 'react';
      import axios from 'axios';
      import Layout from '../components/Layout';
    + import CommentsWidget from '../components/CommentsWidget';

Next, update the `pages/index.js` file to render the `CommentsWidget` inside the `render()` method of the `IndexPage` component. Locate the last `<section>` element inside the `render()` method and modify it to look like this:


    /* pages/index.js */

    <section className="col-md-4 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-light px-0">
      { this.state.post && <CommentsWidget /> }
    </section>

You can reload the app now in your browser to see the changes. The app should look like the following screenshot.

Defining the comment routes

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

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

    /* server.js */

    // server.get('*') is here ...

    const commentsHistory = { comments: [] };

    server.post('/comment', (req, res, next) => {
      const { person = null, comment = '', timestamp = (+new Date) } = req.body;
      const sentimentScore = sentiment.analyze(comment).score;  
      const commentObject = { person, comment, timestamp, sentiment: sentimentScore };

      commentsHistory.comments.push(commentObject);
      pusher.trigger('post-comments', 'new-comment', { comment: commentObject });
    });

    server.post('/comments', (req, res, next) => {
      res.json({ ...commentsHistory, status: 'success' });
    });

    // server.listen() is here ...

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

On the POST /comment route, we are fetching the comment 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 comment. Next, we reconstruct the commentObject, adding the sentiment property containing the sentiment score.

Finally, we add the comment to the commentsHistory, and then trigger a new-comment event on the post-comments Pusher channel, passing the commentObject in the event data. This does the realtime magic.

We are just a few steps away from completing our application. So far, we are not yet rendering the comments on the view. Let's head on to that and finish this up.

Displaying the comments

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

    /* components/Comment.js */

    import React, { Component } from 'react';

    class Comment extends Component {
      render() {
        const { text } = this.props;

        const commentStyle = {
          fontSize: '0.9rem',
          fontWeight: 500,
          lineHeight: 1.5,
          whiteSpace: 'pre-wrap'
        };

        return <div className="w-100 mx-4 d-flex">
          <div className="w-100 px-2">
            <span className="d-block text-secondary" style={commentStyle}>{text}</span>
          </div>
        </div>
      }
    }

    export default Comment;

The Comment component is a very simple component requiring a text prop for the comment text. We simply render the comment text in the render() method and that’s all.

Finally, we will modify the components/CommentsWidget.js file to render the comments from the state. Make the following changes to the CommentsWidget component.

First add the following constants before the class definition of the CommentsWidget component. Each constant is an array of the code points required for a particular sentiment emoji. Also ensure that you import the Comment component.

    /* components/CommentsWidget.js */

    // Module imports here ...
    import Comment from './Comment';

    const SAD_EMOJI = [55357, 56864];
    const HAPPY_EMOJI = [55357, 56832];
    const NEUTRAL_EMOJI = [55357, 56848];

    // Chat component class here ...

Then, add the following snippet between the two child <div> tags of the <Fragment> we created earlier in the render() method of the component.

    /* components/CommentsWidget.js */

    {/** COMMENT HEADER <DIV> HERE **/}

    <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% - 250px)', overflowY: 'scroll' }}>
      {
        this.state.comments.map((comment, index) => {

          const mood = comment.sentiment > 0 ? HAPPY_EMOJI : (comment.sentiment === 0 ? NEUTRAL_EMOJI : SAD_EMOJI);

          return (
            <Fragment key={index}>

              <div className={`d-flex justify-content-start align-items-center w-100 font-weight-bold text-dark mt-4 pb-1 px-1`} style={{ fontSize: '0.9rem' }}>
                <span className="d-inline-block pr-1" style={{ fontSize: '1.25rem' }}>{String.fromCodePoint(...mood)}</span>
                <span className="align-middle" style={{ lineHeight: '1.25rem' }}>{comment.person || 'Anonymous'}</span>
              </div>

              <Comment text={comment.comment} />

            </Fragment>
          );

        })
      }
    </div>

    {/** COMMENT BOX <DIV> HERE **/}

Let's try to understand what this code snippet is doing. First, we are going through each comment object in the state comments array property. We use the sentiment score of the comment object to set the mood of the person making the comment to either happy, sad or neutral using the earlier defined constants.

Also notice how we are using the String.fromCodePoint() method added in ES6 to get the emoji from the code points we defined in our constants earlier. Finally, we render the Comment component with the comment passed in the text prop.

Bravo. If you made it to this point, then you have successfully built a realtime comments widget with sentiment using Next.js and Pusher. Test the app now in your browser to see that everything works.

Conclusion

In this tutorial, we have been able to build a very simple comments widget sentiment using Next.js(React), Pusher and Sentiment Node module. You can check the demo 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 duly hope that this tutorial is of help to you.

  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.