AngularChat
Older Article
This article was published 12 years ago. Some information may be outdated or no longer applicable.
One of the most visited projects of mine is the node.js/socket.io chat application that I’ve blogged about a few times before. I’ve ripped out the old frontend and rebuilt it with Angular. Here’s what changed.
Let’s start with the backend.
The code hasn’t changed drastically, but several things have been tightened up. I spotted cases where the application functioned correctly but the underlying logic was actually broken. (You know the meme: “My code doesn’t work, I don’t know why. My code works, I don’t know why.”)
Previously the entire chat server lived in one JavaScript file. Bad structure. I’ve split it up: app.js now bootstraps the application and wires up routes via Express.
I bolted on route support so the render() function can live outside app.js. Only one route exists (the root), but every other project I’ve built uses routes, so why not here too.
I also carved out a chatServer.js file for all the chat server functionality. Wiring it up is a bit tricky. In app.js I had to initialise a variable and pass the http server to it as a parameter:
var express = require('express'),
app = express(),
server = require('http').createServer(app),
routes = require('./routes'),
chatServer = require('./chatServer')(server);
The app.get('/', routes.index) part defines what route to call when someone hits the / (root) of the site.
Look at chatServer = require('./chatServer')(server) and why the server parameter has to be passed in.
chatServer.js contains the first initialisation of the io object. The socket needs a server (and port) to listen on, so it must be initialised like this:
var io = require('socket.io').listen(server);
That’s exactly why the server gets passed through.
Now app.js handles startup and routing, and chatServer.js handles the chat server. Cleanly separated. But there’s more to strip back.
The purge() function can also be pulled out so the code doesn’t look so cluttered. (If you haven’t seen the previous version, purge() handles socket removal scenarios: what happens when a room owner disconnects? Delete the room, notify everyone, remove them, run a pile of updates.)
I created a standalone purge.js under a utils folder and included it in chatServer.js:
var purgatory = require('./utils/purge');
(Innovative naming convention, isn’t it?)
I also created helper functions in utils/utils.js. These are wrappers for broadcasting messages to connected sockets. Instead of calling socket.emit() or io.sockets.emit() directly, the wrappers have more meaningful names:
module.exports.sendToSelf = function (socket, method, data) {
socket.emit(method, data);
};
module.exports.sendToAllConnectedClients = function (io, method, data) {
io.sockets.emit(method, data);
};
module.exports.sendToAllClientsInRoom = function (io, room, method, data) {
io.sockets.in(room).emit(method, data);
};
Other backend tweaks are smaller. I tightened up logic in a few functions and bolted on more meaningful method names for socket communication. I also stripped back some code. Previously, when a chat room owner disconnected or removed a room, I used various underscore.js methods to remove people from the room.people array. That was expensive. Turns out you can just do this:
room.people = 0;
Empties the array, keeps references intact. No surprises.
The frontend portion
Enough about the backend. The frontend got a complete overhaul, and working with Angular produced multiple “wow” moments. I love the framework. You don’t have to agree, but let me show you why.
The inspiration came from Brian Ford’s article on Socket.io & Angular. I used his sample code as a baseline and bolted my features on top.
To get socket.io working with Angular, I used Brian’s service and extended it. The service wraps the socket object returned by socket.io. Each socket callback gets wrapped in $scope.apply, which tells Angular to check the application state and update the views if something changed. He didn’t wrap every socket.io function, but I bolted on a few more (like disconnect). The tricky part: the on method runs asynchronously, so when socket.disconnect() gets called from within the application, an Angular context already exists. That triggers an “$apply already in progress” error. The fix:
app.factory('socket', function ($rootScope) {
var socket = io.connect();
var disconnect = false;
return {
//more code
disconnect: function() {
disconnect = true;
socket.disconnect();
}
}
} //etc
I bolted on two more services: one for geo-lookup (reused from my Twitter GeoTrending app) and a new one for user agent detection, so I can display icons showing whether a user connects from a PC or a mobile device.
app.factory('useragent', [
'$q',
'$rootScope',
'$window',
'useragentmsgs',
function ($q, $rootScope, $window, useragentmsgs) {
return {
getUserAgent: function () {
var deferred = $q.defer();
if ($window.navigator && $window.navigator.userAgent) {
var ua = $window.navigator.userAgent;
deferred.resolve(ua);
} else {
$rootScope.$broadcast(
'error',
useragentmsgs['errors.useragent.notFound']
);
$rootScope.$apply(function () {
deferred.reject(useragentmsgs['errors.useragent.notFound']);
});
}
return deferred.promise;
},
getIcon: function (ua) {
var deferred = $q.defer();
var icon = '';
if (
ua.match(/Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile/i)
) {
icon = 'mobile';
deferred.resolve(icon);
} else {
icon = 'desktop';
deferred.resolve(icon);
}
return deferred.promise;
},
};
},
]);
On top of the services I built a few directives. The first one auto-scrolls the overflowing container div that holds chat messages:
app.directive('autoscroll', function () {
return function (scope, element, attrs) {
var pos = element[0].parentNode.parentNode.scrollHeight;
$(element).parent().parent().animate(
{
scrollTop: pos,
},
1000
);
};
});
The other two capture focus and blur events on input boxes. (This behaviour was missing from Angular 1.2.14 at the time of writing.)
app.directive('onFocus', function () {
return {
restrict: 'A',
link: function (scope, el, attrs) {
el.bind('focus', function () {
scope.$apply(attrs.onFocus);
});
},
};
});
app.directive('onBlur', function () {
return {
restrict: 'A',
link: function (scope, el, attrs) {
el.bind('blur', function () {
scope.$apply(attrs.onBlur);
});
},
};
});
These two directives power the isTyping feature, which shows a message when someone is typing. It only fires when a field has focus and the user is in a room:
$scope.typing = function (event, room) {
if (event.which !== 13) {
if (typing === false && $scope.focussed && room !== null) {
typing = true;
socket.emit('typing', true);
} else {
clearTimeout(timeout);
timeout = setTimeout(timeoutFunction, 1000);
}
}
};
Most of the Angular/socket.io interaction lives inside the controller, though the initial connection to the socket server is set up in services.js.
The best part of working with Angular was the built-in directives: ng-show, ng-hide, and ng-if. Here’s why.
The frontend and backend pass objects back and forth containing data about connected people and available rooms. This gives flexibility but also creates some risk. The upside: user data gets attached directly to $scope, and any updates to the people object automatically refresh the view. It also lets you control the view with those directives. Look at this example, which controls when to show “join”, “leave”, or “delete” buttons for rooms. A person can “join” a room if they’re not in any room, “leave” a room they don’t own, and “delete” a room they do own:
<li ng-repeat="room in rooms" ng-cloak>
<form class="form-inline" role="form">
<div class="form-group"><p class="white">{{ room.name }}</p></div>
<button
class="btn btn-success btn-xs"
type="submit"
ng-click="joinRoom(room)"
ng-hide="room.id === user.owns || room.id === user.inroom || user.owns || user.inroom"
>
Join
</button>
<button
type="submit"
ng-click="deleteRoom(room)"
class="btn btn-xs btn-danger"
ng-show="room.id === user.owns"
>
Delete
</button>
<button
type="submit"
ng-click="leaveRoom(room)"
class="btn btn-xs btn-info"
ng-hide="room.id === user.owns || !user.inroom || user.owns || user.inroom !== room.id"
>
Leave
</button>
</form>
</li>
Clean, isn’t it?
Now the risk I mentioned. Security logic has to live on the backend. Anyone with Chrome DevTools can manipulate frontend code. The challenge was making sure that when a user gets removed from $scope.users on the frontend, they also get removed on the backend. So some functionality had to be mirrored.
The code is on GitHub with installation instructions.
The app is still being tested. The mobile version isn’t 100% there yet, and I’m still not a web designer, so expect some visual rough edges. If you find a bug, please report it.
More features coming. Stay tuned.