Facebook style chat with jQuery and Joomla

This time I’m going to go through the code for Paceville’s chat in a chronological order from the user’s point of view, as follows:

1.) We start with simply loading the page, and the code needed for correct display to begin with. We will now have a chat bar at the bottom and a button that will show available people for chatting with. Just like on Facebook.


2.) What needs to be done in order to show the list of people we can chat with.

3.) The code needed to create a new chat when we click one of the people in the list.

4.) The code needed to submit a message to the other person and what is needed in order to listen for that message and initiate a chat for the counterparty.

5.) Listening for chat messages back and forth.

6.) Closing a chat.

7.) Cleaning up inactive chats.

SQL for the tables:

CREATE TABLE IF NOT EXISTS `jos_chat_msgs` (
  `id` bigint(21) unsigned NOT NULL AUTO_INCREMENT,
  `from` bigint(12) NOT NULL,
  `to` bigint(12) NOT NULL,
  `message` text NOT NULL,
  `sent` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `recd` int(10) unsigned NOT NULL DEFAULT '0',
  `chat_id` bigint(21) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8

CREATE TABLE IF NOT EXISTS `jos_chats` (
  `id` bigint(21) NOT NULL AUTO_INCREMENT,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `user1` bigint(21) NOT NULL,
  `user2` bigint(21) NOT NULL,
  `user1_closed` tinyint(1) NOT NULL DEFAULT '0',
  `user2_closed` tinyint(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=MyISAM  DEFAULT CHARSET=utf8

The messages first, we’ve got the usual stuff, from id, to id, the message, send date for sorting, recd (shorthand for received yes/no) and the parent chat id. The chats keep track of its users and if one or both of them have closed the chat window, more on that later.

The CSS needed to draw the basic chat bar:

.c_bar{
	bottom:0;
	color:#fff;
	font-size:12px;
	height:26px;
	padding:0;
	position:fixed;
	right:0;
	width:100%;
	z-index:99;
	background: url(images/grey_btn_gradient.jpg) repeat-x;
}
paceville1.png

Note the use of position fixed in order to make it always stay at the bottom of the screen, note also the z-index of 99 to make sure it stays on top.

Let’s move on to the HTML needed to display this initial state (check the image to the right to get a feel for what it looks like):

<?php if(!cExt()->isGuest()): ?>
<div class="c_bar" id="c_bar">
	<div id="c_chat" class="c_chat clearfix">
		<div id="chat_btn" class="c_btn c_btn_normal">
			Chat (<strong><?php cExt()->chatEchoCount() ?></strong>)
		</div>
		
	</div>
	<div id="chat_tab_bar" class="chat_tab_bar">
		<?php foreach(cExt()->getAllChats() as $chat_id => $chat): ?>
			<div id="chatwin_<?php echo $chat_id ?>" class="c_chat_window">
				<div class="c_btn c_btn_extended">
					<img src="<?php echo $chat->thumb ?>" height="22" width="22"/>
					<div id="chatting_with_<?php echo $chat->id ?>" class="c_chat_name"><?php echo $chat->name ?></div>
					<a id="chat_close_<?php echo $chat_id ?>" class="cwin_close_btn"></a>
				</div>
				<div id="chat_flow_<?php echo $chat_id ?>" class="chat_flow">
					<?php foreach($chat->msgs as $msg): ?>
						<div><?php echo $msg->name.': '.$msg->message ?></div>
					<?php endforeach ?>
				</div>
				<input class="inputbox chat_input" id="chat_input_<?php echo $chat_id ?>" type="text" />
			</div>
		<?php endforeach ?>
	</div>
</div>
<?php endif ?>

So if we’re a logged in and registered member we will see the bar, and we will also see a count of the available number of persons we can chat with on the button (check the image above to see what it looks like).

GetAllChats will not return anything yet since we’re not chatting with anyone so I will leave its explanation for later. Anyway, the HTML being printed in the loop are the chat windows, if we had had any that is.

Note that this PHP/HTML is in the main index.php of our Joomla template, therefore it will show on all pages.

jQuery("#chat_btn").toggle(
	function(){
		jQuery.post("/ajax.php", {func: 'chatFetchOnline'}, function(ret){
			jQuery("#c_chat").removeClass("c_chat").addClass("c_chat_extended");
			jQuery("#chat_btn").removeClass("c_btn_normal").addClass("c_btn_extended");
			jQuery("#chat_btn").after(ret);
			jQuery(".c_chat_user").click(function(){
				var usr_id 		= jQuery(this).attr("id").split('_').pop();
				var name 		= jQuery(this).find(".c_chat_name:first").html();
				var thumb 		= jQuery(this).find("img").attr("src");
				createNewChat(usr_id, name, thumb, 0);
			});
		});
	},
	function(){
		jQuery(".c_chat_user").remove();
		jQuery("#c_chat").removeClass("c_chat_extended").addClass("c_chat");
		jQuery("#chat_btn").removeClass("c_btn_extended").addClass("c_btn_normal");
	}
);

The above JavaScript is run when the document has finished loading, here we setup the button that will display the list of available people b way of ajax. It will flip up and down with the help of the toggle functionality.

As you can see we are calling the PHP function chatFetchOnline:

function chatFetchOnline($return = 'html'){
	$sql = "SELECT DISTINCT c.*, u.*, c.params AS c_params FROM jos_session AS s 
			LEFT JOIN jos_users AS u ON u.id = s.userid
			LEFT JOIN jos_community_users AS c ON u.id = c.userid 
			WHERE s.guest = 0";
	$tmp = $this->loadObjectList($sql);
	$friend_ids = Common::objsExtractPart($this->getFriends(), 'id');
	$users = array();
	$my_id = $this->getLoggedIn()->id;
	foreach($tmp as $user){
		if($my_id != $user->id){
			if(stripos($user->c_params, 'privacyChat=30') !== false && in_array($user->id, $friend_ids))
				$users[] = $user;
			else if(stripos($user->c_params, 'privacyChat=20') !== false || stripos($user->c_params, 'privacyChat') === false)
				$users[] = $user;
		}
	}
	
	if($return == 'html'){
		$this->assign("online", $users);
		echo $this->fetch("chat_online_list");
	}else
		return $users;
}

After the result of this function is passed to the JavaScript it could look like in the picture to the right.

What’s happening here is that we’re first creating an array of all the ids of our friends, we then loop through all currently logged in users. If a user has set that he/she should only show for friends we will check if that person is in our array with ids.

If the user has set privacyChat=20 it means that user will show to the whole community, not just friends, the same goes if the setting doesn’t even exist. Now why we’re using the values we’re using and so on for the chat privacy settings is a completely different story.

The different return values are currently needed only because the chatEchoCount function (in the second listing) is currently simply count()ing them to get the number.

function toggleChat(chat){
	chat.find(".c_btn").click(function(){
		if(chat.css("margin-top") == "0px")
			chat.css("margin-top", "-275px");
		else
			chat.css("margin-top", "0px");	
	});
}

function setupChatInput(chat_id){
	var chat = jQuery("#chatwin_" + chat_id);
	var usr_id = chat.find("div[id^='chatting_with_']").attr('id').split('_').pop();
	jQuery("#chat_input_"+chat_id).keydown(function(event){
		if(event.keyCode == 13){
			var msg = jQuery(this).val();
			jQuery(this).val('');
			jQuery.post("/ajax.php", {"func": 'saveChatMsg', "to": usr_id, "message": msg, "chat_id": chat_id}, function(res){
				jQuery("#chat_flow_"+chat_id).append('<div>'+res+': '+msg+'</div>');
			});
		}
	});
}

function setupChatClose(){
	jQuery("a[id^='chat_close_']").click(function(){
		var chat_id = getId(this);
		jQuery("#chatwin_" + chat_id).remove();
		jQuery.post("/ajax.php", {func: 'closeChatWin', id: chat_id});
	});
}

function createNewChat(usr_id, name, thumb, new_id){
	if (jQuery("#chatting_with_" + usr_id).size() == 0) {
		jQuery.post("/ajax.php", {func: 'createChat', userid: usr_id}, function(res){
			createNewChatCommon(usr_id, name, thumb, res);
		});
		return true;
	}else
		return false;
}

function createNewChatCommon(usr_id, name, thumb, new_id){
	var new_chat = jQuery("#chat_tpl").clone();
	new_chat.prependTo("#chat_tab_bar").css("margin-top", "-275px");
	new_chat.attr("id", "chatwin_" + new_id);
	new_chat.find(".c_chat_name").attr("id", "chatting_with_" + usr_id).html(name);
	new_chat.find("img").attr("src", thumb);
	new_chat.find("input").attr("id", "chat_input_" + new_id);
	new_chat.find(".chat_flow").attr("id", "chat_flow_" + new_id);
	new_chat.find("a[id^='chat_close_']").attr("id", 'chat_close_' + new_id);
	toggleChat(new_chat);
	setupChatInput(new_id);
	setupChatClose();
	new_chat.show('slow');
}

So the above is the JavaScript needed in order to create a new chat window, it is at the core of the whole application and it’s executed when we click someone in the list.

The main thing here is setting various ids on elements that previously had none, the chat_tpl div is at the bottom of the site HTML and is hidden, it gets copied every time we create a new chat window. Its HTML is identical to the HTML in listing 2 above.

SetupChatInput will bestow the input box with the required functionality, on enter we will send the chat message to PHP, we will also create a new DIV with the contents of the message (more on that later).

Everything now looks like in the picture to the right.

function createChat(){
	$user1 		= $this->getLoggedIn()->id;
	$user2 		= $this->postArr['userid'];
	$sql 		= "SELECT DISTINCT id FROM jos_chats WHERE (user1 = $user1 AND user2 = $user2) OR (user1 = $user2 AND user2 = $user1)";
	$chat_id 	= $this->loadResult($sql);
	
	if(!empty($chat_id))
		echo $chat_id;
	else{
		$chat 			= new stdClass();
		$chat->user1 	= $user1;
		$chat->user2 	= $user2;
		$this->insertObject('jos_chats', $chat);
		echo $this->db()->insertid();
	}
}

And the chat inserting PHP. Note that we don’t know who initiated the chat, hence the convoluted SQL, the alternative would be to have an extra table for that but that felt even less palatable.

If we get an id from the database query we simply return it, otherwise we create the chat and use db()->insertid() to return its id.

function saveChatMsg(){
	$obj = $this->arrToObj($this->postArr, array("message", "to", "chat_id"));
	$obj->from = $this->getLoggedIn()->id;
	$this->insertObject('jos_chat_msgs', $obj);
	echo $this->getLoggedIn()->name;
}

Here is the PHP that gets called each time we submit a message, we need the message text itself of course and who is the recipient and the id of the chat it belongs to.

Everything now looks like in the picture to the right for the recipient of our message.

function checkChatInit(){
	jQuery.post("/ajax.php", {func: "getChattingWith"}, function(res){
		var msgs = eval('('+res+')');
		jQuery.each(msgs, function(){
			if (jQuery("#chatwin_" + this.chat_id).size() == 0) {
				createNewChatCommon(this.id, this.name, this.thumb, this.chat_id);
				jQuery("#chat_flow_"+this.chat_id).append('<div>'+this.name+': '+this.message+'</div>');
			}
		});
	});
}

At the other end the above checkChatInit function is being run every 60 seconds. It will make use of createNewChatCommon to run exactly the same logic being run if we click someone in the available for chatting list.

And the corresponding PHP:

function chatWinAction($id, $action){
	$id 	= $id == false ? $this->postArr['id'] : $id;
	$my_id 	= $this->getLoggedIn()->id;
	$chat 	= $this->loadObject("SELECT * FROM jos_chats WHERE id = $id");
	
	if($chat->user1 == $my_id)
		$chat->user1_closed = $action;
	else
		$chat->user2_closed = $action;
	
	$this->updateObject('jos_chats', array('user1_closed', 'user2_closed'), 'id', $chat);
}

function closeChatWin(){
	$this->chatWinAction(false, 1);
}

function getChattingWith(){
	$this->cleanUpChats();
	$my_id = $this->getLoggedIn()->id;
	$sql = "SELECT u.id, u.name, u.username, cu.thumb, msg.message, msg.chat_id, msg.id AS msgid FROM jos_chat_msgs msg 
			LEFT JOIN jos_users AS u ON u.id = msg.from
			LEFT JOIN jos_community_users AS cu ON cu.userid = u.id
			WHERE msg.to = $my_id AND msg.recd = 0";
	$msgs = $this->loadObjectList($sql);
	foreach($msgs as $msg){
		$this->exec("UPDATE jos_chat_msgs SET recd = 1 WHERE id = ".$msg->msgid);
		$this->chatWinAction($msg->chat_id, 0);
	}
	echo json_encode($msgs);
}

We’ll get to cleanUpChats later, at the moment it’s good to know though that it’s needed in order to display the chat windows correctly.

ChatWinAction is used to keep track of who has closed the chat window in question. When a chat window has been closed by only one of the participants and the person who still has it open on his/her part sends a message we trigger code that will open it again for the other person and set it to open. When a chat window has been closed by both participants it will be cleaned out, more on that later.

The returned JSON will in this case contain the chat info and old messages in it, that way when a chat window re-displays it will contain the chat history, pretty common practice in the IM world.

function fetchNewMsgs(){
	var chats = jQuery("div[id^='chatwin_']");
	if(chats.size() > 0){
		var ids = '';
		jQuery.each(chats, function(){ ids += getId(jQuery(this)) + ' '; });
		jQuery.post("/ajax.php", {func:"fetchNewChatMsgs", chat_ids:ids}, function(res){
			var tmp = eval('('+res+')');
			
			if(tmp.remove != false){
				jQuery.each(tmp.remove, function(){
					jQuery("#chatwin_" + this).hide("slow").remove();
				});
			}
			
			jQuery.each(tmp.msgs, function(){
				jQuery("#chat_flow_"+this.chat_id).append('<div>'+this.name+': '+this.message+'</div>');
			});
		});
	}
}

The returned JSON will contain all new messages, the chat id will be used in the JavaScript to update all the chat windows at the same time with their respective messages, having each chat update individually could potentially result in a lot of requests to the sever, doing like this is more efficient.

Note the remove flag, if a chat is set to be removed it will simply disappear, that can happen if the other person has logged out.

And the PHP:

function fetchNewChatMsgs(){
	$my_id = $this->getLoggedIn()->id;
	$id_arr_post = explode(' ', trim($this->postArr['chat_ids']));
	
	$ids = implode(',', $id_arr_post);
	
	$sql = "SELECT u.id, u.name, u.username, msg.message, msg.chat_id, msg.id AS msgid FROM jos_chat_msgs msg 
			LEFT JOIN jos_users AS u ON u.id = msg.from
			WHERE msg.chat_id IN($ids) AND msg.to = $my_id AND msg.recd = 0";
	$msgs = $this->loadObjectList($sql);

	$chat_ids = array();
	foreach($msgs as $msg)
		$this->exec("UPDATE jos_chat_msgs SET recd = 1 WHERE id = ".$msg->msgid);
	
	$chat_ids 		= $this->loadResultArray("SELECT id FROM jos_chats WHERE (user1 = $my_id OR user2 = $my_id)");
	$remove_ids 	= array_diff($id_arr_post, array_unique($chat_ids));
	$robj 			= new stdClass();
	$robj->remove 	= empty($remove_ids) ? false : $remove_ids;
	$robj->msgs 	= $msgs;
	
	echo json_encode($robj);
}

This is probably the most complex PHP logic in the whole application. The fetchNewMsgs JS function above will send a list of chat ids to the PHP, the ids will then be used to fetch only messages belonging to these chats.

We will then compare the ids from the JavaScript with the chats that the user in question is actually participating in, all chats that are missing in the database will be marked to be removed.

Damn I just realized that the above code could easily be optimized, can you see where? Check the listing below for the answer.

To the right is what the chat looks like at the moment now that we have fetched a reply from the guy we are chatting with.

function getAllChats(){
	$my_id = $this->getLoggedIn()->id;

	$sql = "SELECT DISTINCT u.id, u.name, u.username, cu.thumb, chat.user1, chat.user1_closed, chat.user2, chat.user2_closed, chat.id AS chatid
			FROM jos_chats chat
			LEFT JOIN jos_users AS u ON u.id = IF($my_id = chat.user1,chat.user2,chat.user1)
			LEFT JOIN jos_community_users AS cu ON cu.userid = u.id
			WHERE (chat.user1 = $my_id OR chat.user2 = $my_id)";
	
	$chats = array();
	$chat_ids = array();
	foreach($this->loadObjectList($sql) as $chat){
		if( !($chat->user1_closed == 1 && $chat->user1 == $my_id) && !($chat->user2_closed == 1 && $chat->user2 == $my_id) ){
			$chat->msgs = array();
			$chats[ $chat->chatid ] = $chat;
			$chat_ids[] = $chat->chatid;
		}
	}
	
	$ids_str = implode(',',$chat_ids);
	$sql = "SELECT DISTINCT u.id, u.name, u.username, msg.message, msg.id AS msgid, msg.chat_id
			FROM jos_chat_msgs msg 
			LEFT JOIN jos_users AS u ON u.id = msg.from
			WHERE msg.chat_id IN($ids_str) ORDER BY msg.sent";

	$msgs = $this->loadObjectList($sql);

	foreach($msgs as $msg)
		$chats[ $msg->chat_id ]->msgs[] = $msg;
	
	$this->exec("UPDATE jos_chat_msgs SET recd = 1 WHERE chat_id IN($ids_str)");

	return $chats;
}

We’re starting to come full circle, If I refresh the page the above PHP will now return all the chats I’m participating in, and all their messages, we build them from scratch here in a lowered state.

The alternative would be to have the JS issue an ajax call after the page has finished loading but that would require two roundtrips to the server in order to load the page and I don’t like that.

After refreshing the chat will look like in the image to the right. If I click the header it will popup with all the old messages.

jQuery("div[id^='chatwin_']").each(function(){ 
	toggleChat(jQuery(this));
	setupChatInput(getId(jQuery(this)));
});

setupChatClose();

setInterval("updateChatCount()", 60000);
setInterval("checkChatInit()", 10000);
setInterval("fetchNewMsgs()", 5000);

And the startup JS, we setup the already loaded chats whose existence was explained in the above listing. After that we set the intervals, we will check for a new count every minute, newly initiated chats every 10 seconds and for new messages to existing chats every 5 seconds.

function cleanUpChats(){
	
	$sess_ids = implode(',', $this->loadResultArray("SELECT userid FROM jos_session"));
	
	$sql = "SELECT * FROM jos_chats WHERE (
			user1 NOT IN($sess_ids) OR user2 NOT IN($sess_ids) 
			OR (user1_closed = 1 AND user2_closed = 1))";
	
	foreach($this->loadObjectList($sql) as $chat){
		$this->exec("DELETE FROM jos_chat_msgs WHERE chat_id = {$chat->id}");
		$this->deleteObject('jos_chats', 'id', $chat);
	}
}

Finally, the cleanup code, we will delete a chat window if one of the participants have logged out or if they both have closed the window in question.

Related Posts

Tags: , , , , ,