DEV Community

Shalvah
Shalvah

Posted on • Updated on • Originally published at blog.shalvah.me

How to Build Twitter’s Real-time Likes Feature with Node.js and Pusher

In June 2017, Twitter updated their web and Android apps with an interesting feature: real-time tweet statistics. In case you’re not familiar with Twitter, it simply means that you get to see the number of Likes or Retweets of a tweet increase (or decrease) as people around the world like or retweet it, without having to refresh the page.

In this article, I’ll walk you through implementing your own real-time post statistics (we’ll limit ourselves to Likes) in a simple Node.js app. Here’s how the app will work when done:

How-to-Build-Twitters-Real-time-Likes-Feature-with-Node.js-and-Pusher.gif

On the home page of our app, users will see all posts and they can click a button to Like or Unlike a post. Whenever a user likes or unlikes a post, the likes count displayed next to the post should increment or decrement in every other browser tab or window where the page is open.

You can check out the source code of the completed application on Github.

Setup the project

This tutorial assumes you have Node.js and MongoDB installed. We’ll be using Express, a popular lightweight Node.js framework. Let’s get our app set up quickly by using the express application generator:

    # if you don't already have it installed
    npm install express-generator -g

    # create a new express app with view engine set to Handlebars (hbs)
    express --view=hbs poster
    cd poster && npm install 
Enter fullscreen mode Exit fullscreen mode

Then we’ll add our dependencies:

npm install --save dotenv faker mongoose pusher
Enter fullscreen mode Exit fullscreen mode

Here’s a breakdown of what each module is for.

  • We’re using MongoDB for our data store, so we’ll use Mongoose to map our models (JavaScript objects) to MongoDB documents.
  • Faker will help us generate fake data for our app, since we just want to demonstrate the likes feature.
  • We need pusher to talk to Pusher’s APIs.
  • Lastly, dotenv is a small package that helps us load our private configuration variables (like our Pusher app credentials) from a .env file.

First, let’s define our data structures. We’ll limit the scope of this demo to two entities: users and posts. For users. we’ll store only their names. For posts, we’ll store:

  • the text
  • the date it was posted
  • the user who posted it (the author), and
  • the number of likes it has received

Since the only detail we need about our users is their names, we won’t set up a User model; we’ll reference the user’s name directly from our Post model. So, let’s create a file, models/post.js:

    let mongoose = require('mongoose');

    let Post = mongoose.model('Post', {
        text: String,
        posted_at: Date,
        likes_count: Number,
        author: String
    });

    module.exports = Post;
Enter fullscreen mode Exit fullscreen mode

Now, we’ll write a small script to get some fake data into our database. Create a file called seed.js in the bin directory, with the following contents:

    #!/usr/bin/env node

    let faker = require('faker');
    let Post = require('../models/post');

    // connect to MongoDB
    require('mongoose').connect('mongodb://localhost/poster');

    // remove all data from the collection first
    Post.remove({})
        .then(() => {
            let posts = [];
            for (let i = 0; i < 30; i++) {
                posts.push({
                    text: faker.lorem.sentence(),
                    posted_at: faker.date.past(),
                    likes_count: Math.round(Math.random() * 20),
                    author: faker.name.findName()
                });
            }
            return Post.create(posts);
        })
        .then(() => {
            process.exit();
        })
        .catch((e) => {
            console.log(e);
            process.exit(1);
        });
Enter fullscreen mode Exit fullscreen mode

Run the seed using node (remember to start your MongoDB server by running sudo mongod first):

    node bin/seed.js
Enter fullscreen mode Exit fullscreen mode

Let’s set up the route and view for our home page. The first thing we’ll do is add our MongoDB connection setup to our app.js, so the connection gets created when our app gets booted.

    // below this line:
    var app = express();

    // add this
    require('mongoose').connect('mongodb://localhost/poster');
Enter fullscreen mode Exit fullscreen mode

Next up, the route where we retrieve all posts from the db and pass them to the view. Replace the code in routes/index.js with this:

    let router = require('express').Router();

    let Post = require('./../models/post');

    router.get('/', (req, res, next) => {
        Post.find().exec((err, posts) => {
            res.render('index', { posts: posts });
        });

    });

    module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Lastly, the view where we render the posts. We’ll use Bootstrap for some quick styling.

    <!DOCTYPE html>
    <html>
    <head>
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"/>
    </head>

    <body>

    <div class="container-fluid text-center">

        {{#each posts }}
            <div class="jumbotron">
                <div>by
                    <b>{{ this.author.name  }}</b>
                    on
                    <small>{{ this.posted_at }}</small>
                </div>

                <div>
                    <p>{{ this.text }}</p>
                </div>

                <div class="row">
                    <button onclick="actOnPost(event);"
                            data-post-id="{{ this.id }}">Like
                    </button>
                    <span id="likes-count-{{ this.id }}">{{ this.likes_count }}</span>
                </div>
            </div>
        {{/each}}

    </div>

    </body>
    </html>
Enter fullscreen mode Exit fullscreen mode

A few notes:

  • We attach a data-post-id attribute to each Like button so we can easily identify which post it points to.
  • We give each likes_count field an id which includes the post ID, so we can directly reference the correct likes_count with just the post ID.
  • We have a click handler on the Like button (actOnPost) . This is where we’ll toggle the button text (Like → Unlike) and increment the likes_count. (And the reverse for when it’s an Unlike button). We’ll implement that in a bit.

Liking and Unliking Logic

When a user clicks on 'Like', here’s what we want to happen:

  1. The text on the button changes from "Like" to "Unlike".
  2. The likes count displayed next to the post increases by 1.
  3. An AJAX request is made to the server to increment the likes_count in the database by 1.
  4. The likes count displayed next to the post increases by 1 in all other tabs/windows where the page is open. (This is where Pusher comes in.)

For unliking:

  1. The text on the button changes from "Unlike" to "Like".
  2. The likes count displayed next to the post decreases by 1.
  3. An AJAX request is made to the server to decrement the likes_count in the database by 1.
  4. The likes count displayed next to the post decreases by 1 in all other tabs/windows where the page is open. (Once again, via Pusher.)

We’ll classify both Likes and Unlikes as actions that can be carried out on a post, so we can handle them together.

Let’s add some JavaScript to our home page for the actOnPost method. We’ll pull in Axios for easy HTTP requests.

    <!-- in index.hbs -->
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script>
        var updatePostStats = {
            Like: function (postId) {
                document.querySelector('#likes-count-' + postId).textContent++;
            },
            Unlike: function(postId) {
                document.querySelector('#likes-count-' + postId).textContent--;
            }
        };

        var toggleButtonText = {
            Like: function(button) {
                button.textContent = "Unlike";
            },
            Unlike: function(button) {
                button.textContent = "Like";
            }
        };

        var actOnPost = function (event) {
            var postId = event.target.dataset.postId;
            var action = event.target.textContent.trim();
            toggleButtonText[action](event.target);
            updatePostStats[action](postId);
            axios.post('/posts/' + postId + '/act', { action: action });
        };
    </script>
Enter fullscreen mode Exit fullscreen mode

Then we define the act route. We’ll add it in our routes/index.js:

    router.post('/posts/:id/act', (req, res, next) => {
        const action = req.body.action;
        const counter = action === 'Like' ? 1 : -1;
        Post.update({_id: req.params.id}, {$inc: {likes_count: counter}}, {}, (err, numberAffected) => {
            res.send('');
        });
    });
Enter fullscreen mode Exit fullscreen mode

Here, we change the likes_count using MongoDB’s built-in $inc operator for update operations.

Notifying Other Clients with Pusher

At this point, we’ve got our regular Liking and Unliking feature in place. Now it’s time to notify other clients when such an action happens. Let’s get our Pusher integration set up. Create a free Pusher account if you don’t have one already. Then visit your dashboard and create a new app and take note of your app’s credentials. Since we’re using the dotenv package, we can put our Pusher credentials in a .env file in the root directory of our project:

    PUSHER_APP_ID=WWWWWWWWW
    PUSHER_APP_KEY=XXXXXXXXX
    PUSHER_APP_SECRET=YYYYYYYY
    PUSHER_APP_CLUSTER=ZZZZZZZZ
Enter fullscreen mode Exit fullscreen mode

Replace the stubs above with your app credentials from your Pusher dashboard. Then add the following line to the top of your app.js:

    require('dotenv').config();
Enter fullscreen mode Exit fullscreen mode

Next we’ll modify our route handler to trigger a Pusher message whenever an action updates the likes_count in the database. We’ll initialise an instance of the Pusher client and use it to send a message by calling pusher.trigger. The trigger method takes four parameters:

  • the name of the channel to send this message on
  • the name of the message
  • the payload (any data you wish to send with the message)
  • the socket ID. If this is supplied, Pusher will send this message to every client except the client with this ID. This is useful so we can exclude the client who caused the action from being notified of it again.

Here’s what we want our payload to look like in the case of a Like action:

    {
      "action": "Like",
      "postId": 1234
    }
Enter fullscreen mode Exit fullscreen mode

So let’s add this logic to our route handler:

    let Pusher = require('pusher');
    let 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
    });

    router.post('/posts/:id/act', (req, res, next) => {
        const action = req.body.action;
        const counter = action === 'Like' ? 1 : -1;
        Post.update({_id: req.params.id}, {$inc: {likes_count: counter}}, {}, (err, numberAffected) => {
            pusher.trigger('post-events', 'postAction', { action: action, postId: req.params.id }, req.body.socketId);
            res.send('');
        });
    });
Enter fullscreen mode Exit fullscreen mode

On the client side (index.hbs) we need to handle two things:

  • subscribe each client to the post-events channel
  • .add the client’s socket ID to our act API request, so the server can use it to exclude the client

We’ll pull in the Pusher SDK

    <script src="https://js.pusher.com/4.1/pusher.min.js"></script>

    <script>
        var pusher = new Pusher('your-app-id', {
            cluster: 'your-app-cluster'
        });
        var socketId;

        // retrieve the socket ID on successful connection
        pusher.connection.bind('connected', function() {
            socketId = pusher.connection.socket_id;
        });


        var channel = pusher.subscribe('post-events');
        channel.bind('postAction', function(data) {
            // log message data to console - for debugging purposes
            console.log(data);
            var action = data.action;
            updatePostStats[action](data.postId);
        });
    </script>
Enter fullscreen mode Exit fullscreen mode

All done! Start your app by running:

    npm start
Enter fullscreen mode Exit fullscreen mode

Now, if you open up http://localhost:3000 in two (or more) tabs in your browser, you should see that liking a post in one instantly reflects in the other. Also, because of our console.log statement placed earlier, you’ll see the event is logged:

s_513389CFE89361B471702AD09462B018CE2C959E4920CE1DCDE07791B54D52D6_1507313810957_How-to-Build-Twitters-Real-time-Likes-Feature-with-Node.js-and-Pusher.png

Conclusion

In this article, we’ve seen how Pusher’s publish-subscribe messaging system makes it straightforward to implement a real-time view of activity on a particular post. Of course, this is just a starting point; we look forward to seeing all the great things you’ll build.

This article was originally published on the Pusher blog.

Top comments (1)

Collapse
 
ognjengt profile image
Ognjen Gatalo

Very good and simple explanation of Pusher. Thank you!