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

header image

In the last part, we built a simple websocket server using socket.io and we were able to send messages to other users. We also provided join/leave events to the connected clients.
In this part, we will take it a step further and add some quick features, such as providing typing indicators and uploading files. So let’s dive into the code.

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

We’ll add some typing indicators to our little app so that the user knows who is typing and when they stopped to type. Here’s what we’ll be doing:

  1. When a user focuses on input box, we’ll emit an event of type start-type with the name of the user.
  2. Server broadcasts this event to other clients, which update their UI. These clients can store the currently typing users in an object.
  3. When the input box loses focus, say when the user changes tabs or clicks out of the input box, emit an event of type stop-type.
  4. Server broadcasts it and other clients react to this by updating their UI and removing the corresponding entry in the object.

The flow is pretty simple. Let’s implement it.

<!-- used for typing indicators -->
<p id="indicator"></p>
<ul id='messages'></ul>

I’ve registered some event listeners on the input box in the index.html file like so:

$('#inp').on('input', emitStartType);
$('#inp').on('blur', emitStopType);

function emitStartType() {
   socket.emit('start-type', name);
}

function emitStopType() {
   socket.emit('stop-type', name);
}

In the same file, where we registered connect, join and other event listeners, register these 2 events there like so:

socket.on('start-type', handleStartType);
socket.on('stop-type', handleStopType);

Here are the 2 functions that change the UI

function handleStartType(name) {
   $('#indicator').text(`${name} typing...`)
}

function handleStopType(name) {
   $('#indicator').text('')
}

Right now, this is all we need. We’ll improve upon this as we go. Let’s now make sure that the server can broadcast the events to other clients. In the server.js file, just add these lines

// in the io.on(‘connection', ...
socket.on('start-type', name => {
   console.log(`${name} started to type`);
   io.emit('start-type', name);
});

socket.on('stop-type', name => {
   console.log(`${name} stopped typing`);
   io.emit('stop-type', name);
});

Now start the server and you should see it working. However, there are some problems with this.

  1. The start-type event is emitted every time the user types in the input box. You can see that in the output for the server. That’s inefficient.
  2. Even when the user isn’t typing, the indicators won’t reflect it unless the user clicks out of the input box.
  3. If multiple people start typing together, the indicator will be a mess.
  4. We don’t want to show the current user his own typing indicator.

The solution for the last point is pretty simple. From the emit cheat sheet of socket.io, the function socket.broadcast.emit broadcasts a message to all connected clients except the sender. In the server.js file, do the following changes:

socket.on('start-type', name => {
   console.log(`${name} started to type`);
   socket.broadcast.emit('start-type', name);
});

socket.on('stop-type', name => {
   console.log(`${name} stopped typing`);
   socket.broadcast.emit('stop-type', name);
});

Restart the server and try again. This time, open 2 windows and see it in action.

Now in order to handle multiple users typing at once, we can make a Set and add currently typing users’ names to the set. Note that a set can’t have duplicate entries and in our app, names can be duplicated. But in a real world scenario, you’d often have a unique identifier for every user. Whenever a user starts typing, we can add the user’s name to the set, convert it to an array and have names for each user be displayed in the typing indicators, or in whatever way we want. After a user stops typing or gets disconnected, we can remove that name from the set.
Here’s how I’d do it:

let typingUsers = new Set()

function handleStartType(name) {
   typingUsers.add(name);
   let displayString = '';
   for (const user of Array.from(typingUsers)) {
      displayString += `${user}, `;
   }
   displayString = displayString.substr(0, displayString.length - 2) // to remove trailing comma
   displayString += ' typing...'
   $('#indicator').text(displayString);
}

function handleStopType(name) {
   typingUsers.delete(name)
   let displayString = ''
   for (const user of Array.from(typingUsers)) {
      displayString += `${user}, `;
   }
   displayString = displayString.substr(0, displayString.length - 2)
   displayString += displayString.length > 0 ? ' typing...' : '' // when string is empty, don’t add anything
   $('#indicator').text(displayString)
}

// to handle disconnecting users to stop typing
function handleLeaveUser(name) {
   handleStopType(name);
   $('#messages').append(`
      <li>User left: ${name}</li>
   `);
}

It should now handle multiple users typing properly.

multiple typing users

Let’s now fix the problem where typing indicators reflect stop-type when the user is focused on the input box but isn’t actually typing.
Take a look at the figure below, this is how we’ll do it:

typing indicator flow

With this, when the user is inactive, i.e. not typing for more than 1s on the input box, we will send a stop-type event to the server.
Here’s the simple implementation for emitStartType function that does this:

let typingTimeout;
function emitStartType() {
   clearTimeout(typingTimeout);
   typingTimeout = setTimeout(emitStopType, 1000);
   socket.emit('start-type', name);
}

Restart the server and try this again. This time, with 2 windows open, start typing and stop in between for more than 1s in order to see it in action.

Addressing the issue of multiple start-type event emits, this can be done easily with a boolean variable, which can be toggled to true every time we send a start-type event and back to false when stop-type is emitted. With this, we can check if that variable holds a truthy value, we shouldn’t be emitting the event again. In order to do this, declare a global variable like so

let typingTimeout = null;
let isStartTypeSent = false;

function emitStartType() {
   clearTimeout(typingTimeout);
   typingTimeout = setTimeout(emitStopType, 1000);
   if (isStartTypeSent) return;
   isStartTypeSent = true;
   socket.emit('start-type', name);
}

function emitStopType() {
   isStartTypeSent = false;
   socket.emit('stop-type', name);
}

Now we only send the start-type event when isStartType is false, which happens when the user stops typing.
Additionally, we can make use of socket.io acknowledgements. With normal events, we just send some data with it and we expect the server to receive it. But we never know if the server actually received it. With acknowledgements, we can also pass a function with an event and the server can run this function sent by the client, which will eventually make this function run on the client as well. In that function, we can be sure that the server has received our data and we can update the UI accordingly. You can read more about it here. https://socket.io/docs/#Sending-and-getting-data-acknowledgements

In our little app, we can use these acknowledgements to toggle isStartType variable and be always sure that the event has been sent/received. Here are the modifications:

function emitStartType() {
   clearTimeout(typingTimeout);
   typingTimeout = setTimeout(emitStopType, 1000);
   if (isStartTypeSent) return;
   socket.emit('start-type', name, () => {
      isStartTypeSent = true;
   });
}

function emitStopType() {
   socket.emit('stop-type', name, () => {
      isStartTypeSent = false;
   });
}

Here we passed another function as the third argument to socket.emit. That is the acknowledgement function. That’s not it though. Remember, the server also has to manually invoke this acknowledgement function so that the client can run it as well. Here’s the server side code:

socket.on('start-type', (name, cb) => {
   console.log(`${name} started to type`);
   socket.broadcast.emit('start-type', name);
   cb();
});

socket.on('stop-type', (name, cb) => {
   console.log(`${name} stopped typing`);
   socket.broadcast.emit('stop-type', name);
   cb();
});

Note that we accept a second argument named cb that we call at the end. That’s the ack function from the client.

Moving on to file uploads, we’ll handle it using busboy. We can also use multer, but I think it will be an overkill for this.
Let’s again start with the client. Here’s a simple form with a submit event handler that we’ll use

<form id="form">
   <input type="file" id="f" />
   <button type="submit">Upload File and Send</button>
   <!-- for upload progress -->
   <div id="upload-progress"></div> 
</form>
$('#form').on('submit', handleFormSubmit);

function handleFormSubmit(e) {
   e.preventDefault();
   const form = $(this);
   const formData = new FormData(form[0]);
   for (const p of formData) {
      // formData is an iterable which stores an array of arrays
      // if the form only has a file field, p variable has [“file-name”, ActualFile]
      if (p[1].size <= 0) {
         // no form data available!
         return;
      }
   }
   $.ajax({
      method: 'POST',
      data: formData,
      cache: false,
      contentType: false,
      processData: false,
      url: '/upload',
      success: handleUploadSuccess,
      xhr: handleUploadProgress
   })
}

I’m using jquery to handle upload so that I can show the upload progress.
Let’s implement an api endpoint on /upload to handle incoming upload requests.
Here’s the server.js file code

const Busboy = require('busboy');
const path = require('path');
const fs = require('fs');

// this is important!
app.use(express.static('uploads'));

app.post('/upload', function(req, res) {
   const busboy = new Busboy({ headers: req.headers });
   req.pipe(busboy);
   busboy.on('file', (fieldname, file, filename) => {
      const ext = path.extname(filename);
      const newFilename = `${Date.now()}${ext}`;
      req.newFilename = newFilename;
      req.originalFilename = filename;
      const saveTo = path.join('uploads', newFilename);
      file.pipe(fs.createWriteStream(saveTo));
   });
   busboy.on('finish', () => {
      res.json({
         originalFilename: req.originalFilename,
         newFilename: req.newFilename
      });
   });
});

Let’s go through it step by step.

  1. We set up a static directory with express js, named uploads. This is where we’ll keep our uploaded files and whenever a request comes for a file, say 1.png like so: http://localhost:3000/1.png, we can serve this 1.png file from the uploads directory.
  2. We create a new busboy instance and pass it the request headers. Then we pipe the request to it so that it can handle incoming files.
  3. When it captures a file, we change its name to the current timestamp of the system and keep the file extension using path.extname. But note that we are storing the original file name on the request object. This will be useful later.
  4. The file.pipe method pipes the contents of the file to the writeable location specified by the constant saveTo. Note that we’re saving incoming files in a folder named uploads at the root of the project. Make sure you have this folder before starting the server.
  5. When busboy finishes the file upload, we just send a response to the client with the original file name and the computed one.

Moving back to the frontend, let’s work on the handleUploadProgress function first.

function handleUploadProgress() {
   const xhr = new window.XMLHttpRequest();
   xhr.upload.addEventListener('progress', e => {
      const percent = (event.loaded / event.total) * 100;
      const progress = Math.round(percent);
      $('#upload-progress').html(`${progress}% uploaded`);
   }, false);
   xhr.addEventListener('error', e => {
      $('#upload-progress').html('Upload errored!');
      console.error(e);
   }, false);
   xhr.addEventListener('abort', e => {
      $('#upload-progress').html('Upload aborted');
         console.error(e);
       }, false);
   return xhr;
}

Here, we simply create an XHR request and attach a progress event listener. We then calculate the progress and set the progress text. The error handlers can be a bit more sophisticated, but it will do for now.
Now comes the main part, the handleUploadSuccess event handler

function handleUploadSuccess(resp) {
   socket.emit('file’, { name, file: { url: `/${resp.newFilename}`, filename: resp.originalFilename } });
   $('#form')[0].reset();
   $('#upload_perc').text('');
}

Here we receive the response from the server in the variable resp. We emit an event of type file with the name and file key as data. The file key has the original url and original filename keys. Then we clear the form and the progress indicator.
Now let’s have the frontend respond to the event of type file

// inside $(document).ready() just below other listeners,
socket.on('file', handleFile);

// in the same file
function handleFile(f) {
   $('#messages').append(`
      <li>
         User: ${f.name}
         <br />
         File: <a target='_blank' href='${f.file.url}' download='${f.file.filename}'>${f.file.filename}</a>
      </li>
   `);
}

Notice the anchor tag has a download attribute set to the original file name of the uploaded file. This will make sure that the file when downloaded has that very name. We also need the server to respond to the event of type file. Let’s make it broadcast this event to all clients.

socket.on('file', f => {
   console.log(`File by: ${f.name}`);
   io.emit('file', f);
});

Now we can run the server and see everything in action.

multimedia chat part 2 end

In this part, we worked upon our app and added some more features. We can now handle file uploads/downloads and easily do typing indicators. In the next part, we will work on sending audio/video over websockets. You can find part 3 here.

Share :
Tags :