/ Programming

Building a custom realtime chat module for Drupal 7 (part 3)

If you've followed along with my previous two posts in this series, you should now have a functioning chat on your Drupal 7 site.

If not, start with those: Part 1, Part 2

That's kind of exciting, right? But we can make it better!

So, you want pasted in links to turn into actual clickable links, eh? Good, I wanted that too so here's how we do it...

First, open up the chat_server.js file. We're going to add a function to it. This function should come somewhere before the io.sockets.on() bit at the end of the file:

function linkify(inputText) {
  //URLs starting with http://, https://, or ftp://
  var replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
  var replacedText = inputText.replace(replacePattern1, '<a href="$1" target="_blank">$1</a>');
 
  //URLs starting with www. (without // before it, or it'd re-link the ones done above)
  var replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
  var replacedText = replacedText.replace(replacePattern2, '$1<a href="http://$2" target="_blank">$2</a>');
 
  //Change email addresses to mailto:: links
  var replacePattern3 = /(\w+@[a-zA-Z_]+?\.[a-zA-Z]{2,6})/gim;
  var replacedText = replacedText.replace(replacePattern3, '<a href="mailto:$1">$1</a>');
 
  return replacedText
}

Great! That function will handle the linkification of most URLs. If it's got an http, https, or ftp in front of it it'll become a link for sure, even really complex long URLs with a lot of parameters. If it starts with www. it'll also become a link, and this should even turn email addresses into clickable mailto: links. Neat huh?

We also need this exact same function in our template. That's a little bit of a bummer, but since your own chat messages are directly displayed on your own screen they don't go through the linkification on the server side. I suppose a compromise would be to always handle it on the client side and omit this from the chat_server.js file altogether, but there's also something to be said for letting the server do as much of the work as possible.

Ok, so we've got that function in both our chat_server.js and example_chat.tpl.php files now. (In the example_chat.tpl.php file it should come between the socket.on('error' ... line and the function message line)

Next is to update our user message event on the server side:

  socket.on('user message', function (msg) {
    socket.broadcast.emit('user message', socket.nickname, linkify(msg));
  });

and the #send-message submit (jQuery) function on the client side:

$('#send-message').submit(function () {
  message(myNick, linkify($('#message').val()));
  socket.emit('user message', $('#message').val());
  clear();
  $('#lines').get(0).scrollTop = 10000000;
  return false;
});

Great. Give it a try now. Pasting links in should turn them into clickable links

Part 2: Timestamps

Sometimes it's nice to know when someone said something in chat, and that's where timestamps come in.
This is also going to really be useful when we get to the next step.

Again our timestamp function is going to be added to both the chat_server.js and the client side template file. Essentially that function will be something like this:

function tstamp() {
  var currentTime = new Date();
  var days = new Array('Sun', 'Mon', 'Tue', 'Wed', 'Thr', 'Fri', 'Sat');
  var day = currentTime.getDay();
  var hours = currentTime.getHours();
  var minutes = currentTime.getMinutes();
  if (minutes < 10) {
    minutes = "0" + minutes;
  }
  if (hours > 11) {
    var ap = 'p';
  }
  else {
    var ap = 'a';
  }
  if (hours > 12) {
    hours = hours - 12;
  }
 
  return "["+ days[day] + " " + hours + ":" + minutes + ap + "m] ";
}

Of course, you might want to use a different date format, since this just says Day Hour:Min AM/PM, like this: Sat 10:08am it might not be useful for your purposes, so feel free to change the output format as needed.
(more information on working with the Javascript Date object)

So, for this to work we need to add it to our user message event on the server like so:

socket.on('user message', function (msg) {
  socket.broadcast.emit('user message', tstamp(), socket.nickname, linkify(msg));
});

And we need to update the message function on the client side:

function message (msg_time, from, msg) {
  $('#lines').append($('<p>').append($('<small>').text(msg_time)).append($('<b>').text(from), linkify(msg)));
}

As well as the send message jQuery submit function:

$('#send-message').submit(function () {
  message(tstamp(), myNick, $('#message').val());
  socket.emit('user message', $('#message').val());
  clear();
  $('#lines').get(0).scrollTop = 10000000;
  return false;
});

So now we should have linked and timestamped messages.

How about a checkbox to toggle visibility of the timestamps? Well that's simple enough.

As the last line of the example_chat.tpl.php add the checkbox markup:

<small><input id="show-timestamps" type="checkbox" checked="checked" /> Show timestamps</small>

Then add a little jQuery magic within our document.ready function in the same file:

$('input#show-timestamps').click(function() {
  if ($(this).is(':checked')) {
    $('#messages p small').show();
  }
  else {
    $('#messages p small').hide();
  }
})

Nice. Now your users can disable timestamps if they don't want them on their screen.

One last change and we'll have a very useful little chat system in place on our Drupal 7 site...

Step 3: Chat History Log

The problem with our current system is that if you leave and return to chat, or even just refresh the page you lose all the previous discussion. That's to be expected of course, but it's not terribly useful.

Wouldn't it be great if, upon joining the chat (or rejoining, or refreshing the page) you can see what's been discussed within the past, oh.. say half hour?

You bet it would! Let's get that working, shall we?

We're going to do our log all in memory, but if you wanted to write it out to disk or even a database that wouldn't be terribly difficult to implement. But for our purposes here in this tutorial, storing the log in memory on the server is all we need. So, the first thing we'll need is a variable to store the log on the server side. Open up the chat_server.js file and add the following line right after the declaration of the nicknames variable on line 7:

var log = {};

Cool, now we have somewhere to store our log entries.

Next, we need a function to update the log. That will look something like this:

function updateLog(type, nick, msg) {
  var curTime = new Date();
  if (typeof type != 'undefined') {
    log[curTime.getTime()] = {'type': type, 'nick': nick, 'msg': msg};
  }
  var i;
  for (i in log) {
    // Cull the log, removing entries older than a half hour.
    if (i < (curTime.getTime() - 1800000)) {
      delete log[i];
    }
  }
}

Lets break that down a little bit..

We're going to pass in the type of activity, username, and their message. The type isn't overly necessary because we really only need to track messages, not joins and parts, but including it will give us some room to grow in the future should we choose to.

So, first, we set the curTime variable to the current time.

It's worth noting, because I wasn't immediately aware of it; in JavaScript time is based on milliseconds, rather than unix time based on seconds. So, in JavaScript Date().getTime() / 1000 is the same as calling time() in php.

Ok, so we've got the time. Next we're going to look and see if the type variable is undefined, if so we won't try to add an entry to the log, we'll just proceed to culling old entries from the log. If it's NOT undefined, then we add a new entry to the log, using the current time value as the key.

Finally, we'll loop through each element in the log, and check if any of the keys are older than 30 minutes ago (1800000 milliseconds == 30 minutes), and if older than that we remove that entry from the log.

Next, we need to call our updateLog function to put data into it. Since we're not concerned about logging connect and disconnect announcements, we'll just add a call to it in the user message event handler:

socket.on('user message', function (msg) {
  socket.broadcast.emit('user message', tstamp(), socket.nickname, linkify(msg));
  updateLog('user message', socket.nickname, msg);
});

Great, this will put user message event entries into our log with timestamps of when they occur.

One last addition to our server side code is necessary so that we have a way of retreiving our log. So, within the io.sockets.on section, we'll add a new event handler get log like so:

socket.on('get log', function () {
  updateLog(); // Ensure old entries are cleared out before sending it.
  io.sockets.emit('chat log', log);
});

Alright, when that event fires, we'll hit the updateLog function without passing in any data so that any outdated entries are cleared out of the log, then fire the response back to the client and fire the client side chat log event, which we'll implement next. Since it's possible our chat server will sit idle for extended periods, we need to call the updateLog function to clear out old entries otherwise if someone joins the chat several hours after it's last been used the log will still include a half hour worth of messages from whenever the last message was sent. That's not very useful, so we update then send, always.

Ok, on to the client side. This will require a handful of changes in a few places within the javascript of our example_chat.tpl.php file.

First, we want a variable to indicate that a user is new, so near the top of the file right after declaring the myNick variable, we'll add another:

var newlyJoined = true;

This variable basically says that by default when the page loads we will consider this user as new.

Next we'll need something to fire the server side get log event, so we're going to update the socket.emit function that sets our users nickname to also fire that event:

socket.emit('nickname', '<?php print $username ?>', function (nick) {
  if (nick != 'me') {
    myNick = nick;
    socket.emit('get log');
    return $('#chat').addClass('nickname-set');
  }
});

Great, now upon joining the chat the user name will be set and the server side get log event will fire, but we still need to add the client side event to handle the response from the server.

More toward the top of the file, between the socket.on('error' .. line and the message function, we'll add our client side event handler code:

socket.on('chat log', function(chatlog) {
  if (newlyJoined) {
    var i = 0;
    for (stamp in chatlog) {
      if (chatlog[stamp].type == 'user message') {
        var ts   = tstamp(stamp);
        var nick = chatlog[stamp].nick
        var msg  = chatlog[stamp].msg
        message(ts, nick, msg);
        i++;
      }
    }
    if (i > 0) {
      $('#lines').append($('<hr>')).append($('<small style="text-align:center; display:block; color: #888;">').text('Chat messages posted within the past half hour appear above this line.')).append($('<hr>'));
      $('#lines').get(0).scrollTop = 10000000;       
    }
    newlyJoined = false;
  }
});

Lets break that down a bit.

Remember when the server sends its response to fire the client side chat log event, it also sends back the log variable. So, our event will grab that value and pass it through the handler function. If the value of the newlyJoined variable is false, this event won't do anything. This way people who have already been in the chat room won't be bombarded with logs every time a new user joins. There might be a better way of doing this, but this implementation is working great for me.

So, next we initialize a counter variable, and then loop through each element of the chatlog variable we received from the server.

You'll recall we're only actually putting user message type entries into the log, but again so things are easier to expand on in the future we'll do the check on each entry to be sure it's of that type. This way if you want to add additional types in the future, you can simple add a new if statement to handle them.

Within the if statement, we're grabbing values from the current chatlog entry, and assigning them to new variables. You'll notice that we're passing a value through our timestamp function tstamp(), but if you recall from earlier we didn't add support for that, so we'll have to do that too before we're done.

Next each user message entry in the log is passed off to the message function and the counter is increased. After we finish our loop, we check if the value of our counter is greater than 0, and if so output a nice message indicating that messages from within the past half hour appear above the line. The scrollTop line ensures that if there are enough messages to cause the message area of our chat interface to have a scrollbar that we scroll down to the end of the list and older entries are scolled off the top of the message area.

Finally, we set our newlyJoined variable to false so that when the next user joins this event won't fire again for this user.

One last update to our timestamp function so that it can take a variable and we'll be done!

function tstamp(stamp) {
  var currentTime = new Date(); 
  if (typeof stamp != 'undefined') {
    currentTime.setTime(stamp);
  }
  var days = new Array('Sun', 'Mon', 'Tue', 'Wed', 'Thr', 'Fri', 'Sat');
  var day = currentTime.getDay();
  var hours = currentTime.getHours();
  var minutes = currentTime.getMinutes();
  if (minutes < 10) {
    minutes = "0" + minutes;
  }
  if (hours > 11) {
    var ap = 'p';
  }
  else {
    var ap = 'a';
  }
  if (hours > 12) {
    hours = hours - 12;
  }
  return "["+ days[day] + " " + hours + ":" + minutes + ap + "m] ";
}

Ok, so now we've told the function to accept a variable, and if that variable is not undefined, set the time of our currentTime date object variable to the timestamp value of the log entry.

That's the whole shebang!

Building a custom realtime chat module for Drupal 7 (part 3)
Share this