Building a multimedia chat app using Express, Socket.IO, Redis and Docker - Part 3

header image

In the last part, we added some level of interactivity to our little chat app. In this part, we’ll add more interactivity, such as users being able to like/dislike each other’s messages, which will result in a notification being sent to the corresponding user. Also we’ll look into sending audio over the wire. We’ll implement video messages in the next part.

You can follow this tutorial with my Github repo: https://github.com/abhi12299/mm-chat

Prerequisites

Install redis from https://redis.io/topics/quickstart

Like/Dislike messages

In order to be able to like/dislike messages, let’s first add some buttons on frontend so that we can like/dislike them.
When we receive a message, we also append 2 buttons for like/dislike. Here’s the code for that:

function handleLike(msg) {
    console.log('msg liked', msg);
}

function handleDislike(msg) {
    console.log('msg disliked', msg);
}

// add 2 btns every time a message is received
function handleChat(data) {
    const msgId = Date.now() + Math.random()
    $('#messages').append(`
        <li>
            User: ${data.name}
            <br />
            Message: ${data.text}
            <br />
            <button id="like-${msgId}">Like</button>
            <button id="dislike-${msgId}">Dislike</button>
        </li>
    `);
    // register click handler for above 2 btns
    document.getElementById(`like-${msgId}`).addEventListener('click', () => handleLikeClick(data));
    document.getElementById(`dislike-${msgId}`).addEventListener('click', () => handleDislikeClick(data));
}

You’ll notice I’ve generated a random message ID above. It’s just for identifying the like/dislike buttons. There can be better ways to do so, but let’s just keep it to that.

Now when a message is liked, we need to emit an event with that message so that whoever’s message that was can receive a notification. Now here’s the challenge, our clients are identified by their name. Websocket connections are internally identified by their socket IDs, which you can access using socket.id. You can observe it if you do the following changes to the server:

io.on('connection', socket => {
    console.log(`Socket ${socket.id} connected!`);
    // rest of the code…
});

According to the emit cheat sheet for socket.io, https://socket.io/docs/emit-cheatsheet/ when we want to send a message to a specific socket, we need its socket id to do the following

io.to(`${socketId}`).emit('hey', 'I just met you');

The most obvious thing to do in this case would be to maintain an object in our nodejs server memory where the key could be the user’s display name and value can be the socket ID. However, we would have to maintain this object and handle connect/disconnect events. It won’t be that big of an issue, but this is very memory intensive and our server could end up using a lot of memory if a lot of clients are connected.

Second approach would be to store this info inside of Redis. This would be beneficial if we want to run multiple server nodes and we can easily share socket IDs and names across different servers. Imagine a figure like so

redis glue layer between node servers

If Redis is not present as the gluing layer between the 2 servers, the server #1 would never know about clients connected to server #2.
However, this also has memory inefficiency. Remember, redis is an in memory data store. Hence any data we store in redis is kept in RAM. The memory will eventually deplete if a lot of clients are connected. But we can split our data across multiple redis nodes, if we make use of redis clusters. A good intro to clustering with redis can be found at https://redis.io/presentation/Redis_Cluster.pdf

There is also a third approach, in which we can create a separate room for every user, where the room is named as the user’s name in our case (it should ideally be named as the user's ID). Then everytime we want to send a message to a user, say John, we can just emit an event to a room named John and be done with it. However, it will again lead to a lot of memory usage.

Now which one to go for? In the long term, I guess I’d go for the redis option and then set up sharded clusters with replication so that a large number of keys can be handled and even if some nodes go down, it should not cause a problem. But anyways, let’s just leave it for now and go for redis based option in its simplest form. Let's add some dependencies

yarn add bluebird redis

The redis package we added provides helper functions to interact with redis running in our local machines. Create a file redisDb.js on the root of the project

const bluebird = require('bluebird')
const redis = require('redis')

class RedisDB {
    constructor() {
        this.redis = bluebird.promisifyAll(redis);
    }

    connectDB() {
        const client = this.redis.createClient();

        client.once('error', (err) => {
            console.error('Redis connect error', err);
            process.exit(1);
        });

        client.on('ready', () => {
            console.log('Redis connected');
        });
        return client;
    }
}

module.exports = new RedisDB().connectDB();

All this config is needed because redis client functions are callback based. Bluebird package converts them to promises so that we can use async/await.
In the server.js file

const redisClient = require('./redisDb');

// ... other code
socket.on('join', async name => {
    console.log(`${name} joined!`);
    socket.name = name;
    io.emit('join', name);
    await redisClient.setAsync(`socketIdFor-${name}`, socket.id);
    // the key is prefixed by socketIdFor and then the name of the client, for clarity
});

socket.on('disconnect', async () => {
    console.log(`${socket.name} disconnected!`);
    io.emit('leave', socket.name);
    await redisClient.delAsync(`socketIdFor-${socket.name}`);
});

Make sure that redis is running at localhost:6379. You can test it by running the following command on terminal

redis-cli ping

If you get back PONG, it means that everything is fine. If not, make sure that the redis server is running in the background.

Assuming redis is running, start the app and enter a name, say jack. Now if there are no errors on the server console, open a new terminal and run

redis-cli # wait for it to connect
get socketIdFor-jack # obtain the key that we set on connect of socket

You should get back the socket id. Close the browser and try to get the socket id from redis. It should have been deleted.
Moving on, let’s implement the frontend functions for like/dislike

function handleLikeClick(msg) {
    socket.emit('like', { ...msg, from: name });
}

function handleDislikeClick(msg) {
    socket.emit('dislike', { ...msg, from: name });
}

The from property holds the doer of the action. Remember, we passed the entire message object to the click event handler before.

// where we registered other socket event listeners, register them
socket.on('like', handleLike);
socket.on('dislike', handleDislike);

For notifications regarding like/dislike, I’ll use toastr.js https://github.com/CodeSeven/toastr
In the head tag of the html file append

<script src="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css" />

The handleLike and dislike methods:

function handleLike(msg) {
    toastr.success(`${msg.from} liked your message ${msg.text}`)
}

function handleDislike(msg) {
    toastr.error(`${msg.from} disliked your message ${msg.text}`)
}

On the server,

// where other socket event handlers are
socket.on('like', async (msg) => {
    try {
        const socketId = await redisClient.getAsync(`socketIdFor-${msg.name}`)
        if (socketId) {
            io.to(socketId).emit('like', msg)
        } else {
            // user was probably disconnected or doesn’t exist
            // if disconnected, store this notif in a db
            // and show it once they log back in
        }
    } catch (error) {
            // handle the error somehow
    }
});

socket.on('dislike', async (msg) => {
    try {
        const socketId = await redisClient.getAsync(`socketIdFor-${msg.name}`)
        if (socketId) {
            io.to(socketId).emit('dislike', msg)
        } else {}
    } catch (error) {
        // handle the error somehow
    }
});

Now restart the server and check out this functionality.

like notification

Now of course, the same user can like/dislike their own message. But it can be prevented by checking if the user is the author of the message on both the client and the server. I’d leave this as an exercise for you guys.

Audio messages

I’ll be honest, cross browser support for audio recording is not very good, considering Safari and Edge do not natively support the navigator.getUserMedia API. I’m going to use the polyfill library https://github.com/ai/audio-recorder-polyfill for support on those browsers as well.

In the head tag, include the script

<script src="https://ai.github.io/audio-recorder-polyfill/polyfill.js"></script>

In the html body,

<div id="mode"></div>
<button id='record-audio'>
    Record message!
</button>
<button id='stop-record-audio' style="display: none;">
    Stop recording message!
</button>

The div is used by the polyfill. Make sure to include it. This is required because the polyfill gets the div which goes by id mode and writes stuff into it. You can of course set its display to none if it bothers you.

Register click event listeners on these buttons

$('#record-audio').on('click', handleRecordAudio);
$('#stop-record-audio').on('click', handleStopRecordAudio);

let mediaRecorder = null;
function handleRecordAudio() {
    $("#record-audio").prop("disabled", true);
    $('#stop-record-audio').css('display', 'block');
    navigator.mediaDevices.getUserMedia({ audio: true })
        .then(streamAudio)
        .catch(console.error)
}

function handleStopRecordAudio() {
    if (mediaRecorder) {
        mediaRecorder.stop();
        mediaRecorder.stream.getTracks().forEach(i => i.stop());
        mediaRecorder = null;
    }
    $('#stop-record-audio').css('display', 'none');
    $("#record-audio").prop("disabled", false);
}

// streamAudio function
function streamAudio(stream) {
    mediaRecorder = new MediaRecorder(stream);

    mediaRecorder.addEventListener("dataavailable", event => {
        const audioBlob = event.data;
        socket.emit('audio', { blob: audioBlob, type: audioBlob.type , name });
        $("#record").prop("disabled", false);
    });

    mediaRecorder.start();
}

The flow is simple. When we click on the record audio button, we obtain a media device with audio recording capability. Then we start recording using the MediaRecorder object. The dataavailable event is fired once the recording stops. Then we convert it to a blob and send it over to the server.
Note that we send the audio blob using websockets. Larger audio flies might be a problem here. To handle that, we can use this code from the polyfill

// Will be executed every second with next part of audio file
recorder.addEventListener('dataavailable', e => {
    sendNextPiece(e.data)
})
// Dump audio data every second
recorder.start(1000)

This way every 1s, we can upload a partial audio file to the server, possibly using AJAX request and combine it when done. However this is beyond the scope of this tutorial.
Audio upload can also be done using AJAX requests, like we’ve done with file uploads. On the server, let’s broadcast this audio event to other clients.

socket.on('audio', msg => {
    io.emit('audio', msg);
});

On the client,

// socket event handlers
socket.on('audio', handleAudioMessage);

// somewhere in the file,
function handleAudioMessage(msg) {
    const blob = new Blob([msg.blob], { type: msg.type }); // required for safari
    const audioUrl = URL.createObjectURL(blob);
    const audio = new Audio(audioUrl);
    const id = `audio_${msg.name}${Date.now()}`;
    $('#messages').append(`
        <li>
            Audio by:${msg.name}<br />
            <button id='play${id}'>Play</button>
            <button id='pause${id}'>Pause</button>
        </li>
    `);

    // event listener for play btn
    $(`#play${id}`).on('click', () => {
        audio.play().catch(console.error);
    });

    // event listener for pause btn
    $(`#pause${id}`).on('click', () => {
        audio.pause().catch(console.error);
    });
}

We make a blob out of the receiving audio blob, create a URL from it and keep it in a variable. We then make 2 buttons for play/pause and invoke the corresponding functions on the Audio object.

Now before running the code, make sure your computer is attached to an audio input device like a mic. It should all work nicely and you shouldn’t have any errors on the console. If you face any problems, feel free to comment below. In the next part, we’ll continue working on the app and integrate video messages and also dockerize it for easy cross-machine development.

Share :
Tags :